|
import { FitAddon } from "@xterm/addon-fit";
|
|
import { Terminal } from "@xterm/xterm";
|
|
import React from "react";
|
|
import { Command } from "#/state/command-slice";
|
|
import { getTerminalCommand } from "#/services/terminal-service";
|
|
import { parseTerminalOutput } from "#/utils/parse-terminal-output";
|
|
import { useWsClient } from "#/context/ws-client-provider";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
interface UseTerminalConfig {
|
|
commands: Command[];
|
|
secrets: string[];
|
|
disabled: boolean;
|
|
}
|
|
|
|
const DEFAULT_TERMINAL_CONFIG: UseTerminalConfig = {
|
|
commands: [],
|
|
secrets: [],
|
|
disabled: false,
|
|
};
|
|
|
|
export const useTerminal = ({
|
|
commands,
|
|
secrets,
|
|
disabled,
|
|
}: UseTerminalConfig = DEFAULT_TERMINAL_CONFIG) => {
|
|
const { send } = useWsClient();
|
|
const terminal = React.useRef<Terminal | null>(null);
|
|
const fitAddon = React.useRef<FitAddon | null>(null);
|
|
const ref = React.useRef<HTMLDivElement>(null);
|
|
const lastCommandIndex = React.useRef(0);
|
|
const keyEventDisposable = React.useRef<{ dispose: () => void } | null>(null);
|
|
|
|
const createTerminal = () =>
|
|
new Terminal({
|
|
fontFamily: "Menlo, Monaco, 'Courier New', monospace",
|
|
fontSize: 14,
|
|
theme: {
|
|
background: "#262626",
|
|
},
|
|
});
|
|
|
|
const initializeTerminal = () => {
|
|
if (terminal.current) {
|
|
if (fitAddon.current) terminal.current.loadAddon(fitAddon.current);
|
|
if (ref.current) terminal.current.open(ref.current);
|
|
}
|
|
};
|
|
|
|
const copySelection = (selection: string) => {
|
|
const clipboardItem = new ClipboardItem({
|
|
"text/plain": new Blob([selection], { type: "text/plain" }),
|
|
});
|
|
|
|
navigator.clipboard.write([clipboardItem]);
|
|
};
|
|
|
|
const pasteSelection = (callback: (text: string) => void) => {
|
|
navigator.clipboard.readText().then(callback);
|
|
};
|
|
|
|
const pasteHandler = (event: KeyboardEvent, cb: (text: string) => void) => {
|
|
const isControlOrMetaPressed =
|
|
event.type === "keydown" && (event.ctrlKey || event.metaKey);
|
|
|
|
if (isControlOrMetaPressed) {
|
|
if (event.code === "KeyV") {
|
|
pasteSelection((text: string) => {
|
|
terminal.current?.write(text);
|
|
cb(text);
|
|
});
|
|
}
|
|
|
|
if (event.code === "KeyC") {
|
|
const selection = terminal.current?.getSelection();
|
|
if (selection) copySelection(selection);
|
|
}
|
|
}
|
|
|
|
return true;
|
|
};
|
|
|
|
const handleEnter = (command: string) => {
|
|
terminal.current?.write("\r\n");
|
|
send(getTerminalCommand(command));
|
|
};
|
|
|
|
const handleBackspace = (command: string) => {
|
|
terminal.current?.write("\b \b");
|
|
return command.slice(0, -1);
|
|
};
|
|
|
|
React.useEffect(() => {
|
|
|
|
terminal.current = createTerminal();
|
|
fitAddon.current = new FitAddon();
|
|
|
|
let resizeObserver: ResizeObserver | null = null;
|
|
|
|
if (ref.current) {
|
|
|
|
initializeTerminal();
|
|
terminal.current.write("$ ");
|
|
|
|
|
|
resizeObserver = new ResizeObserver(() => {
|
|
fitAddon.current?.fit();
|
|
});
|
|
resizeObserver.observe(ref.current);
|
|
}
|
|
|
|
return () => {
|
|
terminal.current?.dispose();
|
|
resizeObserver?.disconnect();
|
|
};
|
|
}, []);
|
|
|
|
React.useEffect(() => {
|
|
|
|
if (terminal.current && commands.length > 0) {
|
|
|
|
for (let i = lastCommandIndex.current; i < commands.length; i += 1) {
|
|
|
|
let { content, type } = commands[i];
|
|
|
|
secrets.forEach((secret) => {
|
|
content = content.replaceAll(secret, "*".repeat(10));
|
|
});
|
|
|
|
terminal.current?.writeln(
|
|
parseTerminalOutput(content.replaceAll("\n", "\r\n").trim()),
|
|
);
|
|
|
|
if (type === "output") {
|
|
terminal.current.write(`\n$ `);
|
|
}
|
|
}
|
|
|
|
lastCommandIndex.current = commands.length;
|
|
}
|
|
}, [commands]);
|
|
|
|
React.useEffect(() => {
|
|
if (terminal.current) {
|
|
|
|
if (keyEventDisposable.current) {
|
|
keyEventDisposable.current.dispose();
|
|
keyEventDisposable.current = null;
|
|
}
|
|
|
|
let commandBuffer = "";
|
|
|
|
if (!disabled) {
|
|
|
|
keyEventDisposable.current = terminal.current.onKey(
|
|
({ key, domEvent }) => {
|
|
if (domEvent.key === "Enter") {
|
|
handleEnter(commandBuffer);
|
|
commandBuffer = "";
|
|
} else if (domEvent.key === "Backspace") {
|
|
if (commandBuffer.length > 0) {
|
|
commandBuffer = handleBackspace(commandBuffer);
|
|
}
|
|
} else {
|
|
|
|
if (key.charCodeAt(0) === 22) {
|
|
return;
|
|
}
|
|
commandBuffer += key;
|
|
terminal.current?.write(key);
|
|
}
|
|
},
|
|
);
|
|
|
|
|
|
terminal.current.attachCustomKeyEventHandler((event) =>
|
|
pasteHandler(event, (text) => {
|
|
commandBuffer += text;
|
|
}),
|
|
);
|
|
} else {
|
|
|
|
keyEventDisposable.current = terminal.current.onKey((e) => {
|
|
e.domEvent.preventDefault();
|
|
e.domEvent.stopPropagation();
|
|
});
|
|
}
|
|
}
|
|
|
|
return () => {
|
|
if (keyEventDisposable.current) {
|
|
keyEventDisposable.current.dispose();
|
|
keyEventDisposable.current = null;
|
|
}
|
|
};
|
|
}, [disabled]);
|
|
|
|
return ref;
|
|
};
|
|
|