tiny-rdm/frontend/src/components/content_value/ContentCli.vue

581 lines
14 KiB
Vue

<script setup>
import { Terminal } from 'xterm'
import { FitAddon } from 'xterm-addon-fit'
import { computed, defineExpose, onMounted, onUnmounted, ref, watch } from 'vue'
import 'xterm/css/xterm.css'
import { EventsEmit, EventsOff, EventsOn } from 'wailsjs/runtime/runtime.js'
import { get, isEmpty, set } from 'lodash'
import { CloseCli, StartCli } from 'wailsjs/go/services/cliService.js'
import usePreferencesStore from 'stores/preferences.js'
import { i18nGlobal } from '@/utils/i18n.js'
const props = defineProps({
name: String,
activated: Boolean,
})
const prefStore = usePreferencesStore()
const termRef = ref(null)
/**
*
* @type {xterm.Terminal|null}
*/
let termInst = null
/**
*
* @type {xterm-addon-fit.FitAddon|null}
*/
let fitAddonInst = null
/**
*
* @return {{fitAddon: xterm-addon-fit.FitAddon, term: Terminal}}
*/
const newTerm = () => {
const { fontSize = 14, fontFamily = 'Courier New' } = prefStore.cliFont
const term = new Terminal({
allowProposedApi: true,
fontFamily,
fontSize,
cursorStyle: prefStore.cli.cursorStyle || 'block',
cursorBlink: true,
disableStdin: false,
screenReaderMode: true,
// LogLevel: 'debug',
theme: {
// foreground: '#ECECEC',
background: '#000000',
// cursor: 'help',
// lineHeight: 20,
},
})
const fitAddon = new FitAddon()
term.open(termRef.value)
term.loadAddon(fitAddon)
term.onData(onTermData)
term.attachCustomKeyEventHandler(onTermKey)
return { term, fitAddon }
}
onMounted(() => {
const { term, fitAddon } = newTerm()
termInst = term
fitAddonInst = fitAddon
window.addEventListener('resize', resizeTerm)
term.writeln('\r\n' + i18nGlobal.t('interface.cli_welcome'))
// term.write('\x1b[4h') // insert mode
CloseCli(props.name)
StartCli(props.name, 0)
EventsOn(`cmd:output:${props.name}`, receiveTermOutput)
fitAddon.fit()
term.focus()
})
onUnmounted(() => {
window.removeEventListener('resize', resizeTerm)
EventsOff(`cmd:output:${props.name}`)
termInst.dispose()
termInst = null
console.warn('destroy term')
})
const resizeTerm = () => {
if (fitAddonInst != null) {
fitAddonInst.fit()
}
}
defineExpose({
resizeTerm,
})
watch(
() => prefStore.cliFont,
({ fontSize = 14, fontFamily = 'Courier New' }) => {
if (termInst != null) {
termInst.options.fontSize = fontSize
termInst.options.fontFamily = fontFamily
}
resizeTerm()
},
)
watch(
() => prefStore.cli.cursorStyle,
(style) => {
if (termInst != null) {
termInst.options.cursorStyle = style || 'block'
}
resizeTerm()
},
)
const prefixContent = computed(() => {
return '\x1b[33m' + promptPrefix.value + '\x1b[0m'
})
const prefixLen = computed(() => {
let len = 0
for (let i = 0; i < promptPrefix.value.length; i++) {
const char = promptPrefix.value.charCodeAt(i)
if (char >= 0x0000 && char <= 0x00ff) {
// single byte ASCII char
len += 1
} else {
// multibyte Unicode char
len += 2
}
}
return len
})
let promptPrefix = ref('')
let inputCursor = 0
const inputHistory = []
let historyIndex = 0
let waitForOutput = false
const onTermData = (data) => {
if (termInst == null) {
return
}
if (data) {
const cc = data.charCodeAt(0)
switch (cc) {
case 127: // backspace
deleteInput(true)
return
case 13: // enter
// try to process local command first
switch (getCurrentInput()) {
case 'clear':
case 'clr':
termInst.clear()
replaceTermInput()
newInputLine()
return
default: // send command to server
flushTermInput()
return
}
case 27:
switch (data.substring(1)) {
case '[A': // arrow up
changeHistory(true)
return
case '[B': // arrow down
changeHistory(false)
return
case '[C': // arrow right ->
moveInputCursor(1)
return
case '[D': // arrow left <-
moveInputCursor(-1)
return
case '[3~': // del
deleteInput(false)
return
}
case 9: // tab
return
}
}
updateInput(data)
// term.write(data)
}
/**
*
* @param e
* @return {boolean}
*/
const onTermKey = (e) => {
if (e.type === 'keydown') {
if (e.ctrlKey) {
switch (e.key) {
case 'a': // move to head of line
moveInputCursorTo(0)
return false
case 'e': // move to tail of line
moveInputCursorTo(Number.MAX_SAFE_INTEGER)
return false
case 'f': // move forward
moveInputCursor(1)
return false
case 'b': // move backward
moveInputCursor(-1)
return false
case 'd': // delete char
deleteInput(false)
return false
case 'h': // back delete
deleteInput(true)
return false
case 'u': // delete all text before cursor
deleteInput2(false)
return false
case 'k': // delete all text after cursor
deleteInput2(true)
return false
case 'w': // delete word before cursor
deleteWord(false)
return false
case 'p': // previous history
changeHistory(true)
return false
case 'n': // next history
changeHistory(false)
return false
case 'l': // clear screen
termInst.clear()
replaceTermInput()
newInputLine()
return false
}
// block all ctrl key combinations input
return false
}
}
return true
}
/**
* move input cursor by step
* @param {number} step above 0 indicate move right; 0 indicate move to last
*/
const moveInputCursor = (step) => {
if (termInst == null) {
return
}
let updateCursor = false
if (step > 0) {
// move right
const currentLine = getCurrentInput()
if (inputCursor + step <= currentLine.length) {
inputCursor += step
updateCursor = true
}
} else if (step < 0) {
// move left
if (inputCursor + step >= 0) {
inputCursor += step
updateCursor = true
}
}
if (updateCursor) {
moveInputCursorTo(inputCursor)
}
}
/**
* move cursor to the end of current line
*/
const moveInputCursorToEnd = () => {
moveInputCursorTo(Number.MAX_SAFE_INTEGER)
}
/**
* move cursor to pos
* @param {number} pos
*/
const moveInputCursorTo = (pos) => {
const currentLine = getCurrentInput()
inputCursor = Math.min(Math.max(0, pos), currentLine.length)
termInst.write(`\x1B[${prefixLen.value + inputCursor + 1}G`)
}
/**
* update current input cache and refresh term
* @param {string} data
*/
const updateInput = (data) => {
if (data == null || data.length <= 0) {
return
}
// replace &nbsp;(Non-Breaking Space) with normal blank space
data = data.replace(/\u00A0/g, ' ')
if (termInst == null) {
return
}
let currentLine = getCurrentInput()
if (inputCursor < currentLine.length) {
// insert
currentLine = currentLine.substring(0, inputCursor) + data + currentLine.substring(inputCursor)
replaceTermInput(currentLine)
moveInputCursor(data.length)
} else {
// append
currentLine += data
termInst.write(data)
inputCursor += data.length
}
updateCurrentInput(currentLine)
}
/**
*
* @param {boolean} back backspace or not
*/
const deleteInput = (back = false) => {
if (termInst == null) {
return
}
let currentLine = getCurrentInput()
if (inputCursor < currentLine.length) {
// delete middle part
if (back) {
currentLine = currentLine.substring(0, inputCursor - 1) + currentLine.substring(inputCursor)
inputCursor -= 1
} else {
currentLine = currentLine.substring(0, inputCursor) + currentLine.substring(inputCursor + 1)
}
} else {
if (back) {
// delete last one
currentLine = currentLine.slice(0, -1)
inputCursor -= 1
}
}
replaceTermInput(currentLine)
updateCurrentInput(currentLine)
moveInputCursorTo(inputCursor)
}
/**
* delete to the end
* @param back
*/
const deleteInput2 = (back = false) => {
if (termInst == null) {
return
}
let currentLine = getCurrentInput()
if (back) {
// delete until tail
currentLine = currentLine.substring(0, inputCursor - 1)
inputCursor = currentLine.length
} else {
// delete until head
currentLine = currentLine.substring(inputCursor)
inputCursor = 0
}
replaceTermInput(currentLine)
updateCurrentInput(currentLine)
moveInputCursorTo(inputCursor)
}
/**
* delete one word
* @param back
*/
const deleteWord = (back = false) => {
if (termInst == null) {
return
}
let currentLine = getCurrentInput()
if (back) {
const prefix = currentLine.substring(0, inputCursor)
let firstNonChar = false
let cursor = inputCursor
while (cursor < currentLine.length) {
const isChar =
(currentLine[cursor] >= 'a' && currentLine[cursor] <= 'z') ||
(currentLine[cursor] >= 'A' && currentLine[cursor] <= 'Z') ||
(currentLine[cursor] >= '0' && currentLine[cursor] <= '9')
if (!firstNonChar || isChar) {
if (!isChar) {
firstNonChar = true
}
cursor++
} else {
break
}
}
currentLine = prefix + currentLine.substring(cursor)
} else {
const suffix = currentLine.substring(inputCursor)
let firstNonChar = false
while (inputCursor >= 0) {
const isChar =
(currentLine[inputCursor] >= 'a' && currentLine[inputCursor] <= 'z') ||
(currentLine[inputCursor] >= 'A' && currentLine[inputCursor] <= 'Z') ||
(currentLine[inputCursor] >= '0' && currentLine[inputCursor] <= '9')
if (!firstNonChar || isChar) {
if (!isChar) {
firstNonChar = true
}
inputCursor--
} else {
break
}
}
currentLine = currentLine.substring(0, inputCursor) + suffix
}
replaceTermInput(currentLine)
updateCurrentInput(currentLine)
moveInputCursorTo(inputCursor)
}
const getCurrentInput = () => {
return get(inputHistory, historyIndex, '')
}
const updateCurrentInput = (input) => {
set(inputHistory, historyIndex, input || '')
}
const newInputLine = () => {
if (historyIndex >= 0 && historyIndex < inputHistory.length - 1) {
// edit prev history, move to last
const pop = inputHistory.splice(historyIndex, 1)
inputHistory[inputHistory.length - 1] = pop[0]
}
if (get(inputHistory, inputHistory.length - 1, '')) {
historyIndex = inputHistory.length
updateCurrentInput('')
}
}
/**
* get prev or next history record
* @param prev
* @return {*|null}
*/
const changeHistory = (prev) => {
let currentLine = null
if (prev) {
if (historyIndex > 0) {
historyIndex -= 1
currentLine = inputHistory[historyIndex]
}
} else {
if (historyIndex < inputHistory.length - 1) {
historyIndex += 1
currentLine = inputHistory[historyIndex]
}
}
if (currentLine != null) {
if (termInst == null) {
return
}
replaceTermInput(currentLine)
moveInputCursorToEnd()
}
return null
}
/**
* flush terminal input and send current prompt to server
* @param {boolean} flushCmd
*/
const flushTermInput = (flushCmd = false) => {
const currentLine = getCurrentInput()
EventsEmit(`cmd:input:${props.name}`, currentLine)
inputCursor = 0
// historyIndex = inputHistory.length
waitForOutput = true
}
/**
* clear current input line and replace with new content
* @param {string|null} [content]
*/
const replaceTermInput = (content = '') => {
if (termInst == null) {
return
}
// erase current line and write new content
termInst.write('\r\x1B[K' + prefixContent.value + (content || ''))
}
/**
* process receive output content
* @param {{content: string[], prompt: string}} data
*/
const receiveTermOutput = (data) => {
if (termInst == null) {
return
}
const { content = [], prompt } = data || {}
if (!isEmpty(content)) {
for (const line of content) {
termInst.write('\r\n' + line)
}
}
if (!isEmpty(prompt)) {
promptPrefix.value = prompt
termInst.write('\r\n' + prefixContent.value)
waitForOutput = false
inputCursor = 0
newInputLine()
}
}
</script>
<template>
<div ref="termRef" class="xterm" />
</template>
<style lang="scss" scoped>
.xterm {
width: 100%;
min-height: 100%;
overflow: hidden;
background-color: #000000;
}
</style>
<style lang="scss">
.xterm-screen {
padding: 0 5px !important;
}
.xterm-viewport::-webkit-scrollbar {
background-color: #000000;
width: 5px;
}
.xterm-viewport::-webkit-scrollbar-thumb {
background: #000000;
}
.xterm-decoration-overview-ruler {
right: 1px;
pointer-events: none;
}
</style>