286 lines
11 KiB
JavaScript
286 lines
11 KiB
JavaScript
import { EventEmitter } from 'node:events';
|
|
import process from 'node:process';
|
|
import React, { PureComponent } from 'react';
|
|
import cliCursor from 'cli-cursor';
|
|
import AppContext from './AppContext.js';
|
|
import StdinContext from './StdinContext.js';
|
|
import StdoutContext from './StdoutContext.js';
|
|
import StderrContext from './StderrContext.js';
|
|
import FocusContext from './FocusContext.js';
|
|
import ErrorOverview from './ErrorOverview.js';
|
|
const tab = '\t';
|
|
const shiftTab = '\u001B[Z';
|
|
const escape = '\u001B';
|
|
// Root component for all Ink apps
|
|
// It renders stdin and stdout contexts, so that children can access them if needed
|
|
// It also handles Ctrl+C exiting and cursor visibility
|
|
export default class App extends PureComponent {
|
|
static displayName = 'InternalApp';
|
|
static getDerivedStateFromError(error) {
|
|
return { error };
|
|
}
|
|
state = {
|
|
isFocusEnabled: true,
|
|
activeFocusId: undefined,
|
|
focusables: [],
|
|
error: undefined,
|
|
};
|
|
// Count how many components enabled raw mode to avoid disabling
|
|
// raw mode until all components don't need it anymore
|
|
rawModeEnabledCount = 0;
|
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
internal_eventEmitter = new EventEmitter();
|
|
// Determines if TTY is supported on the provided stdin
|
|
isRawModeSupported() {
|
|
return this.props.stdin.isTTY;
|
|
}
|
|
render() {
|
|
return (React.createElement(AppContext.Provider
|
|
// eslint-disable-next-line react/jsx-no-constructed-context-values
|
|
, {
|
|
// eslint-disable-next-line react/jsx-no-constructed-context-values
|
|
value: {
|
|
exit: this.handleExit,
|
|
} },
|
|
React.createElement(StdinContext.Provider
|
|
// eslint-disable-next-line react/jsx-no-constructed-context-values
|
|
, {
|
|
// eslint-disable-next-line react/jsx-no-constructed-context-values
|
|
value: {
|
|
stdin: this.props.stdin,
|
|
setRawMode: this.handleSetRawMode,
|
|
isRawModeSupported: this.isRawModeSupported(),
|
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
internal_exitOnCtrlC: this.props.exitOnCtrlC,
|
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
internal_eventEmitter: this.internal_eventEmitter,
|
|
} },
|
|
React.createElement(StdoutContext.Provider
|
|
// eslint-disable-next-line react/jsx-no-constructed-context-values
|
|
, {
|
|
// eslint-disable-next-line react/jsx-no-constructed-context-values
|
|
value: {
|
|
stdout: this.props.stdout,
|
|
write: this.props.writeToStdout,
|
|
} },
|
|
React.createElement(StderrContext.Provider
|
|
// eslint-disable-next-line react/jsx-no-constructed-context-values
|
|
, {
|
|
// eslint-disable-next-line react/jsx-no-constructed-context-values
|
|
value: {
|
|
stderr: this.props.stderr,
|
|
write: this.props.writeToStderr,
|
|
} },
|
|
React.createElement(FocusContext.Provider
|
|
// eslint-disable-next-line react/jsx-no-constructed-context-values
|
|
, {
|
|
// eslint-disable-next-line react/jsx-no-constructed-context-values
|
|
value: {
|
|
activeId: this.state.activeFocusId,
|
|
add: this.addFocusable,
|
|
remove: this.removeFocusable,
|
|
activate: this.activateFocusable,
|
|
deactivate: this.deactivateFocusable,
|
|
enableFocus: this.enableFocus,
|
|
disableFocus: this.disableFocus,
|
|
focusNext: this.focusNext,
|
|
focusPrevious: this.focusPrevious,
|
|
focus: this.focus,
|
|
} }, this.state.error ? (React.createElement(ErrorOverview, { error: this.state.error })) : (this.props.children)))))));
|
|
}
|
|
componentDidMount() {
|
|
cliCursor.hide(this.props.stdout);
|
|
}
|
|
componentWillUnmount() {
|
|
cliCursor.show(this.props.stdout);
|
|
// ignore calling setRawMode on an handle stdin it cannot be called
|
|
if (this.isRawModeSupported()) {
|
|
this.handleSetRawMode(false);
|
|
}
|
|
}
|
|
componentDidCatch(error) {
|
|
this.handleExit(error);
|
|
}
|
|
handleSetRawMode = (isEnabled) => {
|
|
const { stdin } = this.props;
|
|
if (!this.isRawModeSupported()) {
|
|
if (stdin === process.stdin) {
|
|
throw new Error('Raw mode is not supported on the current process.stdin, which Ink uses as input stream by default.\nRead about how to prevent this error on https://github.com/vadimdemedes/ink/#israwmodesupported');
|
|
}
|
|
else {
|
|
throw new Error('Raw mode is not supported on the stdin provided to Ink.\nRead about how to prevent this error on https://github.com/vadimdemedes/ink/#israwmodesupported');
|
|
}
|
|
}
|
|
stdin.setEncoding('utf8');
|
|
if (isEnabled) {
|
|
// Ensure raw mode is enabled only once
|
|
if (this.rawModeEnabledCount === 0) {
|
|
stdin.ref();
|
|
stdin.setRawMode(true);
|
|
stdin.addListener('readable', this.handleReadable);
|
|
}
|
|
this.rawModeEnabledCount++;
|
|
return;
|
|
}
|
|
// Disable raw mode only when no components left that are using it
|
|
if (--this.rawModeEnabledCount === 0) {
|
|
stdin.setRawMode(false);
|
|
stdin.removeListener('readable', this.handleReadable);
|
|
stdin.unref();
|
|
}
|
|
};
|
|
handleReadable = () => {
|
|
let chunk;
|
|
// eslint-disable-next-line @typescript-eslint/ban-types
|
|
while ((chunk = this.props.stdin.read()) !== null) {
|
|
this.handleInput(chunk);
|
|
this.internal_eventEmitter.emit('input', chunk);
|
|
}
|
|
};
|
|
handleInput = (input) => {
|
|
// Exit on Ctrl+C
|
|
// eslint-disable-next-line unicorn/no-hex-escape
|
|
if (input === '\x03' && this.props.exitOnCtrlC) {
|
|
this.handleExit();
|
|
}
|
|
// Reset focus when there's an active focused component on Esc
|
|
if (input === escape && this.state.activeFocusId) {
|
|
this.setState({
|
|
activeFocusId: undefined,
|
|
});
|
|
}
|
|
if (this.state.isFocusEnabled && this.state.focusables.length > 0) {
|
|
if (input === tab) {
|
|
this.focusNext();
|
|
}
|
|
if (input === shiftTab) {
|
|
this.focusPrevious();
|
|
}
|
|
}
|
|
};
|
|
handleExit = (error) => {
|
|
if (this.isRawModeSupported()) {
|
|
this.handleSetRawMode(false);
|
|
}
|
|
this.props.onExit(error);
|
|
};
|
|
enableFocus = () => {
|
|
this.setState({
|
|
isFocusEnabled: true,
|
|
});
|
|
};
|
|
disableFocus = () => {
|
|
this.setState({
|
|
isFocusEnabled: false,
|
|
});
|
|
};
|
|
focus = (id) => {
|
|
this.setState(previousState => {
|
|
const hasFocusableId = previousState.focusables.some(focusable => focusable?.id === id);
|
|
if (!hasFocusableId) {
|
|
return previousState;
|
|
}
|
|
return { activeFocusId: id };
|
|
});
|
|
};
|
|
focusNext = () => {
|
|
this.setState(previousState => {
|
|
const firstFocusableId = previousState.focusables.find(focusable => focusable.isActive)?.id;
|
|
const nextFocusableId = this.findNextFocusable(previousState);
|
|
return {
|
|
activeFocusId: nextFocusableId ?? firstFocusableId,
|
|
};
|
|
});
|
|
};
|
|
focusPrevious = () => {
|
|
this.setState(previousState => {
|
|
const lastFocusableId = previousState.focusables.findLast(focusable => focusable.isActive)?.id;
|
|
const previousFocusableId = this.findPreviousFocusable(previousState);
|
|
return {
|
|
activeFocusId: previousFocusableId ?? lastFocusableId,
|
|
};
|
|
});
|
|
};
|
|
addFocusable = (id, { autoFocus }) => {
|
|
this.setState(previousState => {
|
|
let nextFocusId = previousState.activeFocusId;
|
|
if (!nextFocusId && autoFocus) {
|
|
nextFocusId = id;
|
|
}
|
|
return {
|
|
activeFocusId: nextFocusId,
|
|
focusables: [
|
|
...previousState.focusables,
|
|
{
|
|
id,
|
|
isActive: true,
|
|
},
|
|
],
|
|
};
|
|
});
|
|
};
|
|
removeFocusable = (id) => {
|
|
this.setState(previousState => ({
|
|
activeFocusId: previousState.activeFocusId === id
|
|
? undefined
|
|
: previousState.activeFocusId,
|
|
focusables: previousState.focusables.filter(focusable => {
|
|
return focusable.id !== id;
|
|
}),
|
|
}));
|
|
};
|
|
activateFocusable = (id) => {
|
|
this.setState(previousState => ({
|
|
focusables: previousState.focusables.map(focusable => {
|
|
if (focusable.id !== id) {
|
|
return focusable;
|
|
}
|
|
return {
|
|
id,
|
|
isActive: true,
|
|
};
|
|
}),
|
|
}));
|
|
};
|
|
deactivateFocusable = (id) => {
|
|
this.setState(previousState => ({
|
|
activeFocusId: previousState.activeFocusId === id
|
|
? undefined
|
|
: previousState.activeFocusId,
|
|
focusables: previousState.focusables.map(focusable => {
|
|
if (focusable.id !== id) {
|
|
return focusable;
|
|
}
|
|
return {
|
|
id,
|
|
isActive: false,
|
|
};
|
|
}),
|
|
}));
|
|
};
|
|
findNextFocusable = (state) => {
|
|
const activeIndex = state.focusables.findIndex(focusable => {
|
|
return focusable.id === state.activeFocusId;
|
|
});
|
|
for (let index = activeIndex + 1; index < state.focusables.length; index++) {
|
|
const focusable = state.focusables[index];
|
|
if (focusable?.isActive) {
|
|
return focusable.id;
|
|
}
|
|
}
|
|
return undefined;
|
|
};
|
|
findPreviousFocusable = (state) => {
|
|
const activeIndex = state.focusables.findIndex(focusable => {
|
|
return focusable.id === state.activeFocusId;
|
|
});
|
|
for (let index = activeIndex - 1; index >= 0; index--) {
|
|
const focusable = state.focusables[index];
|
|
if (focusable?.isActive) {
|
|
return focusable.id;
|
|
}
|
|
}
|
|
return undefined;
|
|
};
|
|
}
|
|
//# sourceMappingURL=App.js.map
|