ink
Version:
React for CLI
351 lines • 14.4 kB
JavaScript
import { EventEmitter } from 'node:events';
import process from 'node:process';
import React, { useState, useRef, useCallback, useMemo, useEffect, } from 'react';
import cliCursor from 'cli-cursor';
import { createInputParser } from '../input-parser.js';
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 CursorContext from './CursorContext.js';
import ErrorBoundary from './ErrorBoundary.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
function App({ children, stdin, stdout, stderr, writeToStdout, writeToStderr, exitOnCtrlC, onExit, setCursorPosition, }) {
const [isFocusEnabled, setIsFocusEnabled] = useState(true);
const [activeFocusId, setActiveFocusId] = useState(undefined);
// Focusables array is managed internally via setFocusables callback pattern
// eslint-disable-next-line react/hook-use-state
const [, setFocusables] = useState([]);
// Track focusables count for tab navigation check (avoids stale closure)
const focusablesCountRef = useRef(0);
// Count how many components enabled raw mode to avoid disabling
// raw mode until all components don't need it anymore
const rawModeEnabledCount = useRef(0);
// eslint-disable-next-line @typescript-eslint/naming-convention
const internal_eventEmitter = useRef(new EventEmitter());
// Each useInput hook adds a listener, so the count can legitimately exceed the default limit of 10.
internal_eventEmitter.current.setMaxListeners(Infinity);
// Store the currently attached readable listener to avoid stale closure issues
const readableListenerRef = useRef(undefined);
const inputParserRef = useRef(createInputParser());
const pendingInputFlushRef = useRef(undefined);
const clearPendingInputFlush = useCallback(() => {
if (!pendingInputFlushRef.current) {
return;
}
clearImmediate(pendingInputFlushRef.current);
pendingInputFlushRef.current = undefined;
}, []);
// Determines if TTY is supported on the provided stdin
const isRawModeSupported = stdin.isTTY;
const detachReadableListener = useCallback(() => {
if (!readableListenerRef.current) {
return;
}
stdin.removeListener('readable', readableListenerRef.current);
readableListenerRef.current = undefined;
}, [stdin]);
const disableRawMode = useCallback(() => {
stdin.setRawMode(false);
detachReadableListener();
stdin.unref();
rawModeEnabledCount.current = 0;
inputParserRef.current.reset();
clearPendingInputFlush();
}, [stdin, detachReadableListener, clearPendingInputFlush]);
const handleExit = useCallback((errorOrResult) => {
if (isRawModeSupported && rawModeEnabledCount.current > 0) {
disableRawMode();
}
onExit(errorOrResult);
}, [isRawModeSupported, disableRawMode, onExit]);
const handleInput = useCallback((input) => {
// Exit on Ctrl+C
// eslint-disable-next-line unicorn/no-hex-escape
if (input === '\x03' && exitOnCtrlC) {
handleExit();
return;
}
// Reset focus when there's an active focused component on Esc
if (input === escape) {
setActiveFocusId(currentActiveFocusId => {
if (currentActiveFocusId) {
return undefined;
}
return currentActiveFocusId;
});
}
}, [exitOnCtrlC, handleExit]);
const emitInput = useCallback((input) => {
handleInput(input);
internal_eventEmitter.current.emit('input', input);
}, [handleInput]);
const schedulePendingInputFlush = useCallback(() => {
clearPendingInputFlush();
pendingInputFlushRef.current = setImmediate(() => {
pendingInputFlushRef.current = undefined;
const pendingEscape = inputParserRef.current.flushPendingEscape();
if (!pendingEscape) {
return;
}
emitInput(pendingEscape);
});
}, [clearPendingInputFlush, emitInput]);
const handleReadable = useCallback(() => {
clearPendingInputFlush();
let chunk;
// eslint-disable-next-line @typescript-eslint/ban-types
while ((chunk = stdin.read()) !== null) {
const inputEvents = inputParserRef.current.push(chunk);
for (const input of inputEvents) {
emitInput(input);
}
}
if (inputParserRef.current.hasPendingEscape()) {
schedulePendingInputFlush();
}
}, [stdin, emitInput, clearPendingInputFlush, schedulePendingInputFlush]);
const handleSetRawMode = useCallback((isEnabled) => {
if (!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 (rawModeEnabledCount.current === 0) {
stdin.ref();
stdin.setRawMode(true);
// Store the listener reference to avoid stale closure when removing
readableListenerRef.current = handleReadable;
stdin.addListener('readable', handleReadable);
}
rawModeEnabledCount.current++;
return;
}
// Disable raw mode only when no components left that are using it
if (rawModeEnabledCount.current === 0) {
return;
}
if (--rawModeEnabledCount.current === 0) {
disableRawMode();
}
}, [isRawModeSupported, stdin, handleReadable, disableRawMode]);
// Focus navigation helpers
const findNextFocusable = useCallback((currentFocusables, currentActiveFocusId) => {
const activeIndex = currentFocusables.findIndex(focusable => {
return focusable.id === currentActiveFocusId;
});
for (let index = activeIndex + 1; index < currentFocusables.length; index++) {
const focusable = currentFocusables[index];
if (focusable?.isActive) {
return focusable.id;
}
}
return undefined;
}, []);
const findPreviousFocusable = useCallback((currentFocusables, currentActiveFocusId) => {
const activeIndex = currentFocusables.findIndex(focusable => {
return focusable.id === currentActiveFocusId;
});
for (let index = activeIndex - 1; index >= 0; index--) {
const focusable = currentFocusables[index];
if (focusable?.isActive) {
return focusable.id;
}
}
return undefined;
}, []);
const focusNext = useCallback(() => {
setFocusables(currentFocusables => {
setActiveFocusId(currentActiveFocusId => {
const firstFocusableId = currentFocusables.find(focusable => focusable.isActive)?.id;
const nextFocusableId = findNextFocusable(currentFocusables, currentActiveFocusId);
return nextFocusableId ?? firstFocusableId;
});
return currentFocusables;
});
}, [findNextFocusable]);
const focusPrevious = useCallback(() => {
setFocusables(currentFocusables => {
setActiveFocusId(currentActiveFocusId => {
const lastFocusableId = currentFocusables.findLast(focusable => focusable.isActive)?.id;
const previousFocusableId = findPreviousFocusable(currentFocusables, currentActiveFocusId);
return previousFocusableId ?? lastFocusableId;
});
return currentFocusables;
});
}, [findPreviousFocusable]);
// Handle tab navigation via effect that subscribes to input events
useEffect(() => {
const handleTabNavigation = (input) => {
if (!isFocusEnabled || focusablesCountRef.current === 0)
return;
if (input === tab) {
focusNext();
}
if (input === shiftTab) {
focusPrevious();
}
};
internal_eventEmitter.current.on('input', handleTabNavigation);
const emitter = internal_eventEmitter.current;
return () => {
emitter.off('input', handleTabNavigation);
};
}, [isFocusEnabled, focusNext, focusPrevious]);
const enableFocus = useCallback(() => {
setIsFocusEnabled(true);
}, []);
const disableFocus = useCallback(() => {
setIsFocusEnabled(false);
}, []);
const focus = useCallback((id) => {
setFocusables(currentFocusables => {
const hasFocusableId = currentFocusables.some(focusable => focusable?.id === id);
if (hasFocusableId) {
setActiveFocusId(id);
}
return currentFocusables;
});
}, []);
const addFocusable = useCallback((id, { autoFocus }) => {
setFocusables(currentFocusables => {
focusablesCountRef.current = currentFocusables.length + 1;
return [
...currentFocusables,
{
id,
isActive: true,
},
];
});
if (autoFocus) {
setActiveFocusId(currentActiveFocusId => {
if (!currentActiveFocusId) {
return id;
}
return currentActiveFocusId;
});
}
}, []);
const removeFocusable = useCallback((id) => {
setActiveFocusId(currentActiveFocusId => {
if (currentActiveFocusId === id) {
return undefined;
}
return currentActiveFocusId;
});
setFocusables(currentFocusables => {
const filtered = currentFocusables.filter(focusable => {
return focusable.id !== id;
});
focusablesCountRef.current = filtered.length;
return filtered;
});
}, []);
const activateFocusable = useCallback((id) => {
setFocusables(currentFocusables => currentFocusables.map(focusable => {
if (focusable.id !== id) {
return focusable;
}
return {
id,
isActive: true,
};
}));
}, []);
const deactivateFocusable = useCallback((id) => {
setActiveFocusId(currentActiveFocusId => {
if (currentActiveFocusId === id) {
return undefined;
}
return currentActiveFocusId;
});
setFocusables(currentFocusables => currentFocusables.map(focusable => {
if (focusable.id !== id) {
return focusable;
}
return {
id,
isActive: false,
};
}));
}, []);
// Handle cursor visibility and raw mode cleanup on unmount
useEffect(() => {
return () => {
cliCursor.show(stdout);
if (isRawModeSupported && rawModeEnabledCount.current > 0) {
disableRawMode();
}
};
}, [stdout, isRawModeSupported, disableRawMode]);
// Memoize context values to prevent unnecessary re-renders
const appContextValue = useMemo(() => ({
exit: handleExit,
}), [handleExit]);
const stdinContextValue = useMemo(() => ({
stdin,
setRawMode: handleSetRawMode,
isRawModeSupported,
// eslint-disable-next-line @typescript-eslint/naming-convention
internal_exitOnCtrlC: exitOnCtrlC,
// eslint-disable-next-line @typescript-eslint/naming-convention
internal_eventEmitter: internal_eventEmitter.current,
}), [stdin, handleSetRawMode, isRawModeSupported, exitOnCtrlC]);
const stdoutContextValue = useMemo(() => ({
stdout,
write: writeToStdout,
}), [stdout, writeToStdout]);
const stderrContextValue = useMemo(() => ({
stderr,
write: writeToStderr,
}), [stderr, writeToStderr]);
const cursorContextValue = useMemo(() => ({
setCursorPosition,
}), [setCursorPosition]);
const focusContextValue = useMemo(() => ({
activeId: activeFocusId,
add: addFocusable,
remove: removeFocusable,
activate: activateFocusable,
deactivate: deactivateFocusable,
enableFocus,
disableFocus,
focusNext,
focusPrevious,
focus,
}), [
activeFocusId,
addFocusable,
removeFocusable,
activateFocusable,
deactivateFocusable,
enableFocus,
disableFocus,
focusNext,
focusPrevious,
focus,
]);
return (React.createElement(AppContext.Provider, { value: appContextValue },
React.createElement(StdinContext.Provider, { value: stdinContextValue },
React.createElement(StdoutContext.Provider, { value: stdoutContextValue },
React.createElement(StderrContext.Provider, { value: stderrContextValue },
React.createElement(FocusContext.Provider, { value: focusContextValue },
React.createElement(CursorContext.Provider, { value: cursorContextValue },
React.createElement(ErrorBoundary, { onError: handleExit }, children))))))));
}
App.displayName = 'InternalApp';
export default App;
//# sourceMappingURL=App.js.map