Skip to content

Commit 4b281bd

Browse files
feat: enable pausing handling keystrokes in watch mode (#2041)
* feat: enable pausing handling keystrokes in watch mode * fix: add proper error handling * chore: code review improvements.
1 parent 92b85e6 commit 4b281bd

File tree

4 files changed

+165
-31
lines changed

4 files changed

+165
-31
lines changed

packages/cli-plugin-metro/src/commands/start/watchMode.ts

Lines changed: 40 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ import {logger, hookStdout} from '@react-native-community/cli-tools';
33
import execa from 'execa';
44
import chalk from 'chalk';
55
import {Config} from '@react-native-community/cli-types';
6+
import {KeyPressHandler} from '../../tools/KeyPressHandler';
7+
8+
const CTRL_C = '\u0003';
9+
const CTRL_Z = '\u0026';
610

711
function printWatchModeInstructions() {
812
logger.log(
@@ -37,38 +41,43 @@ function enableWatchMode(messageSocket: any, ctx: Config) {
3741
}
3842
});
3943

40-
process.stdin.on('keypress', (_key, data) => {
41-
const {ctrl, name} = data;
42-
if (ctrl === true) {
43-
switch (name) {
44-
case 'c':
45-
process.exit();
46-
break;
47-
case 'z':
48-
process.emit('SIGTSTP', 'SIGTSTP');
49-
break;
50-
}
51-
} else if (name === 'r') {
52-
messageSocket.broadcast('reload', null);
53-
logger.info('Reloading app...');
54-
} else if (name === 'd') {
55-
messageSocket.broadcast('devMenu', null);
56-
logger.info('Opening developer menu...');
57-
} else if (name === 'i' || name === 'a') {
58-
logger.info(`Opening the app on ${name === 'i' ? 'iOS' : 'Android'}...`);
59-
const params =
60-
name === 'i'
61-
? ctx.project.ios?.watchModeCommandParams
62-
: ctx.project.android?.watchModeCommandParams;
63-
execa('npx', [
64-
'react-native',
65-
name === 'i' ? 'run-ios' : 'run-android',
66-
...(params ?? []),
67-
]).stdout?.pipe(process.stdout);
68-
} else {
69-
console.log(_key);
44+
const onPress = (key: string) => {
45+
switch (key) {
46+
case 'r':
47+
messageSocket.broadcast('reload', null);
48+
logger.info('Reloading app...');
49+
break;
50+
case 'd':
51+
messageSocket.broadcast('devMenu', null);
52+
logger.info('Opening Dev Menu...');
53+
break;
54+
case 'i':
55+
logger.info('Opening app on iOS...');
56+
execa('npx', [
57+
'react-native',
58+
'run-ios',
59+
...(ctx.project.ios?.watchModeCommandParams ?? []),
60+
]).stdout?.pipe(process.stdout);
61+
break;
62+
case 'a':
63+
logger.info('Opening app on Android...');
64+
execa('npx', [
65+
'react-native',
66+
'run-android',
67+
...(ctx.project.android?.watchModeCommandParams ?? []),
68+
]).stdout?.pipe(process.stdout);
69+
break;
70+
case CTRL_Z:
71+
process.emit('SIGTSTP', 'SIGTSTP');
72+
break;
73+
case CTRL_C:
74+
process.exit();
7075
}
71-
});
76+
};
77+
78+
const keyPressHandler = new KeyPressHandler(onPress);
79+
keyPressHandler.createInteractionListener();
80+
keyPressHandler.startInterceptingKeyStrokes();
7281
}
7382

7483
export default enableWatchMode;
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import {
2+
CLIError,
3+
addInteractionListener,
4+
logger,
5+
} from '@react-native-community/cli-tools';
6+
7+
/** An abstract key stroke interceptor. */
8+
export class KeyPressHandler {
9+
private isInterceptingKeyStrokes = false;
10+
11+
constructor(public onPress: (key: string) => void) {}
12+
13+
/** Start observing interaction pause listeners. */
14+
createInteractionListener() {
15+
// Support observing prompts.
16+
let wasIntercepting = false;
17+
18+
const listener = ({pause}: {pause: boolean}) => {
19+
if (pause) {
20+
// Track if we were already intercepting key strokes before pausing, so we can
21+
// resume after pausing.
22+
wasIntercepting = this.isInterceptingKeyStrokes;
23+
this.stopInterceptingKeyStrokes();
24+
} else if (wasIntercepting) {
25+
// Only start if we were previously intercepting.
26+
this.startInterceptingKeyStrokes();
27+
}
28+
};
29+
30+
addInteractionListener(listener);
31+
}
32+
33+
private handleKeypress = async (key: string) => {
34+
try {
35+
logger.debug(`Key pressed: ${key}`);
36+
this.onPress(key);
37+
} catch (error) {
38+
return new CLIError(
39+
'There was an error with the key press handler.',
40+
(error as Error).message,
41+
);
42+
} finally {
43+
return;
44+
}
45+
};
46+
47+
/** Start intercepting all key strokes and passing them to the input `onPress` method. */
48+
startInterceptingKeyStrokes() {
49+
if (this.isInterceptingKeyStrokes) {
50+
return;
51+
}
52+
this.isInterceptingKeyStrokes = true;
53+
const {stdin} = process;
54+
stdin.setRawMode(true);
55+
stdin.resume();
56+
stdin.setEncoding('utf8');
57+
stdin.on('data', this.handleKeypress);
58+
}
59+
60+
/** Stop intercepting all key strokes. */
61+
stopInterceptingKeyStrokes() {
62+
if (!this.isInterceptingKeyStrokes) {
63+
return;
64+
}
65+
this.isInterceptingKeyStrokes = false;
66+
const {stdin} = process;
67+
stdin.removeListener('data', this.handleKeypress);
68+
stdin.setRawMode(false);
69+
stdin.resume();
70+
}
71+
}

packages/cli-tools/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,6 @@ export {getLoader, NoopLoader, Loader} from './loader';
1212
export {default as findProjectRoot} from './findProjectRoot';
1313
export {default as printRunDoctorTip} from './printRunDoctorTip';
1414
export * as link from './doclink';
15+
export * from './prompt';
1516

1617
export * from './errors';

packages/cli-tools/src/prompt.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import prompts, {Options, PromptObject} from 'prompts';
2+
import {CLIError} from './errors';
3+
import logger from './logger';
4+
5+
type PromptOptions = {nonInteractiveHelp?: string} & Options;
6+
type InteractionOptions = {pause: boolean; canEscape?: boolean};
7+
type InteractionCallback = (options: InteractionOptions) => void;
8+
9+
/** Interaction observers for detecting when keystroke tracking should pause/resume. */
10+
const listeners: InteractionCallback[] = [];
11+
12+
export async function prompt(
13+
question: PromptObject,
14+
options: PromptOptions = {},
15+
) {
16+
pauseInteractions();
17+
try {
18+
const results = await prompts(question, {
19+
onCancel() {
20+
throw new CLIError('Prompt cancelled.');
21+
},
22+
...options,
23+
});
24+
25+
return results;
26+
} finally {
27+
resumeInteractions();
28+
}
29+
}
30+
31+
export function pauseInteractions(
32+
options: Omit<InteractionOptions, 'pause'> = {},
33+
) {
34+
logger.debug('Interaction observers paused');
35+
for (const listener of listeners) {
36+
listener({pause: true, ...options});
37+
}
38+
}
39+
40+
/** Notify all listeners that keypress observations can start.. */
41+
export function resumeInteractions(
42+
options: Omit<InteractionOptions, 'pause'> = {},
43+
) {
44+
logger.debug('Interaction observers resumed');
45+
for (const listener of listeners) {
46+
listener({pause: false, ...options});
47+
}
48+
}
49+
50+
/** Used to pause/resume interaction observers while prompting (made for TerminalUI). */
51+
export function addInteractionListener(callback: InteractionCallback) {
52+
listeners.push(callback);
53+
}

0 commit comments

Comments
 (0)