Files
Modrinth-plus/packages/ui/src/composables/terminal.ts
2026-04-18 17:50:20 +00:00

278 lines
6.7 KiB
TypeScript

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'
export 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,
overviewRulerBorder: 'transparent',
}
}
export interface UseTerminalOptions {
container: Ref<HTMLElement | null>
options?: ITerminalOptions
scrollback?: number
onReady?: (terminal: Terminal) => void
onResize?: () => 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 wheelHandler: ((e: WheelEvent) => void) | 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)
}
}
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 ?? Infinity,
convertEol: true,
smoothScrollDuration: 125,
fontFamily: 'monospace',
fontSize: 14,
lineHeight: 1.5,
allowProposedApi: true,
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)
}
term.options.disableStdin = true
term.write('\x1b[?25l')
term.attachCustomKeyEventHandler((e) => {
if (e.type !== 'keydown') return true
const mod = e.ctrlKey || e.metaKey
if (!mod) return true
const key = e.key.toLowerCase()
if (key === 'c' || key === 'insert' || key === 'a') {
return false
}
return true
})
wheelHandler = (e: WheelEvent) => {
e.preventDefault()
}
container.addEventListener('wheel', wheelHandler, { passive: false })
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)
}
options.onResize?.()
})
resizeObserver.observe(container)
themeObserver = new MutationObserver(() => {
term.options.theme = buildTerminalTheme()
})
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme', 'class'],
})
options.onReady?.(term)
})
onBeforeUnmount(() => {
if (wheelHandler && options.container.value) {
options.container.value.removeEventListener('wheel', wheelHandler)
wheelHandler = null
}
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,
}
}