mirror of
https://github.com/harvester/harvester-ui-extension.git
synced 2026-08-16 04:39:15 +00:00
648 lines
15 KiB
Vue
648 lines
15 KiB
Vue
<script>
|
|
import { allHash } from '@shell/utils/promise';
|
|
import debounce from 'lodash/debounce';
|
|
import Socket, {
|
|
EVENT_CONNECTED,
|
|
EVENT_CONNECTING,
|
|
EVENT_DISCONNECTED,
|
|
EVENT_MESSAGE,
|
|
EVENT_CONNECT_ERROR,
|
|
} from '@shell/utils/socket';
|
|
|
|
export default {
|
|
emits: ['close'],
|
|
|
|
props: {
|
|
value: {
|
|
type: Object,
|
|
required: true,
|
|
},
|
|
},
|
|
|
|
data() {
|
|
return {
|
|
socket: null,
|
|
terminal: null,
|
|
textDecoder: null,
|
|
fitAddon: null,
|
|
searchAddon: null,
|
|
webglAddon: null,
|
|
onResize: null,
|
|
isOpen: false,
|
|
isOpening: false,
|
|
backlog: [],
|
|
firstTime: true,
|
|
queue: [],
|
|
isDraining: false,
|
|
drainScheduled: false,
|
|
drainTimer: null,
|
|
showSearch: false,
|
|
searchQuery: '',
|
|
searchOptions: {
|
|
caseSensitive: false,
|
|
regex: false,
|
|
wholeWord: false,
|
|
incremental: true,
|
|
decorations: {
|
|
matchBackground: '#B45309',
|
|
matchBorder: '#FDBA74',
|
|
matchOverviewRuler: '#FDBA74',
|
|
activeMatchBackground: '#EA580C',
|
|
activeMatchBorder: '#FED7AA',
|
|
activeMatchColorOverviewRuler: '#FED7AA',
|
|
},
|
|
},
|
|
};
|
|
},
|
|
|
|
computed: {
|
|
serialConsoleUrl() {
|
|
return this.value?.getSerialConsolePath || '';
|
|
},
|
|
|
|
xtermConfig() {
|
|
return {
|
|
allowProposedApi: true,
|
|
cursorBlink: true,
|
|
cursorStyle: 'bar',
|
|
cursorWidth: 2,
|
|
scrollback: 5000,
|
|
useStyle: true,
|
|
fontFamily: 'JetBrains Mono, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
|
|
fontSize: 16,
|
|
lineHeight: 1.3,
|
|
};
|
|
},
|
|
},
|
|
|
|
beforeUnmount() {
|
|
this.close();
|
|
},
|
|
|
|
watch: {
|
|
serialConsoleUrl() {
|
|
if (this.terminal) {
|
|
this.connect();
|
|
}
|
|
},
|
|
},
|
|
|
|
async mounted() {
|
|
this.textDecoder = new TextDecoder('utf-8');
|
|
await this.setupTerminal();
|
|
await this.connect();
|
|
this.onResize = debounce(() => this.fit(), 100);
|
|
window.addEventListener('resize', this.onResize);
|
|
this.scheduleFit();
|
|
},
|
|
|
|
methods: {
|
|
async setupTerminal() {
|
|
const docStyle = getComputedStyle(document.querySelector('body'));
|
|
const xterm = await import(/* webpackChunkName: "xterm" */ '@xterm/xterm');
|
|
|
|
const addons = await allHash({
|
|
fit: import(/* webpackChunkName: "xterm" */ '@xterm/addon-fit'),
|
|
webgl: import(/* webpackChunkName: "xterm" */ '@xterm/addon-webgl'),
|
|
weblinks: import(/* webpackChunkName: "xterm" */ '@xterm/addon-web-links'),
|
|
search: import(/* webpackChunkName: "xterm" */ '@xterm/addon-search'),
|
|
canvas: import(/* webpackChunkName: "xterm" */ '@xterm/addon-canvas'),
|
|
});
|
|
|
|
const terminal = new xterm.Terminal({
|
|
theme: {
|
|
background: docStyle.getPropertyValue('--terminal-bg').trim(),
|
|
cursor: docStyle.getPropertyValue('--terminal-text').trim(),
|
|
foreground: docStyle.getPropertyValue('--terminal-text').trim()
|
|
},
|
|
...this.xtermConfig,
|
|
});
|
|
|
|
this.fitAddon = new addons.fit.FitAddon();
|
|
this.searchAddon = new addons.search.SearchAddon();
|
|
terminal.loadAddon(this.fitAddon);
|
|
terminal.loadAddon(this.searchAddon);
|
|
terminal.loadAddon(new addons.weblinks.WebLinksAddon());
|
|
terminal.open(this.$refs.xterm);
|
|
|
|
// The webgl renderer can fail without throwing: the context may be lost
|
|
// mid-session, or it can attach but silently never paint, leaving the
|
|
// terminal's helper textarea at 0x0. Detect both cases and fall back to the
|
|
// canvas renderer so the terminal stays interactive.
|
|
try {
|
|
this.webglAddon = new addons.webgl.WebglAddon();
|
|
|
|
this.webglAddon.onContextLoss(() => {
|
|
if (!this.isUnmounted && this.terminal) {
|
|
this.fallbackToCanvas(terminal, addons);
|
|
}
|
|
});
|
|
|
|
terminal.loadAddon(this.webglAddon);
|
|
|
|
// Detect the silent "attaches but never paints" failure after a render frame.
|
|
requestAnimationFrame(() => {
|
|
if (this.isUnmounted || !this.terminal) {
|
|
return;
|
|
}
|
|
|
|
const textarea = this.$refs.xterm?.querySelector('.xterm-helper-textarea');
|
|
const hasPainted = textarea && textarea.getBoundingClientRect().height > 0;
|
|
|
|
if (this.webglAddon && !hasPainted) {
|
|
this.fallbackToCanvas(terminal, addons);
|
|
this.fit();
|
|
}
|
|
});
|
|
} catch (e) {
|
|
// Some browsers (e.g. Safari) don't support the webgl renderer, so don't use it.
|
|
this.fallbackToCanvas(terminal, addons);
|
|
}
|
|
|
|
this.fit();
|
|
this.flush();
|
|
this.scheduleDrainQueue();
|
|
|
|
terminal.onData((input) => {
|
|
const msg = this.str2ab(input);
|
|
|
|
this.write(msg);
|
|
});
|
|
|
|
terminal.attachCustomKeyEventHandler((event) => {
|
|
const key = event.key?.toLowerCase();
|
|
|
|
if ((event.ctrlKey || event.metaKey) && key === 'f') {
|
|
event.preventDefault();
|
|
this.openSearch();
|
|
|
|
return false;
|
|
}
|
|
|
|
if (key === 'escape' && this.showSearch) {
|
|
event.preventDefault();
|
|
this.closeSearch();
|
|
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
});
|
|
|
|
this.terminal = terminal;
|
|
},
|
|
|
|
openSearch() {
|
|
this.showSearch = true;
|
|
|
|
this.$nextTick(() => {
|
|
const input = this.$refs.searchInput;
|
|
|
|
if (input) {
|
|
input.focus();
|
|
input.select();
|
|
}
|
|
});
|
|
},
|
|
|
|
closeSearch() {
|
|
this.showSearch = false;
|
|
this.searchQuery = '';
|
|
this.clearSearchHighlights();
|
|
this.terminal?.focus();
|
|
},
|
|
|
|
clearSearchHighlights() {
|
|
this.terminal?.clearSelection();
|
|
this.searchAddon?.clearActiveDecoration();
|
|
this.searchAddon?.clearDecorations();
|
|
},
|
|
|
|
findNext() {
|
|
if (!this.searchAddon || !this.searchQuery) {
|
|
return;
|
|
}
|
|
|
|
this.searchAddon.findNext(this.searchQuery, this.searchOptions);
|
|
},
|
|
|
|
findPrevious() {
|
|
if (!this.searchAddon || !this.searchQuery) {
|
|
return;
|
|
}
|
|
|
|
this.searchAddon.findPrevious(this.searchQuery, this.searchOptions);
|
|
},
|
|
|
|
onSearchInput() {
|
|
if (!this.searchQuery) {
|
|
this.clearSearchHighlights();
|
|
|
|
return;
|
|
}
|
|
|
|
this.findNext();
|
|
},
|
|
|
|
onSearchKeydown(event) {
|
|
if (event.key === 'Enter') {
|
|
event.preventDefault();
|
|
|
|
if (event.shiftKey) {
|
|
this.findPrevious();
|
|
} else {
|
|
this.findNext();
|
|
}
|
|
}
|
|
|
|
if (event.key === 'Escape') {
|
|
event.preventDefault();
|
|
this.closeSearch();
|
|
}
|
|
},
|
|
|
|
scheduleFit() {
|
|
this.$nextTick(() => {
|
|
requestAnimationFrame(() => {
|
|
this.fit();
|
|
|
|
// Re-fit after layout settles to avoid undersized rows on first paint.
|
|
setTimeout(() => this.fit(), 120);
|
|
});
|
|
});
|
|
},
|
|
|
|
// Dispose the webgl renderer (if any) and switch to the canvas renderer.
|
|
// Used when webgl fails to construct, loses its context, or silently never paints.
|
|
fallbackToCanvas(terminal, addons) {
|
|
if (this.isUnmounted) {
|
|
return;
|
|
}
|
|
|
|
this.webglAddon?.dispose();
|
|
this.webglAddon = null;
|
|
|
|
if (!this.canvasAddon) {
|
|
this.canvasAddon = new addons.canvas.CanvasAddon();
|
|
terminal.loadAddon(this.canvasAddon);
|
|
}
|
|
this.fit();
|
|
},
|
|
|
|
str2ab(str) {
|
|
const enc = new TextEncoder();
|
|
|
|
return enc.encode(str);
|
|
},
|
|
|
|
write(msg) {
|
|
if ( this.isOpen ) {
|
|
this.socket.send(msg);
|
|
} else {
|
|
this.backlog.push(msg);
|
|
}
|
|
},
|
|
|
|
enqueueMessage(messagePromise) {
|
|
this.queue.push(messagePromise);
|
|
this.scheduleDrainQueue();
|
|
},
|
|
|
|
scheduleDrainQueue() {
|
|
if (this.drainScheduled || this.isDraining || !this.terminal) {
|
|
return;
|
|
}
|
|
|
|
this.drainScheduled = true;
|
|
|
|
const shouldDrainImmediately = this.queue.length <= 3;
|
|
|
|
const runDrain = () => {
|
|
this.drainScheduled = false;
|
|
this.drainTimer = null;
|
|
this.drainQueue();
|
|
};
|
|
|
|
if (shouldDrainImmediately) {
|
|
runDrain();
|
|
|
|
return;
|
|
}
|
|
|
|
this.drainTimer = requestAnimationFrame(runDrain);
|
|
},
|
|
|
|
async decodeMessageData(data) {
|
|
if (typeof data === 'string') {
|
|
return data;
|
|
}
|
|
|
|
if (!this.textDecoder) {
|
|
this.textDecoder = new TextDecoder('utf-8');
|
|
}
|
|
|
|
if (data instanceof Blob) {
|
|
const buffer = await data.arrayBuffer();
|
|
|
|
return this.textDecoder.decode(new Uint8Array(buffer), { stream: true });
|
|
}
|
|
|
|
if (data instanceof ArrayBuffer) {
|
|
return this.textDecoder.decode(new Uint8Array(data), { stream: true });
|
|
}
|
|
|
|
if (ArrayBuffer.isView(data)) {
|
|
const view = data;
|
|
const bytes = new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
|
|
|
|
return this.textDecoder.decode(bytes, { stream: true });
|
|
}
|
|
|
|
return String(data || '');
|
|
},
|
|
|
|
async drainQueue() {
|
|
if (this.isDraining || !this.terminal) {
|
|
return;
|
|
}
|
|
|
|
this.isDraining = true;
|
|
|
|
try {
|
|
while (this.queue.length > 0) {
|
|
const pendingMessages = this.queue.splice(0, this.queue.length);
|
|
const settledMessages = await Promise.allSettled(pendingMessages);
|
|
const messages = settledMessages
|
|
.filter((result) => result.status === 'fulfilled')
|
|
.map((result) => result.value);
|
|
const output = messages.filter(Boolean).join('');
|
|
|
|
if (output) {
|
|
await this.writeOutputInChunks(output);
|
|
}
|
|
|
|
if (this.queue.length > 0) {
|
|
await this.nextFrame();
|
|
}
|
|
}
|
|
} finally {
|
|
this.isDraining = false;
|
|
|
|
// If data arrived between the last while-check and releasing the lock,
|
|
// immediately schedule another drain so output never gets stuck.
|
|
if (this.queue.length > 0 && this.terminal) {
|
|
this.scheduleDrainQueue();
|
|
}
|
|
}
|
|
},
|
|
|
|
async writeOutputInChunks(output) {
|
|
if (!output) {
|
|
return;
|
|
}
|
|
|
|
const chunkSize = output.length > 20000 ? 16000 : output.length > 8000 ? 8000 : output.length;
|
|
|
|
if (chunkSize === output.length) {
|
|
this.terminal.write(output);
|
|
|
|
return;
|
|
}
|
|
|
|
for (let index = 0; index < output.length; index += chunkSize) {
|
|
this.terminal.write(output.slice(index, index + chunkSize));
|
|
|
|
if (index + chunkSize < output.length) {
|
|
await this.nextFrame();
|
|
}
|
|
}
|
|
},
|
|
|
|
nextFrame() {
|
|
return new Promise((resolve) => requestAnimationFrame(resolve));
|
|
},
|
|
|
|
clear() {
|
|
this.terminal.clear();
|
|
},
|
|
|
|
getSocketUrl() {
|
|
return this.serialConsoleUrl;
|
|
},
|
|
|
|
async connect() {
|
|
if ( this.socket ) {
|
|
await this.socket.disconnect();
|
|
this.socket = null;
|
|
this.terminal.reset();
|
|
}
|
|
|
|
const url = this.getSocketUrl();
|
|
|
|
if ( !url ) {
|
|
return;
|
|
}
|
|
|
|
this.socket = new Socket(url);
|
|
|
|
this.socket.addEventListener(EVENT_CONNECTING, (e) => {
|
|
this.isOpen = false;
|
|
this.isOpening = true;
|
|
});
|
|
|
|
this.socket.addEventListener(EVENT_CONNECT_ERROR, (e) => {
|
|
this.isOpen = false;
|
|
this.isOpening = false;
|
|
console.error('Connect Error', e); // eslint-disable-line no-console
|
|
});
|
|
|
|
this.socket.addEventListener(EVENT_CONNECTED, (e) => {
|
|
this.isOpen = true;
|
|
this.isOpening = false;
|
|
if (this.show) {
|
|
this.fit();
|
|
this.flush();
|
|
}
|
|
this.scheduleFit();
|
|
|
|
if (this.firstTime) {
|
|
this.socket.send(this.str2ab('\n'));
|
|
this.firstTime = false;
|
|
}
|
|
});
|
|
|
|
this.socket.addEventListener(EVENT_DISCONNECTED, (e) => {
|
|
this.isOpen = false;
|
|
this.isOpening = false;
|
|
this.$emit('close');
|
|
});
|
|
|
|
this.socket.addEventListener(EVENT_MESSAGE, (e) => {
|
|
this.enqueueMessage(this.decodeMessageData(e.detail.data));
|
|
});
|
|
|
|
this.socket.connect();
|
|
this.terminal.focus();
|
|
},
|
|
|
|
flush() {
|
|
const backlog = this.backlog.slice();
|
|
|
|
this.backlog = [];
|
|
|
|
for ( const data of backlog ) {
|
|
this.socket.send(data);
|
|
}
|
|
},
|
|
|
|
fit(arg) {
|
|
if ( !this.fitAddon ) {
|
|
return;
|
|
}
|
|
|
|
this.fitAddon.fit();
|
|
},
|
|
|
|
close() {
|
|
if (this.onResize) {
|
|
window.removeEventListener('resize', this.onResize);
|
|
this.onResize = null;
|
|
}
|
|
|
|
if (this.drainTimer) {
|
|
cancelAnimationFrame(this.drainTimer);
|
|
this.drainTimer = null;
|
|
}
|
|
|
|
if ( this.socket ) {
|
|
this.socket.disconnect();
|
|
}
|
|
|
|
if ( this.terminal ) {
|
|
this.terminal.dispose();
|
|
}
|
|
},
|
|
}
|
|
};
|
|
</script>
|
|
|
|
<template>
|
|
<div class="harvester-shell-container">
|
|
<div
|
|
v-if="showSearch"
|
|
class="shell-search"
|
|
>
|
|
<input
|
|
ref="searchInput"
|
|
v-model="searchQuery"
|
|
type="text"
|
|
class="shell-search-input"
|
|
placeholder="Search in terminal"
|
|
@input="onSearchInput"
|
|
@keydown="onSearchKeydown"
|
|
>
|
|
<button
|
|
class="btn role-secondary btn-sm shell-search-btn"
|
|
type="button"
|
|
title="Shift+Enter"
|
|
@click="findPrevious"
|
|
>
|
|
Prev
|
|
</button>
|
|
<button
|
|
class="btn role-secondary btn-sm shell-search-btn"
|
|
type="button"
|
|
title="Enter"
|
|
@click="findNext"
|
|
>
|
|
Next
|
|
</button>
|
|
<button
|
|
class="btn role-secondary btn-sm shell-search-btn"
|
|
type="button"
|
|
title="Esc"
|
|
@click="closeSearch"
|
|
>
|
|
Close
|
|
</button>
|
|
</div>
|
|
<div
|
|
ref="xterm"
|
|
class="shell-body"
|
|
/>
|
|
<resize-observer @notify="fit" />
|
|
</div>
|
|
</template>
|
|
|
|
<style lang="scss">
|
|
@import '../../../../node_modules/@xterm/xterm/css/xterm.css';
|
|
|
|
body, #__nuxt, #__layout, MAIN {
|
|
height: 100%;
|
|
}
|
|
|
|
.harvester-shell-container {
|
|
display: flex;
|
|
flex-direction: column;
|
|
position: relative;
|
|
height: 100%;
|
|
min-height: 0;
|
|
overflow: hidden;
|
|
|
|
.resize-observer {
|
|
display: none;
|
|
}
|
|
|
|
.shell-search {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
justify-content: flex-end;
|
|
position: absolute;
|
|
top: 8px;
|
|
right: 10px;
|
|
z-index: 5;
|
|
padding: 8px;
|
|
border-radius: 6px;
|
|
border: 1px solid var(--border);
|
|
background: color-mix(in srgb, var(--body-bg) 88%, transparent);
|
|
}
|
|
|
|
.shell-search-input {
|
|
min-width: 220px;
|
|
max-width: 420px;
|
|
flex: 0 1 320px;
|
|
border: 1px solid var(--border);
|
|
border-radius: 4px;
|
|
background: var(--body-bg);
|
|
color: var(--body-text);
|
|
padding: 6px 8px;
|
|
}
|
|
|
|
.shell-search-btn {
|
|
min-width: 64px;
|
|
}
|
|
|
|
.shell-body {
|
|
flex: 1 1 auto;
|
|
height: 100%;
|
|
width: 100%;
|
|
min-height: 0;
|
|
padding: 0 10px;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
.terminal.xterm,
|
|
.terminal.xterm .xterm-viewport,
|
|
.terminal.xterm .xterm-screen,
|
|
.terminal.xterm .xterm-scrollable-element {
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
|
|
.terminal.xterm .xterm-scrollable-element {
|
|
min-height: 100%;
|
|
}
|
|
}
|
|
</style>
|