mirror of
https://github.com/pybricks/pybricks-code.git
synced 2026-09-15 02:54:07 +00:00
254 lines
8.9 KiB
TypeScript
254 lines
8.9 KiB
TypeScript
// SPDX-License-Identifier: MIT
|
|
// Copyright (c) 2020-2023 The Pybricks Authors
|
|
|
|
import 'xterm/css/xterm.css';
|
|
import './terminal.scss';
|
|
import {
|
|
ContextMenu,
|
|
Menu,
|
|
MenuDivider,
|
|
MenuItem,
|
|
ResizeSensor,
|
|
} from '@blueprintjs/core';
|
|
import { Blank, Clipboard, Duplicate, Trash } from '@blueprintjs/icons';
|
|
import React, { useContext, useEffect, useMemo, useRef, useState } from 'react';
|
|
import { useDispatch } from 'react-redux';
|
|
import { useTernaryDarkMode } from 'usehooks-ts';
|
|
import { Terminal as XTerm } from 'xterm';
|
|
import { FitAddon } from 'xterm-addon-fit';
|
|
import { isMacOS } from '../utils/os';
|
|
import { TerminalContext } from './TerminalContext';
|
|
import { receiveData } from './actions';
|
|
import { useI18n } from './i18n';
|
|
|
|
// Source: https://freesound.org/people/altemark/sounds/45759/
|
|
// This sound is released under the Creative Commons Attribution 3.0 Unported
|
|
// (CC BY 3.0) license. It was created by 'altemark'. No modifications have been
|
|
// made, apart from the conversion to base64.
|
|
const BELL_SOUND =
|
|
'data:audio/mp3;base64,SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjU4LjMyLjEwNAAAAAAAAAAAAAAA//tQxAADB8AhSmxhIIEVCSiJrDCQBTcu3UrAIwUdkRgQbFAZC1CQEwTJ9mjRvBA4UOLD8nKVOWfh+UlK3z/177OXrfOdKl7pyn3Xf//WreyTRUoAWgBgkOAGbZHBgG1OF6zM82DWbZaUmMBptgQhGjsyYqc9ae9XFz280948NMBWInljyzsNRFLPWdnZGWrddDsjK1unuSrVN9jJsK8KuQtQCtMBjCEtImISdNKJOopIpBFpNSMbIHCSRpRR5iakjTiyzLhchUUBwCgyKiweBv/7UsQbg8isVNoMPMjAAAA0gAAABEVFGmgqK////9bP/6XCykxBTUUzLjEwMKqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq';
|
|
|
|
function handleKeyEvent(event: KeyboardEvent): boolean {
|
|
if (
|
|
event.key === 'v' &&
|
|
event.ctrlKey &&
|
|
!event.shiftKey &&
|
|
!event.altKey &&
|
|
!event.metaKey
|
|
) {
|
|
// this allows CTRL+V to be handled by the browser instead of sending
|
|
// a control character to the terminal.
|
|
return false;
|
|
}
|
|
|
|
if (event.key === 'F5' || event.key === 'F6') {
|
|
// allow global handler for these keys
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
function createXTerm(): { xterm: XTerm; fitAddon: FitAddon } {
|
|
const xterm = new XTerm({
|
|
cursorBlink: true,
|
|
cursorStyle: 'underline',
|
|
fontSize: 18,
|
|
});
|
|
const fitAddon = new FitAddon();
|
|
xterm.loadAddon(fitAddon);
|
|
xterm.attachCustomKeyEventHandler(handleKeyEvent);
|
|
|
|
return { xterm, fitAddon };
|
|
}
|
|
|
|
type ContextMenuContentProps = { xterm: XTerm };
|
|
|
|
const ContextMenuContent: React.FunctionComponent<ContextMenuContentProps> = ({
|
|
xterm,
|
|
}) => {
|
|
const i18n = useI18n();
|
|
const [hasSelection, setHasSelection] = useState(xterm.hasSelection());
|
|
|
|
useEffect(() => {
|
|
const subscription = xterm.onSelectionChange(() => {
|
|
setHasSelection(xterm.hasSelection());
|
|
});
|
|
|
|
return () => subscription.dispose();
|
|
}, [xterm, setHasSelection]);
|
|
|
|
return (
|
|
<Menu>
|
|
<MenuItem
|
|
onClick={(): void => {
|
|
const selected = xterm.getSelection();
|
|
if (selected) {
|
|
navigator.clipboard.writeText(selected);
|
|
}
|
|
}}
|
|
text={i18n.translate('copy')}
|
|
icon={<Duplicate />}
|
|
label={isMacOS() ? 'Cmd-C' : 'Ctrl-Shift-C'}
|
|
disabled={!hasSelection}
|
|
/>
|
|
<MenuItem
|
|
onClick={async (): Promise<void> => {
|
|
xterm.paste(await navigator.clipboard.readText());
|
|
}}
|
|
text={i18n.translate('paste')}
|
|
icon={<Clipboard />}
|
|
label={isMacOS() ? 'Cmd-V' : 'Ctrl-V'}
|
|
/>
|
|
<MenuItem
|
|
onClick={() => xterm.selectAll()}
|
|
text={i18n.translate('selectAll')}
|
|
icon={<Blank />}
|
|
/>
|
|
<MenuDivider />
|
|
<MenuItem
|
|
onClick={(): void => xterm.clear()}
|
|
text={i18n.translate('clear')}
|
|
icon={<Trash />}
|
|
/>
|
|
</Menu>
|
|
);
|
|
};
|
|
const Terminal: React.FunctionComponent = () => {
|
|
const { xterm, fitAddon } = useMemo(createXTerm, [createXTerm]);
|
|
const terminalRef = useRef<HTMLDivElement>(null);
|
|
const { isDarkMode } = useTernaryDarkMode();
|
|
const dispatch = useDispatch();
|
|
const terminalStream = useContext(TerminalContext);
|
|
|
|
// xterm.open() has to be called after terminalRef has been rendered
|
|
useEffect(() => {
|
|
// istanbul ignore if: should not happen ever
|
|
if (!terminalRef.current) {
|
|
console.error('Missing terminal reference');
|
|
return;
|
|
}
|
|
|
|
xterm.open(terminalRef.current);
|
|
fitAddon.fit();
|
|
|
|
// HACK: remove terminal from keyboard tab focus
|
|
// Since it steals tab key presses, there is no way
|
|
// to get out of it, so we use landmark navigation instead.
|
|
xterm.element?.removeAttribute('tabindex');
|
|
xterm.textarea?.setAttribute('tabindex', '-1');
|
|
|
|
return () => xterm.dispose();
|
|
}, [xterm, fitAddon]);
|
|
|
|
// wire up isDarkMode to terminal
|
|
useEffect(() => {
|
|
xterm.options.theme = {
|
|
background: isDarkMode ? 'black' : 'white',
|
|
foreground: isDarkMode ? 'white' : 'black',
|
|
cursor: isDarkMode ? 'white' : 'black',
|
|
// transparency is needed to work around https://github.com/xtermjs/xterm.js/issues/2808
|
|
selectionBackground: isDarkMode
|
|
? 'rgb(81,81,81,0.5)'
|
|
: 'rgba(181,213,255,0.5)', // this should match editor theme
|
|
};
|
|
}, [isDarkMode, xterm]);
|
|
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent): void => {
|
|
// implement CTRL+SHIFT+C keyboard shortcut for copying text from terminal
|
|
if (e.key === 'C' && e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey) {
|
|
// this would otherwise open up debug console in web browser
|
|
e.preventDefault();
|
|
|
|
if (
|
|
document.hasFocus() &&
|
|
document.activeElement ===
|
|
terminalRef.current?.getElementsByClassName(
|
|
'xterm-helper-textarea',
|
|
)[0] &&
|
|
xterm.hasSelection()
|
|
) {
|
|
navigator.clipboard.writeText(xterm.getSelection());
|
|
}
|
|
}
|
|
};
|
|
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
}, [terminalRef, xterm]);
|
|
|
|
// wire shared context to terminal output
|
|
useEffect(() => {
|
|
const subscription = terminalStream.dataSource.observable.subscribe({
|
|
next: (d) => xterm.write(d),
|
|
});
|
|
|
|
return () => subscription.unsubscribe();
|
|
}, [terminalStream, xterm]);
|
|
|
|
// wire terminal input to actions
|
|
useEffect(() => {
|
|
const onDataHandle = xterm.onData((d) => dispatch(receiveData(d)));
|
|
return () => onDataHandle.dispose();
|
|
}, [dispatch, xterm]);
|
|
|
|
useEffect(() => {
|
|
const listener = () => {
|
|
xterm.focus();
|
|
};
|
|
|
|
addEventListener('pb-terminal-focus', listener);
|
|
|
|
return () => removeEventListener('pb-terminal-focus', listener);
|
|
}, [xterm]);
|
|
|
|
// audio and visual notification of bell
|
|
|
|
const bellRef = useRef<HTMLAudioElement>(null);
|
|
const bellOverlayRef = useRef<HTMLDivElement>(null);
|
|
const bellTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
|
|
|
useEffect(() => {
|
|
const audioElement = bellRef.current;
|
|
const overlayElement = bellOverlayRef.current;
|
|
|
|
if (process.env.NODE_ENV === 'test' || !audioElement || !overlayElement) {
|
|
return;
|
|
}
|
|
|
|
const subscription = xterm.onBell(() => {
|
|
if (bellTimeoutRef.current) {
|
|
clearTimeout(bellTimeoutRef.current);
|
|
}
|
|
|
|
audioElement.play();
|
|
overlayElement.classList.add('pb-bell');
|
|
|
|
bellTimeoutRef.current = setTimeout(() => {
|
|
overlayElement.classList.remove('pb-bell');
|
|
}, 150);
|
|
});
|
|
|
|
return () => subscription.dispose();
|
|
}, [xterm, bellRef, bellOverlayRef, bellTimeoutRef]);
|
|
|
|
return (
|
|
<ContextMenu
|
|
className="h-100"
|
|
content={<ContextMenuContent xterm={xterm} />}
|
|
popoverProps={{ onClosed: () => xterm.focus() }}
|
|
>
|
|
<audio hidden preload="auto" ref={bellRef}>
|
|
<source src={BELL_SOUND} />
|
|
</audio>
|
|
<div className="pb-terminal-bell-overlay" ref={bellOverlayRef} />
|
|
<ResizeSensor targetRef={terminalRef} onResize={(): void => fitAddon.fit()}>
|
|
<div ref={terminalRef} className="h-100 pb-focus-managed" />
|
|
</ResizeSensor>
|
|
</ContextMenu>
|
|
);
|
|
};
|
|
|
|
export default Terminal;
|