feat: console component (#5685)
This commit is contained in:
101
packages/ui/src/components/base/BaseTerminal.vue
Normal file
101
packages/ui/src/components/base/BaseTerminal.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex size-full flex-col bg-surface-2 overflow-hidden rounded-[20px] border border-solid border-surface-4"
|
||||
>
|
||||
<div class="relative min-h-0 pb-1 flex-1 overflow-hidden">
|
||||
<div ref="containerRef" class="size-full pl-2" />
|
||||
<div v-if="!isAtBottom" class="absolute bottom-4 right-4">
|
||||
<ButtonStyled circular type="highlight">
|
||||
<button class="!shadow-none" aria-label="Scroll to bottom" @click="scrollToBottom">
|
||||
<ChevronDownIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="showInput"
|
||||
class="border-t border-solid border-b-0 border-x-0 border-surface-5 bg-surface-3 p-4"
|
||||
>
|
||||
<StyledInput
|
||||
v-model="commandInput"
|
||||
:icon="TerminalSquareIcon"
|
||||
placeholder="Send a command"
|
||||
wrapper-class="w-full"
|
||||
input-class="!h-10"
|
||||
@keydown.enter="submitCommand"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ChevronDownIcon, TerminalSquareIcon } from '@modrinth/assets'
|
||||
import type { Terminal } from '@xterm/xterm'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import StyledInput from '#ui/components/base/StyledInput.vue'
|
||||
import { useTerminal } from '#ui/composables/terminal'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
scrollback?: number
|
||||
showInput?: boolean
|
||||
}>(),
|
||||
{
|
||||
scrollback: 10000,
|
||||
showInput: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
command: [command: string]
|
||||
ready: [terminal: Terminal]
|
||||
}>()
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
const commandInput = ref('')
|
||||
|
||||
const { terminal, searchAddon, isAtBottom, write, writeln, clear, reset, fit, scrollToBottom } =
|
||||
useTerminal({
|
||||
container: containerRef,
|
||||
scrollback: props.scrollback,
|
||||
onReady: (term) => emit('ready', term),
|
||||
})
|
||||
|
||||
const submitCommand = () => {
|
||||
const cmd = commandInput.value.trim()
|
||||
if (!cmd) return
|
||||
emit('command', cmd)
|
||||
commandInput.value = ''
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
write,
|
||||
writeln,
|
||||
clear,
|
||||
reset,
|
||||
fit,
|
||||
scrollToBottom,
|
||||
terminal,
|
||||
searchAddon,
|
||||
isAtBottom,
|
||||
commandInput,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.xterm {
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.xterm .xterm-scrollable-element {
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen {
|
||||
min-height: 100% !important;
|
||||
margin-left: auto !important;
|
||||
margin-right: auto !important;
|
||||
}
|
||||
</style>
|
||||
@@ -5,6 +5,7 @@ export { default as AutoBrandIcon } from './AutoBrandIcon.vue'
|
||||
export { default as AutoLink } from './AutoLink.vue'
|
||||
export { default as Avatar } from './Avatar.vue'
|
||||
export { default as Badge } from './Badge.vue'
|
||||
export { default as BaseTerminal } from './BaseTerminal.vue'
|
||||
export { default as BigOptionButton } from './BigOptionButton.vue'
|
||||
export { default as BulletDivider } from './BulletDivider.vue'
|
||||
export { default as Button } from './Button.vue'
|
||||
|
||||
@@ -9,4 +9,5 @@ export * from './i18n-debug'
|
||||
export * from './page-leave-safety'
|
||||
export * from './scroll-indicator'
|
||||
export * from './sticky-observer'
|
||||
export * from './terminal'
|
||||
export * from './virtual-scroll'
|
||||
|
||||
252
packages/ui/src/composables/terminal.ts
Normal file
252
packages/ui/src/composables/terminal.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import type { FitAddon } from '@xterm/addon-fit'
|
||||
import type { SearchAddon } from '@xterm/addon-search'
|
||||
import type { ITerminalOptions, Terminal } from '@xterm/xterm'
|
||||
import {
|
||||
nextTick,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
type Ref,
|
||||
ref,
|
||||
type ShallowRef,
|
||||
shallowRef,
|
||||
} from 'vue'
|
||||
|
||||
function getCssVar(name: string, fallback: string): string {
|
||||
if (typeof document === 'undefined') return fallback
|
||||
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim()
|
||||
return value || fallback
|
||||
}
|
||||
|
||||
function buildTerminalTheme() {
|
||||
const surface2 = getCssVar('--surface-2', '#1d1f23')
|
||||
const surface5 = getCssVar('--surface-5', '#42444a')
|
||||
const textDefault = getCssVar('--color-text-default', '#b0bac5')
|
||||
const textTertiary = getCssVar('--color-text-tertiary', '#96a2b0')
|
||||
const textPrimary = getCssVar('--color-text-primary', '#ffffff')
|
||||
const red = getCssVar('--color-red', '#ff496e')
|
||||
const orange = getCssVar('--color-orange', '#ffa347')
|
||||
const green = getCssVar('--color-green', '#1bd96a')
|
||||
const blue = getCssVar('--color-blue', '#4a9eff')
|
||||
const purple = getCssVar('--color-purple', '#bc3fbc')
|
||||
|
||||
return {
|
||||
background: surface2,
|
||||
foreground: textDefault,
|
||||
cursor: textDefault,
|
||||
cursorAccent: surface2,
|
||||
selectionBackground: 'rgba(128, 128, 128, 0.3)',
|
||||
black: surface2,
|
||||
red,
|
||||
green,
|
||||
yellow: orange,
|
||||
blue,
|
||||
magenta: purple,
|
||||
cyan: textTertiary,
|
||||
white: textDefault,
|
||||
brightBlack: surface5,
|
||||
brightRed: red,
|
||||
brightGreen: green,
|
||||
brightYellow: orange,
|
||||
brightBlue: blue,
|
||||
brightMagenta: purple,
|
||||
brightCyan: textTertiary,
|
||||
brightWhite: textPrimary,
|
||||
scrollbarSliderBackground: surface5,
|
||||
scrollbarSliderHoverBackground: surface5,
|
||||
scrollbarSliderActiveBackground: surface5,
|
||||
}
|
||||
}
|
||||
|
||||
export interface UseTerminalOptions {
|
||||
container: Ref<HTMLElement | null>
|
||||
options?: ITerminalOptions
|
||||
scrollback?: number
|
||||
onReady?: (terminal: Terminal) => void
|
||||
}
|
||||
|
||||
export interface UseTerminalReturn {
|
||||
terminal: ShallowRef<Terminal | null>
|
||||
fitAddon: ShallowRef<FitAddon | null>
|
||||
searchAddon: ShallowRef<SearchAddon | null>
|
||||
isAtBottom: Ref<boolean>
|
||||
write: (data: string) => void
|
||||
writeln: (data: string) => void
|
||||
clear: () => void
|
||||
reset: () => void
|
||||
fit: () => void
|
||||
scrollToBottom: () => void
|
||||
}
|
||||
|
||||
export function useTerminal(options: UseTerminalOptions): UseTerminalReturn {
|
||||
const terminal = shallowRef<Terminal | null>(null)
|
||||
const fitAddon = shallowRef<FitAddon | null>(null)
|
||||
const searchAddon = shallowRef<SearchAddon | null>(null)
|
||||
const isAtBottom = ref(true)
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
let themeObserver: MutationObserver | null = null
|
||||
let hasWritten = false
|
||||
const pendingWrites: Array<{ data: string; newline: boolean }> = []
|
||||
|
||||
const write = (data: string) => {
|
||||
if (terminal.value) {
|
||||
terminal.value.write(data)
|
||||
hasWritten = true
|
||||
} else {
|
||||
pendingWrites.push({ data, newline: false })
|
||||
}
|
||||
}
|
||||
|
||||
const writeln = (data: string) => {
|
||||
if (terminal.value) {
|
||||
if (hasWritten) {
|
||||
terminal.value.write('\r\n' + data)
|
||||
} else {
|
||||
terminal.value.write(data)
|
||||
hasWritten = true
|
||||
}
|
||||
} else {
|
||||
pendingWrites.push({ data, newline: true })
|
||||
}
|
||||
}
|
||||
|
||||
const clear = () => {
|
||||
terminal.value?.clear()
|
||||
hasWritten = false
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
terminal.value?.reset()
|
||||
hasWritten = false
|
||||
}
|
||||
|
||||
const fit = () => {
|
||||
const fa = fitAddon.value
|
||||
const term = terminal.value
|
||||
if (!fa || !term) return
|
||||
const dims = fa.proposeDimensions()
|
||||
if (dims) {
|
||||
term.resize(dims.cols, dims.rows + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const scrollToBottom = () => {
|
||||
terminal.value?.scrollToBottom()
|
||||
isAtBottom.value = true
|
||||
|
||||
// dont even ask, shit is broken as hell
|
||||
// scrollToBottom is unreliable so we have to spam it to make sure it actually goes to the bottom
|
||||
let calls = 0
|
||||
const interval = setInterval(() => {
|
||||
terminal.value?.scrollToBottom()
|
||||
if (++calls >= 10) clearInterval(interval)
|
||||
}, 25)
|
||||
}
|
||||
|
||||
const checkIfAtBottom = () => {
|
||||
const term = terminal.value
|
||||
if (!term) return
|
||||
const buffer = term.buffer.active
|
||||
isAtBottom.value = buffer.baseY - buffer.viewportY <= 2
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const container = options.container.value
|
||||
if (!container) return
|
||||
|
||||
const [{ Terminal }, { FitAddon }, { SearchAddon }] = await Promise.all([
|
||||
import('@xterm/xterm'),
|
||||
import('@xterm/addon-fit'),
|
||||
import('@xterm/addon-search'),
|
||||
])
|
||||
|
||||
await import('@xterm/xterm/css/xterm.css')
|
||||
|
||||
const term = new Terminal({
|
||||
disableStdin: true,
|
||||
scrollback: options.scrollback ?? 10000,
|
||||
convertEol: true,
|
||||
smoothScrollDuration: 125,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 14,
|
||||
lineHeight: 1.5,
|
||||
theme: buildTerminalTheme(),
|
||||
...options.options,
|
||||
})
|
||||
|
||||
const fit = new FitAddon()
|
||||
const search = new SearchAddon()
|
||||
|
||||
term.loadAddon(fit)
|
||||
term.loadAddon(search)
|
||||
term.open(container)
|
||||
await nextTick()
|
||||
const dims = fit.proposeDimensions()
|
||||
if (dims) {
|
||||
term.resize(dims.cols, dims.rows + 1)
|
||||
}
|
||||
|
||||
term.options.disableStdin = true
|
||||
term.write('\x1b[?25l')
|
||||
|
||||
term.onScroll(() => checkIfAtBottom())
|
||||
term.onWriteParsed(() => {
|
||||
if (isAtBottom.value) {
|
||||
term.scrollToBottom()
|
||||
}
|
||||
})
|
||||
|
||||
terminal.value = term
|
||||
fitAddon.value = fit
|
||||
searchAddon.value = search
|
||||
|
||||
for (const pending of pendingWrites) {
|
||||
if (pending.newline) {
|
||||
writeln(pending.data)
|
||||
} else {
|
||||
write(pending.data)
|
||||
}
|
||||
}
|
||||
pendingWrites.length = 0
|
||||
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
const d = fit.proposeDimensions()
|
||||
if (d) {
|
||||
term.resize(d.cols, d.rows + 1)
|
||||
}
|
||||
})
|
||||
resizeObserver.observe(container)
|
||||
|
||||
themeObserver = new MutationObserver(() => {
|
||||
term.options.theme = buildTerminalTheme()
|
||||
})
|
||||
themeObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['data-theme', 'class'],
|
||||
})
|
||||
|
||||
options.onReady?.(term)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
resizeObserver?.disconnect()
|
||||
resizeObserver = null
|
||||
themeObserver?.disconnect()
|
||||
themeObserver = null
|
||||
terminal.value?.dispose()
|
||||
terminal.value = null
|
||||
})
|
||||
|
||||
return {
|
||||
terminal,
|
||||
fitAddon,
|
||||
searchAddon,
|
||||
isAtBottom,
|
||||
write,
|
||||
writeln,
|
||||
clear,
|
||||
reset,
|
||||
fit,
|
||||
scrollToBottom,
|
||||
}
|
||||
}
|
||||
135
packages/ui/src/stories/base/BaseTerminal.stories.ts
Normal file
135
packages/ui/src/stories/base/BaseTerminal.stories.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import BaseTerminal from '../../components/base/BaseTerminal.vue'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/BaseTerminal',
|
||||
component: BaseTerminal,
|
||||
} satisfies Meta<typeof BaseTerminal>
|
||||
|
||||
export default meta
|
||||
|
||||
export const Default: StoryObj = {
|
||||
render: () => ({
|
||||
components: { BaseTerminal },
|
||||
setup() {
|
||||
const termRef = ref<InstanceType<typeof BaseTerminal> | null>(null)
|
||||
|
||||
onMounted(() => {
|
||||
const t = termRef.value
|
||||
if (!t) return
|
||||
t.writeln('\x1b[1;32m=== Modrinth Server Console ===\x1b[0m')
|
||||
t.writeln('')
|
||||
t.writeln('\x1b[36m[10:15:30]\x1b[0m \x1b[32m[Server/INFO]\x1b[0m: Loading properties')
|
||||
t.writeln(
|
||||
'\x1b[36m[10:15:30]\x1b[0m \x1b[32m[Server/INFO]\x1b[0m: Default game type: SURVIVAL',
|
||||
)
|
||||
t.writeln(
|
||||
'\x1b[36m[10:15:31]\x1b[0m \x1b[32m[Server/INFO]\x1b[0m: Starting Minecraft server on *:25565',
|
||||
)
|
||||
t.writeln('\x1b[36m[10:15:32]\x1b[0m \x1b[32m[Server/INFO]\x1b[0m: Preparing level "world"')
|
||||
t.writeln(
|
||||
"\x1b[36m[10:15:33]\x1b[0m \x1b[33m[Server/WARN]\x1b[0m: Can't keep up! Is the server overloaded?",
|
||||
)
|
||||
t.writeln(
|
||||
'\x1b[36m[10:15:34]\x1b[0m \x1b[31m[Server/ERROR]\x1b[0m: Connection reset by peer',
|
||||
)
|
||||
t.writeln(
|
||||
'\x1b[36m[10:15:35]\x1b[0m \x1b[32m[Server/INFO]\x1b[0m: Done (4.523s)! For help, type "help"',
|
||||
)
|
||||
})
|
||||
|
||||
return { termRef }
|
||||
},
|
||||
template: /*html*/ `
|
||||
<div style="width: 100%; height: 95vh;">
|
||||
<BaseTerminal ref="termRef" />
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
|
||||
const SAMPLE_LINES = [
|
||||
'\x1b[36m[{time}]\x1b[0m \x1b[32m[Server/INFO]\x1b[0m: Player Steve joined the game',
|
||||
'\x1b[36m[{time}]\x1b[0m \x1b[32m[Server/INFO]\x1b[0m: Steve has made the advancement [Getting an Upgrade]',
|
||||
"\x1b[36m[{time}]\x1b[0m \x1b[33m[Server/WARN]\x1b[0m: Can't keep up! Is the server overloaded? Running 2501ms behind",
|
||||
'\x1b[36m[{time}]\x1b[0m \x1b[32m[Server/INFO]\x1b[0m: Preparing spawn area: 84%',
|
||||
'\x1b[36m[{time}]\x1b[0m \x1b[31m[Server/ERROR]\x1b[0m: java.net.ConnectException: Connection timed out',
|
||||
'\x1b[36m[{time}]\x1b[0m \x1b[32m[Server/INFO]\x1b[0m: [Fabric] Loading 127 mods',
|
||||
'\x1b[36m[{time}]\x1b[0m \x1b[32m[Server/INFO]\x1b[0m: Environment: authlib=6.0.54, java=21.0.3',
|
||||
'\x1b[36m[{time}]\x1b[0m \x1b[33m[Server/WARN]\x1b[0m: Ambiguity between arguments at position 1',
|
||||
'\x1b[36m[{time}]\x1b[0m \x1b[32m[Server/INFO]\x1b[0m: Player Alex left the game',
|
||||
'\x1b[36m[{time}]\x1b[0m \x1b[31m[Server/ERROR]\x1b[0m: Chunk file at [-3, 12] is missing level data, skipping',
|
||||
'\x1b[36m[{time}]\x1b[0m \x1b[32m[Server/INFO]\x1b[0m: ThreadedAnvilChunkStorage: All chunks are saved',
|
||||
'\x1b[36m[{time}]\x1b[0m \x1b[34m[Server/DEBUG]\x1b[0m: Reloading ResourceManager: Default, fabric',
|
||||
'\x1b[36m[{time}]\x1b[0m \x1b[32m[Server/INFO]\x1b[0m: <Steve> Hello everyone!',
|
||||
'\x1b[36m[{time}]\x1b[0m \x1b[32m[Server/INFO]\x1b[0m: Saving the game (this may take a moment!)',
|
||||
'\x1b[36m[{time}]\x1b[0m \x1b[32m[Server/INFO]\x1b[0m: Saved the game',
|
||||
]
|
||||
|
||||
function getTimeString(): string {
|
||||
const now = new Date()
|
||||
return `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export const StreamingLogs: StoryObj = {
|
||||
render: () => ({
|
||||
components: { BaseTerminal },
|
||||
setup() {
|
||||
const termRef = ref<InstanceType<typeof BaseTerminal> | null>(null)
|
||||
let interval: ReturnType<typeof setInterval> | null = null
|
||||
let index = 0
|
||||
|
||||
onMounted(() => {
|
||||
termRef.value?.writeln('\x1b[1;32m=== Modrinth Server Console ===\x1b[0m')
|
||||
termRef.value?.writeln('')
|
||||
|
||||
interval = setInterval(() => {
|
||||
const line = SAMPLE_LINES[index % SAMPLE_LINES.length].replace('{time}', getTimeString())
|
||||
termRef.value?.writeln(line)
|
||||
index++
|
||||
}, 25)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (interval) clearInterval(interval)
|
||||
})
|
||||
|
||||
return { termRef }
|
||||
},
|
||||
template: /*html*/ `
|
||||
<div style="width: 100%; height: 95vh;">
|
||||
<BaseTerminal ref="termRef" />
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
|
||||
export const WithInput: StoryObj = {
|
||||
render: () => ({
|
||||
components: { BaseTerminal },
|
||||
setup() {
|
||||
const termRef = ref<InstanceType<typeof BaseTerminal> | null>(null)
|
||||
|
||||
const onCommand = (cmd: string) => {
|
||||
termRef.value?.writeln(`\x1b[32m> ${cmd}\x1b[0m`)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
termRef.value?.writeln('\x1b[1;32m=== Modrinth Server Console ===\x1b[0m')
|
||||
termRef.value?.writeln('')
|
||||
termRef.value?.writeln(
|
||||
'\x1b[36m[10:15:35]\x1b[0m \x1b[32m[Server/INFO]\x1b[0m: Done! For help, type "help"',
|
||||
)
|
||||
})
|
||||
|
||||
return { termRef, onCommand }
|
||||
},
|
||||
template: /*html*/ `
|
||||
<div style="width: 100%; height: 95vh;">
|
||||
<BaseTerminal ref="termRef" show-input @command="onCommand" />
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
Reference in New Issue
Block a user