mirror of
https://github.com/tale/headplane.git
synced 2026-07-26 07:48:14 +00:00
feat: redo xterm terminal logic
This commit is contained in:
Vendored
+3
-3
@@ -43,9 +43,9 @@ interface XtermConfig {
|
||||
cols: number;
|
||||
timeout?: number;
|
||||
|
||||
onStdout: (data: string) => void;
|
||||
onStderr: (data: string) => void;
|
||||
onStdin: (func: (input: string) => void) => void;
|
||||
onStdout: (data: Uint8Array) => void;
|
||||
onStderr: (data: Uint8Array) => void;
|
||||
onStdin: (func: (input: Uint8Array) => void) => void;
|
||||
|
||||
onConnect: () => void;
|
||||
onDisconnect: () => void;
|
||||
|
||||
+137
-97
@@ -2,153 +2,192 @@ import { ClipboardAddon } from '@xterm/addon-clipboard';
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
import { Unicode11Addon } from '@xterm/addon-unicode11';
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links';
|
||||
import { WebglAddon } from '@xterm/addon-webgl';
|
||||
import * as xterm from '@xterm/xterm';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import cn from '~/utils/cn';
|
||||
import { useLiveData } from '~/utils/live-data';
|
||||
import toast from '~/utils/toast';
|
||||
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
|
||||
interface XTermProps {
|
||||
ipn: TsWasmNet;
|
||||
username: string;
|
||||
hostname: string;
|
||||
}
|
||||
|
||||
// Go's WASM -> JS crosses realms so we might have to normalize the data under
|
||||
// certain conditions. This also enforces bytes instead of strings being sent.
|
||||
function normU8(data: unknown) {
|
||||
if (data instanceof Uint8Array) {
|
||||
return data;
|
||||
}
|
||||
|
||||
if (data && typeof data === 'object') {
|
||||
const any = data as {
|
||||
buffer?: ArrayBufferLike;
|
||||
byteOffset?: number;
|
||||
byteLength?: number;
|
||||
};
|
||||
|
||||
if (
|
||||
any.buffer instanceof ArrayBuffer &&
|
||||
typeof any.byteLength === 'number'
|
||||
) {
|
||||
return new Uint8Array(
|
||||
any.buffer.slice(
|
||||
any.byteOffset ?? 0,
|
||||
(any.byteOffset ?? 0) + any.byteLength,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Data is not a Uint8Array or ArrayBuffer-like object');
|
||||
}
|
||||
|
||||
export default function XTerm({ ipn, username, hostname }: XTermProps) {
|
||||
const { pause } = useLiveData();
|
||||
|
||||
const container = useRef<HTMLDivElement>(null);
|
||||
const term = useRef<xterm.Terminal>(null);
|
||||
const inputRef = useRef<((input: string) => void) | null>(null);
|
||||
const genRef = useRef(0);
|
||||
const termRef = useRef<xterm.Terminal>(null);
|
||||
const roRef = useRef<ResizeObserver>(null);
|
||||
const inputRef = useRef<(input: Uint8Array) => void>(null);
|
||||
const sshRef = useRef<SSHSession>(null);
|
||||
const divRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isFailed, setIsFailed] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
pause();
|
||||
if (!container.current) {
|
||||
console.error('Container ref is not set');
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!divRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't create a new terminal if one already exists
|
||||
if (term.current) {
|
||||
console.warn('Terminal already exists, skipping initialization');
|
||||
return;
|
||||
}
|
||||
|
||||
const terminal = new xterm.Terminal({
|
||||
const currentGen = ++genRef.current;
|
||||
const term = new xterm.Terminal({
|
||||
allowProposedApi: true,
|
||||
cursorBlink: true,
|
||||
convertEol: true,
|
||||
fontSize: 14,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
theme: {
|
||||
background: '#1e1e1e',
|
||||
foreground: '#ffffff',
|
||||
},
|
||||
});
|
||||
|
||||
terminal.loadAddon(new Unicode11Addon());
|
||||
terminal.loadAddon(new ClipboardAddon());
|
||||
terminal.loadAddon(
|
||||
const fit = new FitAddon();
|
||||
term.loadAddon(fit);
|
||||
|
||||
term.loadAddon(new Unicode11Addon());
|
||||
term.loadAddon(new ClipboardAddon());
|
||||
term.loadAddon(
|
||||
new WebLinksAddon((event, uri) => {
|
||||
event.view?.open(uri, '_blank', 'noopener noreferrer');
|
||||
}),
|
||||
);
|
||||
|
||||
terminal.unicode.activeVersion = '11';
|
||||
const fit = new FitAddon();
|
||||
terminal.loadAddon(fit);
|
||||
|
||||
const gl = new WebglAddon();
|
||||
terminal.loadAddon(gl);
|
||||
|
||||
gl.onContextLoss(() => {
|
||||
console.warn('WebGL context lost, falling back to canvas rendering');
|
||||
gl.dispose();
|
||||
});
|
||||
|
||||
term.unicode.activeVersion = '11';
|
||||
termRef.current = term;
|
||||
term.open(divRef.current!);
|
||||
fit.fit();
|
||||
term.current = terminal;
|
||||
terminal.open(container.current!);
|
||||
terminal.focus();
|
||||
term.focus();
|
||||
|
||||
let onUnload: ((e: Event) => void) | null = null;
|
||||
const session = ipn.OpenSSH(hostname, username, {
|
||||
rows: terminal.rows,
|
||||
cols: terminal.cols,
|
||||
|
||||
onStdout: (data) => terminal.write(data),
|
||||
onStderr: (data) => {
|
||||
terminal.write(data);
|
||||
console.log('SSH stderr:', data);
|
||||
toast(data);
|
||||
},
|
||||
onStdin: (func) => {
|
||||
console.log('SSH session is ready to receive input');
|
||||
inputRef.current = func;
|
||||
console.log('Stdin handler set', func);
|
||||
},
|
||||
onConnect: () => {
|
||||
console.log('SSH session connected');
|
||||
setIsLoading(false);
|
||||
},
|
||||
|
||||
onDisconnect: () => {
|
||||
ro?.disconnect();
|
||||
terminal.dispose();
|
||||
if (onUnload) {
|
||||
parent.removeEventListener('unload', onUnload);
|
||||
rows: term.rows,
|
||||
cols: term.cols,
|
||||
onStdout: (data) => {
|
||||
if (currentGen !== genRef.current || term !== termRef.current) {
|
||||
console.warn('Stale terminal instance, ignoring stdout');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('SSH session disconnected');
|
||||
terminal.writeln('Disconnected from SSH session');
|
||||
setIsLoading(false);
|
||||
setIsFailed(true);
|
||||
term.current = null;
|
||||
const text = normU8(data);
|
||||
term.write(text);
|
||||
},
|
||||
onStderr: (data) => {
|
||||
if (currentGen !== genRef.current || term !== termRef.current) {
|
||||
console.warn('Stale terminal instance, ignoring stderr');
|
||||
return;
|
||||
}
|
||||
|
||||
const text = normU8(data);
|
||||
term.write(text);
|
||||
const str = new TextDecoder().decode(text);
|
||||
setError(str);
|
||||
},
|
||||
onStdin: (func) => {
|
||||
inputRef.current = func;
|
||||
},
|
||||
onConnect: () => {
|
||||
if (currentGen !== genRef.current) {
|
||||
console.warn('Stale terminal instance, ignoring onConnect');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
},
|
||||
onDisconnect: () => {
|
||||
if (currentGen !== genRef.current) {
|
||||
console.warn('Stale terminal instance, ignoring onDisconnect');
|
||||
return;
|
||||
}
|
||||
|
||||
roRef.current?.disconnect();
|
||||
term.dispose();
|
||||
termRef.current = null;
|
||||
inputRef.current = null;
|
||||
sshRef.current = null;
|
||||
|
||||
setIsLoading(false);
|
||||
},
|
||||
});
|
||||
|
||||
sshRef.current = session;
|
||||
const enc = new TextEncoder();
|
||||
term.onData((data) => {
|
||||
if (currentGen !== genRef.current) {
|
||||
console.warn('Stale terminal instance, ignoring onData');
|
||||
return;
|
||||
}
|
||||
|
||||
const bytes = enc.encode(data);
|
||||
inputRef.current?.(bytes);
|
||||
});
|
||||
|
||||
const ro = new ResizeObserver(() => {
|
||||
if (term.current) {
|
||||
setIsResizing(true);
|
||||
fit.fit();
|
||||
setTimeout(() => setIsResizing(false), 100);
|
||||
if (currentGen !== genRef.current || term !== termRef.current) {
|
||||
console.warn('Stale terminal instance, ignoring resize');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsResizing(true);
|
||||
fit.fit();
|
||||
sshRef.current?.Resize(term.cols, term.rows);
|
||||
setTimeout(() => setIsResizing(false), 100);
|
||||
});
|
||||
|
||||
ro.observe(container.current);
|
||||
terminal.onResize(({ cols, rows }) => {
|
||||
console.log(`Terminal resized to ${cols}x${rows}`);
|
||||
session.Resize(cols, rows);
|
||||
});
|
||||
|
||||
terminal.onData((data) => {
|
||||
inputRef.current?.(data);
|
||||
});
|
||||
|
||||
onUnload = (_) => session.Close();
|
||||
parent.addEventListener('unload', onUnload);
|
||||
roRef.current = ro;
|
||||
ro.observe(divRef.current!);
|
||||
|
||||
return () => {
|
||||
if (onUnload) {
|
||||
parent.removeEventListener('unload', onUnload);
|
||||
++genRef.current;
|
||||
roRef.current?.disconnect();
|
||||
roRef.current = null;
|
||||
|
||||
sshRef.current?.Close();
|
||||
sshRef.current = null;
|
||||
|
||||
term.dispose();
|
||||
if (termRef.current === term) {
|
||||
termRef.current = null;
|
||||
}
|
||||
|
||||
session.Close();
|
||||
ro?.disconnect();
|
||||
terminal.dispose();
|
||||
term.current = null;
|
||||
inputRef.current = null;
|
||||
console.log('SSH session closed and terminal disposed');
|
||||
};
|
||||
}, []);
|
||||
}, [ipn, username, hostname]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -159,19 +198,19 @@ export default function XTerm({ ipn, username, hostname }: XTermProps) {
|
||||
) : undefined}
|
||||
<div
|
||||
className={cn('w-full h-full', isLoading ? 'opacity-0' : 'opacity-100')}
|
||||
ref={container}
|
||||
ref={divRef}
|
||||
/>
|
||||
{term.current && isResizing ? (
|
||||
{termRef.current && isResizing ? (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2',
|
||||
'px-4 py-2 bg-headplane-800 text-white rounded-full shadow z-50',
|
||||
)}
|
||||
>
|
||||
{term.current.cols}x{term.current.rows}
|
||||
{termRef.current.cols}x{termRef.current.rows}
|
||||
</div>
|
||||
) : undefined}
|
||||
{isFailed ? (
|
||||
{error !== null ? (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-center',
|
||||
@@ -179,6 +218,7 @@ export default function XTerm({ ipn, username, hostname }: XTermProps) {
|
||||
)}
|
||||
>
|
||||
Failed to connect to SSH session
|
||||
{error}
|
||||
</div>
|
||||
) : undefined}
|
||||
</>
|
||||
|
||||
+10
-25
@@ -4,7 +4,6 @@ package hp_ipn
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"syscall/js"
|
||||
)
|
||||
|
||||
@@ -43,14 +42,14 @@ func ParseTsWasmNetOptions(obj js.Value) (*TsWasmNetOptions, error) {
|
||||
|
||||
// Options passed from the JS side to pass data to xterm.js.
|
||||
type SSHXtermConfig struct {
|
||||
Timeout int // Timeout in seconds for the PTY connection.
|
||||
Rows int // Number of rows in the PTY.
|
||||
Cols int // Number of columns in the PTY.
|
||||
OnStdout func(data string) // Fires when the PTY has output.
|
||||
OnStderr func(error string) // Fires when the PTY has an error.
|
||||
OnStdin js.Value // Passes a function to the JS side to provide input.
|
||||
OnConnect func() // Fires when the PTY is opened.
|
||||
OnDisconnect func() // Fires when the PTY is closed.
|
||||
Timeout int // Timeout in seconds for the PTY connection.
|
||||
Rows int // Number of rows in the PTY.
|
||||
Cols int // Number of columns in the PTY.
|
||||
OnStdout func(data js.Value) // Fires when the PTY has output.
|
||||
OnStderr func(error js.Value) // Fires when the PTY has an error.
|
||||
OnStdin js.Value // Passes a function to the JS side to provide input.
|
||||
OnConnect func() // Fires when the PTY is opened.
|
||||
OnDisconnect func() // Fires when the PTY is closed.
|
||||
}
|
||||
|
||||
// Parses the provided JS object to validate and extract SSHXtermConfig.
|
||||
@@ -82,7 +81,7 @@ func ParseSSHXtermConfig(obj js.Value) (*SSHXtermConfig, error) {
|
||||
return nil, errors.New("`onStdout` is required and must be a function")
|
||||
}
|
||||
|
||||
config.OnStdout = func(data string) {
|
||||
config.OnStdout = func(data js.Value) {
|
||||
onStdout.Invoke(data)
|
||||
}
|
||||
|
||||
@@ -91,7 +90,7 @@ func ParseSSHXtermConfig(obj js.Value) (*SSHXtermConfig, error) {
|
||||
return nil, errors.New("`onStderr` is required and must be a function")
|
||||
}
|
||||
|
||||
config.OnStderr = func(error string) {
|
||||
config.OnStderr = func(error js.Value) {
|
||||
onStderr.Invoke(error)
|
||||
}
|
||||
|
||||
@@ -123,20 +122,6 @@ func ParseSSHXtermConfig(obj js.Value) (*SSHXtermConfig, error) {
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func (t *SSHXtermConfig) PassStdinHandler(fn func(string)) {
|
||||
handler := js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||
if len(args) != 1 || args[0].Type() != js.TypeString {
|
||||
return nil
|
||||
}
|
||||
|
||||
fn(args[0].String())
|
||||
return nil
|
||||
})
|
||||
|
||||
log.Printf("Passing stdin handler to JS: %v", handler)
|
||||
t.OnStdin.Invoke(handler)
|
||||
}
|
||||
|
||||
// Retrieves a string value from a JS object safely.
|
||||
func safeString(key string, obj js.Value) string {
|
||||
if obj.IsUndefined() || obj.IsNull() {
|
||||
|
||||
+85
-28
@@ -3,11 +3,12 @@
|
||||
package hp_ipn
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"syscall/js"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
@@ -35,6 +36,9 @@ type SSHSession struct {
|
||||
|
||||
// Tracks resize notifications for columns.
|
||||
ResizeCols int
|
||||
|
||||
// Reference to our stdin handler, released on close.
|
||||
stdinHandler *js.Func
|
||||
}
|
||||
|
||||
// Creates a new SSH session given a hostname and username.
|
||||
@@ -92,24 +96,6 @@ func (s *SSHSession) ConnectAndRun() {
|
||||
defer pty.Close()
|
||||
s.Pty = pty
|
||||
|
||||
pty.Stdout = XtermPipe{s.TermConfig.OnStdout}
|
||||
pty.Stderr = XtermPipe{s.TermConfig.OnStdout}
|
||||
|
||||
// TODO: Set Stdin func
|
||||
stdin, err := pty.StdinPipe()
|
||||
if err != nil {
|
||||
s.writeError("SSH", err)
|
||||
return
|
||||
}
|
||||
|
||||
s.TermConfig.PassStdinHandler(func(input string) {
|
||||
_, err := stdin.Write([]byte(input))
|
||||
if err != nil {
|
||||
s.writeError("SSH", err)
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
rows := s.TermConfig.Rows
|
||||
if s.ResizeRows != 0 {
|
||||
rows = s.ResizeRows
|
||||
@@ -120,12 +106,45 @@ func (s *SSHSession) ConnectAndRun() {
|
||||
cols = s.ResizeCols
|
||||
}
|
||||
|
||||
err = pty.RequestPty("xterm", rows, cols, ssh.TerminalModes{})
|
||||
err = pty.RequestPty("xterm", rows, cols, ssh.TerminalModes{
|
||||
ssh.ECHO: 1, // enable echoing
|
||||
ssh.ICANON: 1, // canonical mode
|
||||
ssh.ISIG: 1, // enable signals
|
||||
ssh.ICRNL: 1, // map CR to NL on input
|
||||
ssh.IUTF8: 1, // input is UTF-8
|
||||
ssh.TTY_OP_ISPEED: 14400, // input speed = 14.4kbaud
|
||||
ssh.TTY_OP_OSPEED: 14400, // output speed = 14.4kbaud
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
s.writeError("SSH", err)
|
||||
return
|
||||
}
|
||||
|
||||
stdin, err := pty.StdinPipe()
|
||||
if err != nil {
|
||||
s.writeError("SSH", err)
|
||||
return
|
||||
}
|
||||
|
||||
s.wireStdinHandler(stdin)
|
||||
|
||||
stdout, err := pty.StdoutPipe()
|
||||
if err != nil {
|
||||
s.writeError("SSH", err)
|
||||
return
|
||||
}
|
||||
|
||||
stderr, err := pty.StderrPipe()
|
||||
if err != nil {
|
||||
s.writeError("SSH", err)
|
||||
return
|
||||
}
|
||||
|
||||
go io.Copy(XtermPipe{s.TermConfig.OnStdout}, stdout)
|
||||
go io.Copy(XtermPipe{s.TermConfig.OnStderr}, stderr)
|
||||
|
||||
// Create our shell
|
||||
err = pty.Shell()
|
||||
if err != nil {
|
||||
s.writeError("SSH", err)
|
||||
@@ -155,29 +174,67 @@ func (s *SSHSession) Resize(rows, cols int) error {
|
||||
|
||||
// Closes the SSH session.
|
||||
func (s *SSHSession) Close() error {
|
||||
if s.Pty == nil {
|
||||
return nil
|
||||
if s.stdinHandler != nil {
|
||||
s.stdinHandler.Release()
|
||||
s.stdinHandler = nil
|
||||
}
|
||||
|
||||
return s.Pty.Close()
|
||||
if s.Pty != nil {
|
||||
err := s.Pty.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Wires up the stdin handler to pass data from JS to the SSH session.
|
||||
func (s *SSHSession) wireStdinHandler(w io.Writer) {
|
||||
if s.stdinHandler != nil {
|
||||
s.stdinHandler.Release()
|
||||
s.stdinHandler = nil
|
||||
}
|
||||
|
||||
cb := js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||
v := args[0] // This is ALWAYS a Uint8Array technically
|
||||
len := v.Get("byteLength").Int()
|
||||
buf := make([]byte, len)
|
||||
js.CopyBytesToGo(buf, v)
|
||||
|
||||
if _, err := w.Write(buf); err != nil {
|
||||
s.writeError("SSH Stdin", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
// TODO: Remove debug log
|
||||
log.Printf("SSH wrote %d bytes: %v (%q)", len, buf, string(buf))
|
||||
return nil
|
||||
})
|
||||
|
||||
s.stdinHandler = &cb
|
||||
s.TermConfig.OnStdin.Invoke(cb)
|
||||
}
|
||||
|
||||
// Quick easy formatter for writing errors to the terminal.
|
||||
func (s *SSHSession) writeError(label string, err error) {
|
||||
o := fmt.Sprintf("%s error: %v\r\n", label, err)
|
||||
s.TermConfig.OnStderr(o)
|
||||
uint8Array := js.Global().Get("Uint8Array").New(len(o))
|
||||
|
||||
js.CopyBytesToJS(uint8Array, []byte(o))
|
||||
s.TermConfig.OnStderr(uint8Array)
|
||||
}
|
||||
|
||||
// io.Writer "emulator" to pass to the ssh module.
|
||||
type XtermPipe struct {
|
||||
// Function to call when data is written.
|
||||
Send func(data string)
|
||||
Send func(data js.Value)
|
||||
}
|
||||
|
||||
// Write implements the io.Writer interface for XtermPipe.
|
||||
func (x XtermPipe) Write(data []byte) (int, error) {
|
||||
// Tailscale's webSSH does this to fix issues in xterm.js
|
||||
res := bytes.Replace(data, []byte("\n"), []byte("\n\r"), -1)
|
||||
x.Send(string(res))
|
||||
uint8Array := js.Global().Get("Uint8Array").New(len(data))
|
||||
js.CopyBytesToJS(uint8Array, data)
|
||||
x.Send(uint8Array)
|
||||
return len(data), nil
|
||||
}
|
||||
|
||||
@@ -37,7 +37,6 @@
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-unicode11": "^0.8.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/addon-webgl": "^0.18.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"arktype": "^2.1.20",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
Generated
-12
@@ -76,9 +76,6 @@ importers:
|
||||
'@xterm/addon-web-links':
|
||||
specifier: ^0.11.0
|
||||
version: 0.11.0(@xterm/xterm@5.5.0)
|
||||
'@xterm/addon-webgl':
|
||||
specifier: ^0.18.0
|
||||
version: 0.18.0(@xterm/xterm@5.5.0)
|
||||
'@xterm/xterm':
|
||||
specifier: ^5.5.0
|
||||
version: 5.5.0
|
||||
@@ -2263,11 +2260,6 @@ packages:
|
||||
peerDependencies:
|
||||
'@xterm/xterm': ^5.0.0
|
||||
|
||||
'@xterm/addon-webgl@0.18.0':
|
||||
resolution: {integrity: sha512-xCnfMBTI+/HKPdRnSOHaJDRqEpq2Ugy8LEj9GiY4J3zJObo3joylIFaMvzBwbYRg8zLtkO0KQaStCeSfoaI2/w==}
|
||||
peerDependencies:
|
||||
'@xterm/xterm': ^5.0.0
|
||||
|
||||
'@xterm/xterm@5.5.0':
|
||||
resolution: {integrity: sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==}
|
||||
|
||||
@@ -6541,10 +6533,6 @@ snapshots:
|
||||
dependencies:
|
||||
'@xterm/xterm': 5.5.0
|
||||
|
||||
'@xterm/addon-webgl@0.18.0(@xterm/xterm@5.5.0)':
|
||||
dependencies:
|
||||
'@xterm/xterm': 5.5.0
|
||||
|
||||
'@xterm/xterm@5.5.0': {}
|
||||
|
||||
acorn@8.15.0:
|
||||
|
||||
Reference in New Issue
Block a user