/* Terminal.js - ESP3D WebUI component file Copyright (c) 2021 Luc LEBOSSE. All rights reserved. This code is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation; either version 2.1 of the License, or (at your option) any later version. This code is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. You should have received a copy of the GNU Lesser General Public License along with This code; if not, write to the Free Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA */ import { TargetedKeyboardEvent, TargetedEvent, type FunctionalComponent, type VNode } from "preact" import { useEffect, useRef, useState, useMemo } from "preact/hooks" import { T } from "../Translations" import { Terminal, Send, CheckCircle, Circle, PauseCircle, ChevronLeft, ChevronRight, } from "preact-feather" import { useUiContext, useDatasContext, useUiContextFn, type TerminalElement } from "../../contexts" import { useTargetContext, variablesList } from "../../targets" import { useTargetCommands } from "../../hooks" import { replaceVariables } from "../Helpers" import { ButtonImg, FullScreenButton, CloseButton, ContainerHelper } from "../Controls" import { Menu as PanelMenu } from "./" /* * Local const * */ type TerminalLine = TerminalElement & { content: string type?: string isAction?: boolean actionType?: string isverboseOnly?: boolean } const TerminalPanel: FunctionalComponent = () => { const { panels, uisettings } = useUiContext() const { terminal } = useDatasContext() const { processData } = useTargetContext() const { targetCommands } = useTargetCommands() if (terminal.isVerbose.current == undefined) terminal.isVerbose.current = uisettings.getValue("verbose") if (terminal.isAutoScroll.current == undefined) terminal.isAutoScroll.current = uisettings.getValue("autoscroll") if (terminal.maxTerminalMessages.current == undefined) terminal.maxTerminalMessages.current = uisettings.getValue("MaxTerminalMessages") || 400 const [isVerbose, setIsVerbose] = useState(terminal.isVerbose.current) const [isAutoScroll, setIsAutoScroll] = useState( terminal.isAutoScroll.current ) const [isAutoScrollPaused, setIsAutoScrollPaused] = useState(false) let lastPos: number = 0 const inputRef = useRef(null) const messagesEndRef = useRef(null) const terminalOutput = useRef(null) const id = "terminalPanel" const inputHistoryIndex = useRef(terminal.inputHistory.length) const renderedMessages = useRef>([]) const lastRenderedCount = useRef(0) const lastFirstMessage = useRef(null) const scrollToBottom = () => { if ( terminal.isAutoScroll.current && !terminal.isAutoScrollPaused.current ) { terminalOutput.current!.scrollTop = terminalOutput.current!.scrollHeight } } const renderLine = (line: TerminalLine, index: number): VNode | null => { if (line.isAction) { return (
                    {line.content}
                
) } else if ( isVerbose || isVerbose === line.isverboseOnly ) { let className = "" switch (line.type) { case "echo": className = "echo" break case "error": className = "error" break case "stream": if (line.content.startsWith("ALARM:") || line.content.startsWith("Hold:") || line.content.startsWith("Door:") ) { className = "warning" } else if (line.content.startsWith("error:")) { className = "error" } else if (line.content.startsWith("<Hold{line.content.substring(5)} } else if (line.content.startsWith("<Door{line.content.substring(5)} } else if (line.content.startsWith("<Alarm{line.content.substring(6)} } else if (line.content.startsWith("[MSG:ERR")) { return
[MSG:ERR{line.content.substring(8)}
} else if (line.content.startsWith("[MSG:WARN")) { return
[MSG:WARN{line.content.substring(9)}
} else if (line.content.startsWith("[MSG:INFO")) { return
[MSG:INFO{line.content.substring(9)}
} break default: //do nothing } return
{line.content}
} return null } const historyPrev = (): void => { if (terminal.inputHistory.length > 0 && inputHistoryIndex.current > 0) { inputHistoryIndex.current-- if (inputRef.current) inputRef.current.value = terminal.inputHistory[inputHistoryIndex.current] terminal.input.current = inputRef.current ? inputRef.current.value : "" } } const historyNext = (): void => { if ( terminal.inputHistory.length > 0 && inputHistoryIndex.current < terminal.inputHistory.length-1 ) { inputHistoryIndex.current++ if (inputRef.current) inputRef.current.value = terminal.inputHistory[inputHistoryIndex.current] terminal.input.current = inputRef.current ? inputRef.current.value : "" } else { if (inputRef.current) inputRef.current.value = "" terminal.input.current = inputRef.current ? inputRef.current.value : "" inputHistoryIndex.current = terminal.inputHistory.length } } const onKeyUp = (e: TargetedKeyboardEvent) => { switch (e.key) { case "Enter": onSend(e) break case "ArrowUp": //prev historyPrev() break case "ArrowDown": //next historyNext() break default: //ignore } } const onSend = (_e?: Event) => { useUiContextFn.haptic() inputRef.current?.focus() if (!terminal.input.current && variablesList.allowEmptyLine) terminal.input.current = "" if ( (terminal.input.current && terminal.input.current.trim().length > 0) || variablesList.allowEmptyLine ) { const cmd = terminal.input.current.trim() if ( cmd.length > 0 && terminal.inputHistory[terminal.inputHistory.length - 1] != cmd ) { terminal.addInputHistory(cmd) } inputHistoryIndex.current = terminal.inputHistory.length // echo here to prevent verbose mode from filtering out // polling commands and whatnot. If the user sends it // explicitly, we want to echo it. processData( "echo", replaceVariables(variablesList.commands, cmd, true) ) // echo:false because it was echoed above targetCommands(cmd, null, { echo: false }) } inputHistoryIndex.current = terminal.inputHistory.length terminal.input.current = "" if (inputRef.current) inputRef.current.value = "" } const onInput = (e: TargetedEvent) => { terminal.input.current = e.currentTarget.value } useEffect(() => { scrollToBottom() }, [terminal.content]) useEffect(() => { return () => { //console.log('Resetting terminal history'); inputHistoryIndex.current = terminal.inputHistory.length - 1; }; }, []); const toggleVerboseMode = (): void => { useUiContextFn.haptic() terminal.isVerbose.current = !isVerbose setIsVerbose(!isVerbose) } const toggleAutoScroll = (): void => { useUiContextFn.haptic() if (!isAutoScrollPaused) { terminal.isAutoScroll.current = !isAutoScroll setIsAutoScroll(!isAutoScroll) } terminal.isAutoScrollPaused.current = false setIsAutoScrollPaused(false) scrollToBottom() } const menu = [ { label: T("S76"), displayToggle: () => ( {isVerbose ? ( ) : ( )} ), onClick: toggleVerboseMode, }, { label: T("S77"), displayToggle: () => ( {isAutoScroll ? ( isAutoScrollPaused ? ( ) : ( ) ) : ( )} ), onClick: toggleAutoScroll, }, { divider: true }, { label: T("S79"), onClick: (_e: MouseEvent) => { useUiContextFn.haptic() terminal.clear() }, icon: , }, ] return (
} onClick={onSend} />
} onClick={historyPrev} /> } onClick={historyNext} />
) => { const el = e.currentTarget if ( lastPos > el.scrollTop && terminal.isAutoScroll.current ) { terminal.isAutoScrollPaused.current = true setIsAutoScrollPaused(true) } if ( terminal.isAutoScrollPaused.current && Math.abs( el.scrollTop + el.offsetHeight - el.scrollHeight ) < 5 ) { terminal.isAutoScrollPaused.current = false setIsAutoScrollPaused(false) } lastPos = el.scrollTop }} > {useMemo(() => { if (!terminal.content) return null const currentCount = terminal.content.length const firstMessage = terminal.content[0] // If verbose mode changed, content was cleared, or messages rolled off (first message changed), re-render everything if (currentCount < lastRenderedCount.current || (currentCount > 0 && firstMessage !== lastFirstMessage.current)) { // Re-render all current messages renderedMessages.current = terminal.content.map((line, index) => renderLine(line as unknown as TerminalLine, index)) lastRenderedCount.current = currentCount lastFirstMessage.current = firstMessage as unknown as TerminalLine } else { // Only process new messages since last render for (let i = lastRenderedCount.current; i < currentCount; i++) { renderedMessages.current.push(renderLine(terminal.content[i] as unknown as TerminalLine, i)) } lastRenderedCount.current = currentCount lastFirstMessage.current = firstMessage as unknown as TerminalLine } return renderedMessages.current }, [terminal.content, isVerbose])}
) } const TerminalPanelElement = { id: "terminalPanel", content: , name: "S75", icon: "Terminal", show: "showterminalpanel", onstart: "openterminalonstart", settingid: "terminal", } export { TerminalPanel, TerminalPanelElement }