Skip to content

Commit d9ab3c0

Browse files
committed
Add command history navigation
1 parent 2a45400 commit d9ab3c0

File tree

1 file changed

+163
-54
lines changed

1 file changed

+163
-54
lines changed

src/index.tsx

Lines changed: 163 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,19 @@ import React, {
55
KeyboardEvent,
66
ChangeEvent,
77
ReactNode,
8-
ReactElement
9-
} from 'react';
10-
import TerminalInput from './linetypes/TerminalInput';
11-
import TerminalOutput from './linetypes/TerminalOutput';
12-
import './style.css';
13-
import {IWindowButtonsProps, WindowButtons} from "./ui-elements/WindowButtons";
8+
ReactElement,
9+
} from "react";
10+
import TerminalInput from "./linetypes/TerminalInput";
11+
import TerminalOutput from "./linetypes/TerminalOutput";
12+
import "./style.css";
13+
import {
14+
IWindowButtonsProps,
15+
WindowButtons,
16+
} from "./ui-elements/WindowButtons";
1417

1518
export enum ColorMode {
1619
Light,
17-
Dark
20+
Dark,
1821
}
1922

2023
export interface Props {
@@ -31,22 +34,41 @@ export interface Props {
3134
TopButtonsPanel?: (props: IWindowButtonsProps) => ReactElement | null;
3235
}
3336

34-
const Terminal = ({name, prompt, height = "600px", colorMode, onInput, children, startingInputValue = "", redBtnCallback, yellowBtnCallback, greenBtnCallback, TopButtonsPanel = WindowButtons}: Props) => {
35-
const [currentLineInput, setCurrentLineInput] = useState('');
37+
const Terminal = ({
38+
name,
39+
prompt,
40+
height = "600px",
41+
colorMode,
42+
onInput,
43+
children,
44+
startingInputValue = "",
45+
redBtnCallback,
46+
yellowBtnCallback,
47+
greenBtnCallback,
48+
TopButtonsPanel = WindowButtons,
49+
}: Props) => {
50+
// command history handling
51+
const [historyIndex, setHistoryIndex] = useState(-1);
52+
const [history, setHistory] = useState<string[]>([]);
53+
54+
const [currentLineInput, setCurrentLineInput] = useState("");
3655
const [cursorPos, setCursorPos] = useState(0);
3756

38-
const scrollIntoViewRef = useRef<HTMLDivElement>(null)
57+
const scrollIntoViewRef = useRef<HTMLDivElement>(null);
3958

4059
const updateCurrentLineInput = (event: ChangeEvent<HTMLInputElement>) => {
4160
setCurrentLineInput(event.target.value);
42-
}
61+
};
4362

4463
// Calculates the total width in pixels of the characters to the right of the cursor.
4564
// Create a temporary span element to measure the width of the characters.
46-
const calculateInputWidth = (inputElement: HTMLInputElement, chars: string) => {
47-
const span = document.createElement('span');
48-
span.style.visibility = 'hidden';
49-
span.style.position = 'absolute';
65+
const calculateInputWidth = (
66+
inputElement: HTMLInputElement,
67+
chars: string,
68+
) => {
69+
const span = document.createElement("span");
70+
span.style.visibility = "hidden";
71+
span.style.position = "absolute";
5072
span.style.fontSize = window.getComputedStyle(inputElement).fontSize;
5173
span.style.fontFamily = window.getComputedStyle(inputElement).fontFamily;
5274
span.innerText = chars;
@@ -57,82 +79,169 @@ const Terminal = ({name, prompt, height = "600px", colorMode, onInput, children,
5779
return -width;
5880
};
5981

82+
// Change index ensuring it doesn't go out of bound
83+
const changeHistoryIndex = (direction: 1 | -1) => {
84+
setHistoryIndex((oldIndex) => {
85+
if (history.length === 0) return -1;
86+
87+
// If we're not currently looking at history (oldIndex === -1) and user presses ArrowUp, jump to the last entry.
88+
if (oldIndex === -1 && direction === -1) {
89+
return history.length - 1;
90+
}
91+
92+
// If oldIndex === -1 and direction === 1 (ArrowDown), keep -1 (nothing to go to).
93+
if (oldIndex === -1 && direction === 1) {
94+
return -1;
95+
}
96+
97+
return clamp(oldIndex + direction, 0, history.length - 1);
98+
});
99+
};
100+
60101
const clamp = (value: number, min: number, max: number) => {
61-
if(value > max) return max;
62-
if(value < min) return min;
102+
if (value > max) return max;
103+
if (value < min) return min;
63104
return value;
64-
}
105+
};
65106

66107
const handleInputKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
67-
if(!onInput) {
108+
event.preventDefault();
109+
if (!onInput) {
68110
return;
69-
};
70-
if (event.key === 'Enter') {
111+
}
112+
if (event.key === "Enter") {
71113
onInput(currentLineInput);
72114
setCursorPos(0);
73-
setCurrentLineInput('');
74-
setTimeout(() => scrollIntoViewRef?.current?.scrollIntoView({ behavior: "auto", block: "nearest" }), 500);
75-
} else if (["ArrowLeft", "ArrowRight", "ArrowDown", "ArrowUp", "Delete"].includes(event.key)) {
115+
116+
// history update
117+
setHistory((previousHistory) =>
118+
currentLineInput.trim() === ""
119+
? previousHistory
120+
: [...previousHistory, currentLineInput],
121+
);
122+
setHistoryIndex(-1);
123+
124+
setCurrentLineInput("");
125+
setTimeout(
126+
() =>
127+
scrollIntoViewRef?.current?.scrollIntoView({
128+
behavior: "auto",
129+
block: "nearest",
130+
}),
131+
500,
132+
);
133+
} else if (
134+
["ArrowLeft", "ArrowRight", "ArrowDown", "ArrowUp", "Delete"].includes(
135+
event.key,
136+
)
137+
) {
76138
const inputElement = event.currentTarget;
77139
let charsToRightOfCursor = "";
78-
let cursorIndex = currentLineInput.length - (inputElement.selectionStart || 0);
140+
let cursorIndex =
141+
currentLineInput.length - (inputElement.selectionStart || 0);
79142
cursorIndex = clamp(cursorIndex, 0, currentLineInput.length);
80143

81-
if(event.key === 'ArrowLeft') {
82-
if(cursorIndex > currentLineInput.length - 1) cursorIndex --;
83-
charsToRightOfCursor = currentLineInput.slice(currentLineInput.length -1 - cursorIndex);
84-
}
85-
else if (event.key === 'ArrowRight' || event.key === 'Delete') {
86-
charsToRightOfCursor = currentLineInput.slice(currentLineInput.length - cursorIndex + 1);
87-
}
88-
else if (event.key === 'ArrowUp') {
89-
charsToRightOfCursor = currentLineInput.slice(0)
144+
if (event.key === "ArrowLeft") {
145+
if (cursorIndex > currentLineInput.length - 1) cursorIndex--;
146+
charsToRightOfCursor = currentLineInput.slice(
147+
currentLineInput.length - 1 - cursorIndex,
148+
);
149+
} else if (event.key === "ArrowRight" || event.key === "Delete") {
150+
charsToRightOfCursor = currentLineInput.slice(
151+
currentLineInput.length - cursorIndex + 1,
152+
);
153+
} else if (event.key === "ArrowUp") {
154+
charsToRightOfCursor = currentLineInput.slice(0);
155+
changeHistoryIndex(-1);
156+
} else if (event.key === "ArrowDown") {
157+
charsToRightOfCursor = currentLineInput.slice(0);
158+
changeHistoryIndex(1);
90159
}
91160

92-
const inputWidth = calculateInputWidth(inputElement, charsToRightOfCursor);
161+
const inputWidth = calculateInputWidth(
162+
inputElement,
163+
charsToRightOfCursor,
164+
);
93165
setCursorPos(inputWidth);
94166
}
95-
}
167+
};
96168

97169
useEffect(() => {
98170
setCurrentLineInput(startingInputValue.trim());
99171
}, [startingInputValue]);
100172

173+
// If history index changes or history length changes, we want to update the input value
174+
useEffect(() => {
175+
if (historyIndex >= 0 && historyIndex < history.length) {
176+
setCurrentLineInput(history[historyIndex]);
177+
}
178+
}, [historyIndex, history.length]);
179+
101180
// We use a hidden input to capture terminal input; make sure the hidden input is focused when clicking anywhere on the terminal
102181
useEffect(() => {
103182
if (onInput == null) {
104183
return;
105184
}
106185
// keep reference to listeners so we can perform cleanup
107-
const elListeners: { terminalEl: Element; listener: EventListenerOrEventListenerObject }[] = [];
108-
for (const terminalEl of document.getElementsByClassName('react-terminal-wrapper')) {
109-
const listener = () => (terminalEl?.querySelector('.terminal-hidden-input') as HTMLElement)?.focus();
110-
terminalEl?.addEventListener('click', listener);
186+
const elListeners: {
187+
terminalEl: Element;
188+
listener: EventListenerOrEventListenerObject;
189+
}[] = [];
190+
for (const terminalEl of document.getElementsByClassName(
191+
"react-terminal-wrapper",
192+
)) {
193+
const listener = () =>
194+
(
195+
terminalEl?.querySelector(".terminal-hidden-input") as HTMLElement
196+
)?.focus();
197+
terminalEl?.addEventListener("click", listener);
111198
elListeners.push({ terminalEl, listener });
112199
}
113-
return function cleanup () {
114-
elListeners.forEach(elListener => {
115-
elListener.terminalEl.removeEventListener('click', elListener.listener);
200+
return function cleanup() {
201+
elListeners.forEach((elListener) => {
202+
elListener.terminalEl.removeEventListener("click", elListener.listener);
116203
});
117-
}
204+
};
118205
}, [onInput]);
119206

120-
const classes = ['react-terminal-wrapper'];
207+
const classes = ["react-terminal-wrapper"];
121208
if (colorMode === ColorMode.Light) {
122-
classes.push('react-terminal-light');
209+
classes.push("react-terminal-light");
123210
}
211+
124212
return (
125-
<div className={ classes.join(' ') } data-terminal-name={ name }>
126-
<TopButtonsPanel {...{redBtnCallback, yellowBtnCallback, greenBtnCallback}}/>
127-
<div className="react-terminal" style={ { height } }>
128-
{ children }
129-
{ typeof onInput === 'function' && <div className="react-terminal-line react-terminal-input react-terminal-active-input" data-terminal-prompt={ prompt || '$' } key="terminal-line-prompt" >{ currentLineInput }<span className="cursor" style={{ left: `${cursorPos+1}px` }}></span></div> }
130-
<div ref={ scrollIntoViewRef }></div>
213+
<div className={classes.join(" ")} data-terminal-name={name}>
214+
<TopButtonsPanel
215+
{...{ redBtnCallback, yellowBtnCallback, greenBtnCallback }}
216+
/>
217+
<div className="react-terminal" style={{ height }}>
218+
{children}
219+
{typeof onInput === "function" && (
220+
<div
221+
className="react-terminal-line react-terminal-input react-terminal-active-input"
222+
data-terminal-prompt={prompt || "$"}
223+
key="terminal-line-prompt"
224+
>
225+
{currentLineInput}
226+
<span
227+
className="cursor"
228+
style={{ left: `${cursorPos + 1}px` }}
229+
></span>
230+
</div>
231+
)}
232+
<div ref={scrollIntoViewRef}></div>
131233
</div>
132-
<input className="terminal-hidden-input" placeholder="Terminal Hidden Input" value={ currentLineInput } autoFocus={ onInput != null } onChange={ updateCurrentLineInput } onKeyDown={ handleInputKeyDown }/>
234+
<input
235+
className="terminal-hidden-input"
236+
placeholder="Terminal Hidden Input"
237+
value={currentLineInput}
238+
autoFocus={onInput != null}
239+
onChange={updateCurrentLineInput}
240+
onKeyDown={handleInputKeyDown}
241+
/>
133242
</div>
134243
);
135-
}
244+
};
136245

137246
export { TerminalInput, TerminalOutput };
138247
export default Terminal;

0 commit comments

Comments
 (0)