Skip to content

Commit 2008b8c

Browse files
authored
Merge pull request #63 from leonardo3130/feature/history-navigation
Feature/history navigation
2 parents 2a45400 + 16e8c0e commit 2008b8c

File tree

2 files changed

+286
-54
lines changed

2 files changed

+286
-54
lines changed

src/index.tsx

Lines changed: 181 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,46 @@ 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+
// local storage key
51+
const terminalHistoryKey = name
52+
? `terminal-history-${name}`
53+
: "terminal-history";
54+
55+
// command history handling
56+
const [historyIndex, setHistoryIndex] = useState(-1);
57+
const [history, setHistory] = useState<string[]>([]);
58+
59+
const [currentLineInput, setCurrentLineInput] = useState("");
3660
const [cursorPos, setCursorPos] = useState(0);
3761

38-
const scrollIntoViewRef = useRef<HTMLDivElement>(null)
62+
const scrollIntoViewRef = useRef<HTMLDivElement>(null);
3963

4064
const updateCurrentLineInput = (event: ChangeEvent<HTMLInputElement>) => {
4165
setCurrentLineInput(event.target.value);
42-
}
66+
};
4367

4468
// Calculates the total width in pixels of the characters to the right of the cursor.
4569
// 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';
70+
const calculateInputWidth = (
71+
inputElement: HTMLInputElement,
72+
chars: string,
73+
) => {
74+
const span = document.createElement("span");
75+
span.style.visibility = "hidden";
76+
span.style.position = "absolute";
5077
span.style.fontSize = window.getComputedStyle(inputElement).fontSize;
5178
span.style.fontFamily = window.getComputedStyle(inputElement).fontFamily;
5279
span.innerText = chars;
@@ -57,82 +84,182 @@ const Terminal = ({name, prompt, height = "600px", colorMode, onInput, children,
5784
return -width;
5885
};
5986

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

66112
const handleInputKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
67-
if(!onInput) {
113+
event.preventDefault();
114+
if (!onInput) {
68115
return;
69-
};
70-
if (event.key === 'Enter') {
116+
}
117+
if (event.key === "Enter") {
71118
onInput(currentLineInput);
72119
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)) {
120+
121+
// history update
122+
setHistory((previousHistory) =>
123+
currentLineInput.trim() === "" ||
124+
previousHistory[previousHistory.length - 1] === currentLineInput.trim()
125+
? previousHistory
126+
: [...previousHistory, currentLineInput],
127+
);
128+
setHistoryIndex(-1);
129+
130+
setCurrentLineInput("");
131+
setTimeout(
132+
() =>
133+
scrollIntoViewRef?.current?.scrollIntoView({
134+
behavior: "auto",
135+
block: "nearest",
136+
}),
137+
500,
138+
);
139+
} else if (
140+
["ArrowLeft", "ArrowRight", "ArrowDown", "ArrowUp", "Delete"].includes(
141+
event.key,
142+
)
143+
) {
76144
const inputElement = event.currentTarget;
77145
let charsToRightOfCursor = "";
78-
let cursorIndex = currentLineInput.length - (inputElement.selectionStart || 0);
146+
let cursorIndex =
147+
currentLineInput.length - (inputElement.selectionStart || 0);
79148
cursorIndex = clamp(cursorIndex, 0, currentLineInput.length);
80149

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)
150+
if (event.key === "ArrowLeft") {
151+
if (cursorIndex > currentLineInput.length - 1) cursorIndex--;
152+
charsToRightOfCursor = currentLineInput.slice(
153+
currentLineInput.length - 1 - cursorIndex,
154+
);
155+
} else if (event.key === "ArrowRight" || event.key === "Delete") {
156+
charsToRightOfCursor = currentLineInput.slice(
157+
currentLineInput.length - cursorIndex + 1,
158+
);
159+
} else if (event.key === "ArrowUp") {
160+
charsToRightOfCursor = currentLineInput.slice(0);
161+
changeHistoryIndex(-1);
162+
} else if (event.key === "ArrowDown") {
163+
charsToRightOfCursor = currentLineInput.slice(0);
164+
changeHistoryIndex(1);
90165
}
91166

92-
const inputWidth = calculateInputWidth(inputElement, charsToRightOfCursor);
167+
const inputWidth = calculateInputWidth(
168+
inputElement,
169+
charsToRightOfCursor,
170+
);
93171
setCursorPos(inputWidth);
94172
}
95-
}
173+
};
96174

