349 lines
15 KiB
JavaScript
349 lines
15 KiB
JavaScript
import { EventEmitter } from 'node:events';
|
|
import process from 'node:process';
|
|
import cliCursor from 'cli-cursor';
|
|
import React, { PureComponent } from 'react';
|
|
import AppContext from './AppContext.js';
|
|
import ErrorOverview from './ErrorOverview.js';
|
|
import FocusContext from './FocusContext.js';
|
|
import StderrContext from './StderrContext.js';
|
|
import StdinContext from './StdinContext.js';
|
|
import StdoutContext from './StdoutContext.js';
|
|
|
|
const tab = '\t';
|
|
const shiftTab = '\u001B[Z';
|
|
const escape = '\u001B';
|
|
export default class App extends PureComponent {
|
|
static displayName = 'InternalApp';
|
|
static getDerivedStateFromError(error) {
|
|
return { error };
|
|
}
|
|
state = {
|
|
isFocusEnabled: true,
|
|
activeFocusId: undefined,
|
|
focusables: [],
|
|
error: undefined,
|
|
};
|
|
rawModeEnabledCount = 0;
|
|
internal_eventEmitter = new EventEmitter();
|
|
isRawModeSupported() {
|
|
return this.props.stdin.isTTY;
|
|
}
|
|
render() {
|
|
return (React.createElement(AppContext.Provider, { value: { exit: this.handleExit } },
|
|
React.createElement(StdinContext.Provider, { value: { stdin: this.props.stdin, setRawMode: this.handleSetRawMode, isRawModeSupported: this.isRawModeSupported(), internal_exitOnCtrlC: this.props.exitOnCtrlC, internal_eventEmitter: this.internal_eventEmitter } },
|
|
React.createElement(StdoutContext.Provider, { value: { stdout: this.props.stdout, write: this.props.writeToStdout } },
|
|
React.createElement(StderrContext.Provider, { value: { stderr: this.props.stderr, write: this.props.writeToStderr } },
|
|
React.createElement(FocusContext.Provider, { 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);
|
|
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) {
|
|
if (this.rawModeEnabledCount === 0) {
|
|
stdin.ref();
|
|
stdin.setRawMode(true);
|
|
stdin.addListener('readable', this.handleReadable);
|
|
// Enable bracketed paste on this TTY
|
|
this.props.stdout?.write('\x1B[?2004h');
|
|
}
|
|
this.rawModeEnabledCount++;
|
|
return;
|
|
}
|
|
if (--this.rawModeEnabledCount === 0) {
|
|
this.props.stdout?.write('\x1B[?2004l');
|
|
stdin.setRawMode(false);
|
|
stdin.removeListener('readable', this.handleReadable);
|
|
stdin.unref();
|
|
}
|
|
};
|
|
keyParseState = { mode: 'NORMAL', incomplete: '', pasteBuffer: '' };
|
|
fallbackPaste = { aggregating: false, buffer: '', timer: null, lastAt: 0, chunks: 0, bytes: 0, escalated: false, recentTime: 0, recentLen: 0 };
|
|
FALLBACK_NORMAL_MS = 16;
|
|
FALLBACK_PASTE_MS = 150;
|
|
PLACEHOLDER_LINE_THRESHOLD = 5;
|
|
PLACEHOLDER_CHAR_THRESHOLD = 500;
|
|
FALLBACK_START_LEN_THRESHOLD = 200;
|
|
parseChunk = (state, chunk) => {
|
|
const START = '\x1B[200~';
|
|
const END = '\x1B[201~';
|
|
const events = [];
|
|
let next = { ...state };
|
|
let buf = (next.incomplete || '') + (chunk || '');
|
|
next.incomplete = '';
|
|
const pushText = (text) => {
|
|
if (text && text.length > 0) {
|
|
events.push({ type: 'text', value: text });
|
|
}
|
|
};
|
|
if (next.mode === 'NORMAL') {
|
|
let offset = 0;
|
|
while (offset < buf.length) {
|
|
const startIdx = buf.indexOf(START, offset);
|
|
if (startIdx === -1) {
|
|
const remainder = buf.slice(offset);
|
|
let keep = 0;
|
|
const max = Math.min(remainder.length, START.length - 1);
|
|
// Only keep potential START prefixes of length >= 2 (e.g., "\x1B[") to avoid swallowing a bare ESC
|
|
for (let i = max; i > 1; i--) {
|
|
if (START.startsWith(remainder.slice(-i))) {
|
|
keep = i;
|
|
break;
|
|
}
|
|
}
|
|
if (remainder.length > keep) {
|
|
pushText(remainder.slice(0, remainder.length - keep));
|
|
}
|
|
next.incomplete = remainder.slice(remainder.length - keep);
|
|
break;
|
|
}
|
|
if (startIdx > offset) {
|
|
pushText(buf.slice(offset, startIdx));
|
|
}
|
|
offset = startIdx + START.length;
|
|
const endIdx = buf.indexOf(END, offset);
|
|
if (endIdx !== -1) {
|
|
const content = buf.slice(offset, endIdx);
|
|
events.push({ type: 'paste', value: content });
|
|
offset = endIdx + END.length;
|
|
continue;
|
|
}
|
|
next.mode = 'IN_PASTE';
|
|
next.pasteBuffer = buf.slice(offset);
|
|
break;
|
|
}
|
|
return [events, next];
|
|
}
|
|
if (next.mode === 'IN_PASTE') {
|
|
next.pasteBuffer += buf;
|
|
const endIdx = next.pasteBuffer.indexOf(END);
|
|
if (endIdx === -1) {
|
|
return [events, next];
|
|
}
|
|
const content = next.pasteBuffer.slice(0, endIdx);
|
|
events.push({ type: 'paste', value: content });
|
|
const after = next.pasteBuffer.slice(endIdx + END.length);
|
|
next.mode = 'NORMAL';
|
|
next.pasteBuffer = '';
|
|
const [moreEvents, finalState] = this.parseChunk(next, after);
|
|
return [events.concat(moreEvents), finalState];
|
|
}
|
|
return [events, next];
|
|
};
|
|
countLines = (text) => {
|
|
if (!text)
|
|
return 0;
|
|
const m = text.match(/\r\n|\r|\n/g);
|
|
return (m ? m.length : 0);
|
|
};
|
|
fallbackStart = () => {
|
|
this.fallbackStop();
|
|
this.fallbackPaste.aggregating = true;
|
|
this.fallbackPaste.buffer = '';
|
|
this.fallbackPaste.chunks = 0;
|
|
this.fallbackPaste.bytes = 0;
|
|
this.fallbackPaste.escalated = false;
|
|
this.fallbackPaste.lastAt = Date.now();
|
|
this.fallbackPaste.timer = setTimeout(this.fallbackFlush, this.FALLBACK_NORMAL_MS);
|
|
};
|
|
fallbackSchedule = (ms) => {
|
|
if (this.fallbackPaste.timer)
|
|
clearTimeout(this.fallbackPaste.timer);
|
|
this.fallbackPaste.timer = setTimeout(this.fallbackFlush, ms);
|
|
this.fallbackPaste.lastAt = Date.now();
|
|
};
|
|
fallbackStop = () => {
|
|
if (this.fallbackPaste.timer)
|
|
clearTimeout(this.fallbackPaste.timer);
|
|
this.fallbackPaste.timer = null;
|
|
this.fallbackPaste.aggregating = false;
|
|
};
|
|
fallbackFlush = () => {
|
|
const txt = this.fallbackPaste.buffer;
|
|
this.fallbackStop();
|
|
if (!txt)
|
|
return;
|
|
const lines = this.countLines(txt);
|
|
const isPaste = this.fallbackPaste.escalated || (lines > this.PLACEHOLDER_LINE_THRESHOLD) || (txt.length > this.PLACEHOLDER_CHAR_THRESHOLD);
|
|
if (isPaste) {
|
|
const pasteEvent = { sequence: txt, raw: txt, isPasted: true, name: '', ctrl: false, meta: false, shift: false };
|
|
this.internal_eventEmitter.emit('input', pasteEvent);
|
|
}
|
|
else {
|
|
this.handleInput(txt);
|
|
this.internal_eventEmitter.emit('input', txt);
|
|
}
|
|
this.fallbackPaste.buffer = '';
|
|
this.fallbackPaste.chunks = 0;
|
|
this.fallbackPaste.bytes = 0;
|
|
this.fallbackPaste.escalated = false;
|
|
};
|
|
handleReadable = () => {
|
|
let chunk;
|
|
while ((chunk = this.props.stdin.read()) !== null) {
|
|
const [events, nextState] = this.parseChunk(this.keyParseState, chunk);
|
|
this.keyParseState = nextState;
|
|
for (const evt of events) {
|
|
if (evt.type === 'paste') {
|
|
if (this.fallbackPaste.aggregating) {
|
|
this.fallbackFlush();
|
|
}
|
|
const content = evt.value;
|
|
const pasteEvent = { sequence: content, raw: content, isPasted: true, name: '', ctrl: false, meta: false, shift: false };
|
|
this.internal_eventEmitter.emit('input', pasteEvent);
|
|
}
|
|
else if (evt.type === 'text') {
|
|
const text = evt.value;
|
|
if (!text)
|
|
continue;
|
|
const hasNewline = /\r|\n/.test(text);
|
|
if (this.fallbackPaste.aggregating) {
|
|
this.fallbackPaste.buffer += text;
|
|
this.fallbackPaste.chunks += 1;
|
|
this.fallbackPaste.bytes += text.length;
|
|
if (!this.fallbackPaste.escalated) {
|
|
if (this.fallbackPaste.buffer.length >= 128) {
|
|
this.fallbackPaste.escalated = true;
|
|
}
|
|
}
|
|
this.fallbackSchedule(this.fallbackPaste.escalated ? this.FALLBACK_PASTE_MS : this.FALLBACK_NORMAL_MS);
|
|
continue;
|
|
}
|
|
const now = Date.now();
|
|
const quickCombo = (now - this.fallbackPaste.recentTime) <= 16 && (this.fallbackPaste.recentLen + text.length) >= 128;
|
|
if (text.length >= 128 || quickCombo) {
|
|
this.fallbackStart();
|
|
this.fallbackPaste.buffer += text;
|
|
this.fallbackPaste.chunks = 1;
|
|
this.fallbackPaste.bytes = text.length;
|
|
this.fallbackPaste.escalated = text.length >= 128;
|
|
this.fallbackSchedule(this.FALLBACK_PASTE_MS);
|
|
continue;
|
|
}
|
|
this.handleInput(text);
|
|
this.internal_eventEmitter.emit('input', text);
|
|
this.fallbackPaste.recentTime = Date.now();
|
|
this.fallbackPaste.recentLen = text.length;
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
};
|
|
handleInput = (input) => {
|
|
if (input === '\x03' && this.props.exitOnCtrlC) {
|
|
this.handleExit();
|
|
}
|
|
// Disable ESC-based focus clearing to avoid consuming the first Escape
|
|
// 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 => focusable.id !== id) }));
|
|
};
|
|
activateFocusable = (id) => {
|
|
this.setState(previousState => ({ focusables: previousState.focusables.map(focusable => (focusable.id !== id ? focusable : { id, isActive: true })) }));
|
|
};
|
|
deactivateFocusable = (id) => {
|
|
this.setState(previousState => ({ activeFocusId: previousState.activeFocusId === id ? undefined : previousState.activeFocusId, focusables: previousState.focusables.map(focusable => (focusable.id !== id ? focusable : { 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
|
|
|
|
|