97175
useEffect(() => {
98176
setCurrentLineInput(startingInputValue.trim());
99177
}, [startingInputValue]);
100178

179+
// If history index changes or history length changes, we want to update the input value
180+
useEffect(() => {
181+
if (historyIndex >= 0 && historyIndex < history.length) {
182+
setCurrentLineInput(history[historyIndex]);
183+
}
184+
}, [historyIndex, history.length]);
185+
186+
// history local storage persistency
187+
useEffect(() => {
188+
const storedHistory = localStorage.getItem(terminalHistoryKey);
189+
if (storedHistory) {
190+
setHistory(JSON.parse(storedHistory));
191+
}
192+
}, []);
193+
194+
useEffect(() => {
195+
localStorage.setItem(terminalHistoryKey, JSON.stringify(history));
196+
}, [history]);
197+
101198
// We use a hidden input to capture terminal input; make sure the hidden input is focused when clicking anywhere on the terminal
102199
useEffect(() => {
103200
if (onInput == null) {
104201
return;
105202
}
106203
// 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);
204+
const elListeners: {
205+
terminalEl: Element;
206+
listener: EventListenerOrEventListenerObject;
207+
}[] = [];
208+
for (const terminalEl of document.getElementsByClassName(
209+
"react-terminal-wrapper",
210+
)) {
211+
const listener = () =>
212+
(
213+
terminalEl?.querySelector(".terminal-hidden-input") as HTMLElement
214+
)?.focus();
215+
terminalEl?.addEventListener("click", listener);
111216
elListeners.push({ terminalEl, listener });
112217
}
113-
return function cleanup () {
114-
elListeners.forEach(elListener => {
115-
elListener.terminalEl.removeEventListener('click', elListener.listener);
218+
return function cleanup() {
219+
elListeners.forEach((elListener) => {
220+
elListener.terminalEl.removeEventListener("click", elListener.listener);
116221
});
117-
}
222+
};
118223
}, [onInput]);
119224

120-
const classes = ['react-terminal-wrapper'];
225+
const classes = ["react-terminal-wrapper"];
121226
if (colorMode === ColorMode.Light) {
122-
classes.push('react-terminal-light');
227+
classes.push("react-terminal-light");
123228
}
229+
124230
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>
231+
<div className={classes.join(" ")} data-terminal-name={name}>
232+
<TopButtonsPanel
233+
{...{ redBtnCallback, yellowBtnCallback, greenBtnCallback }}
234+
/>
235+
<div className="react-terminal" style={{ height }}>
236+
{children}
237+
{typeof onInput === "function" && (
238+
<div
239+
className="react-terminal-line react-terminal-input react-terminal-active-input"
240+
data-terminal-prompt={prompt || "$"}
241+
key="terminal-line-prompt"
242+
>
243+
{currentLineInput}
244+
<span
245+
className="cursor"
246+
style={{ left: `${cursorPos + 1}px` }}
247+
></span>
248+
</div>
249+
)}
250+
<div ref={scrollIntoViewRef}></div>
131251
</div>
132-
<input className="terminal-hidden-input" placeholder="Terminal Hidden Input" value={ currentLineInput } autoFocus={ onInput != null } onChange={ updateCurrentLineInput } onKeyDown={ handleInputKeyDown }/>
252+
<input
253+
className="terminal-hidden-input"
254+
placeholder="Terminal Hidden Input"
255+
value={currentLineInput}
256+
autoFocus={onInput != null}
257+
onChange={updateCurrentLineInput}
258+
onKeyDown={handleInputKeyDown}
259+
/>
133260
</div>
134261
);
135-
}
262+
};
136263

137264
export { TerminalInput, TerminalOutput };
138265
export default Terminal;

0 commit comments

Comments
 (0)