Skip to content

Commit 87122cf

Browse files
authored
feat: console component (#5685)
1 parent fc87506 commit 87122cf

File tree

7 files changed

+572
-108
lines changed

7 files changed

+572
-108
lines changed

packages/ui/package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,9 @@
6666
"@types/three": "^0.172.0",
6767
"@vintl/how-ago": "^3.0.1",
6868
"@vueuse/core": "^11.1.0",
69+
"@xterm/addon-fit": "^0.11.0",
70+
"@xterm/addon-search": "^0.16.0",
71+
"@xterm/xterm": "^6.0.0",
6972
"ace-builds": "^1.43.5",
7073
"apexcharts": "^4.0.0",
7174
"dayjs": "^1.11.10",
@@ -74,8 +77,8 @@
7477
"fuse.js": "^6.6.2",
7578
"highlight.js": "^11.9.0",
7679
"intl-messageformat": "^10.7.7",
77-
"lru-cache": "^11.2.4",
7880
"jszip": "^3.10.1",
81+
"lru-cache": "^11.2.4",
7982
"markdown-it": "^13.0.2",
8083
"postprocessing": "^6.37.6",
8184
"qrcode.vue": "^3.4.1",
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
<template>
2+
<div
3+
class="flex size-full flex-col bg-surface-2 overflow-hidden rounded-[20px] border border-solid border-surface-4"
4+
>
5+
<div class="relative min-h-0 pb-1 flex-1 overflow-hidden">
6+
<div ref="containerRef" class="size-full pl-2" />
7+
<div v-if="!isAtBottom" class="absolute bottom-4 right-4">
8+
<ButtonStyled circular type="highlight">
9+
<button class="!shadow-none" aria-label="Scroll to bottom" @click="scrollToBottom">
10+
<ChevronDownIcon />
11+
</button>
12+
</ButtonStyled>
13+
</div>
14+
</div>
15+
<div
16+
v-if="showInput"
17+
class="border-t border-solid border-b-0 border-x-0 border-surface-5 bg-surface-3 p-4"
18+
>
19+
<StyledInput
20+
v-model="commandInput"
21+
:icon="TerminalSquareIcon"
22+
placeholder="Send a command"
23+
wrapper-class="w-full"
24+
input-class="!h-10"
25+
@keydown.enter="submitCommand"
26+
/>
27+
</div>
28+
</div>
29+
</template>
30+
31+
<script setup lang="ts">
32+
import { ChevronDownIcon, TerminalSquareIcon } from '@modrinth/assets'
33+
import type { Terminal } from '@xterm/xterm'
34+
import { ref } from 'vue'
35+
36+
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
37+
import StyledInput from '#ui/components/base/StyledInput.vue'
38+
import { useTerminal } from '#ui/composables/terminal'
39+
40+
const props = withDefaults(
41+
defineProps<{
42+
scrollback?: number
43+
showInput?: boolean
44+
}>(),
45+
{
46+
scrollback: 10000,
47+
showInput: false,
48+
},
49+
)
50+
51+
const emit = defineEmits<{
52+
command: [command: string]
53+
ready: [terminal: Terminal]
54+
}>()
55+
56+
const containerRef = ref<HTMLElement | null>(null)
57+
const commandInput = ref('')
58+
59+
const { terminal, searchAddon, isAtBottom, write, writeln, clear, reset, fit, scrollToBottom } =
60+
useTerminal({
61+
container: containerRef,
62+
scrollback: props.scrollback,
63+
onReady: (term) => emit('ready', term),
64+
})
65+
66+
const submitCommand = () => {
67+
const cmd = commandInput.value.trim()
68+
if (!cmd) return
69+
emit('command', cmd)
70+
commandInput.value = ''
71+
}
72+
73+
defineExpose({
74+
write,
75+
writeln,
76+
clear,
77+
reset,
78+
fit,
79+
scrollToBottom,
80+
terminal,
81+
searchAddon,
82+
isAtBottom,
83+
commandInput,
84+
})
85+
</script>
86+
87+
<style>
88+
.xterm {
89+
height: 100% !important;
90+
}
91+
92+
.xterm .xterm-scrollable-element {
93+
height: 100% !important;
94+
}
95+
96+
.xterm .xterm-screen {
97+
min-height: 100% !important;
98+
margin-left: auto !important;
99+
margin-right: auto !important;
100+
}
101+
</style>

packages/ui/src/components/base/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export { default as AutoBrandIcon } from './AutoBrandIcon.vue'
55
export { default as AutoLink } from './AutoLink.vue'
66
export { default as Avatar } from './Avatar.vue'
77
export { default as Badge } from './Badge.vue'
8+
export { default as BaseTerminal } from './BaseTerminal.vue'
89
export { default as BigOptionButton } from './BigOptionButton.vue'
910
export { default as BulletDivider } from './BulletDivider.vue'
1011
export { default as Button } from './Button.vue'

packages/ui/src/composables/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,5 @@ export * from './i18n-debug'
99
export * from './page-leave-safety'
1010
export * from './scroll-indicator'
1111
export * from './sticky-observer'
12+
export * from './terminal'
1213
export * from './virtual-scroll'
Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
import type { FitAddon } from '@xterm/addon-fit'
2+
import type { SearchAddon } from '@xterm/addon-search'
3+
import type { ITerminalOptions, Terminal } from '@xterm/xterm'
4+
import {
5+
nextTick,
6+
onBeforeUnmount,
7+
onMounted,
8+
type Ref,
9+
ref,
10+
type ShallowRef,
11+
shallowRef,
12+
} from 'vue'
13+
14+
function getCssVar(name: string, fallback: string): string {
15+
if (typeof document === 'undefined') return fallback
16+
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim()
17+
return value || fallback
18+
}
19+
20+
function buildTerminalTheme() {
21+
const surface2 = getCssVar('--surface-2', '#1d1f23')
22+
const surface5 = getCssVar('--surface-5', '#42444a')
23+
const textDefault = getCssVar('--color-text-default', '#b0bac5')
24+
const textTertiary = getCssVar('--color-text-tertiary', '#96a2b0')
25+
const textPrimary = getCssVar('--color-text-primary', '#ffffff')
26+
const red = getCssVar('--color-red', '#ff496e')
27+
const orange = getCssVar('--color-orange', '#ffa347')
28+
const green = getCssVar('--color-green', '#1bd96a')
29+
const blue = getCssVar('--color-blue', '#4a9eff')
30+
const purple = getCssVar('--color-purple', '#bc3fbc')
31+
32+
return {
33+
background: surface2,
34+
foreground: textDefault,
35+
cursor: textDefault,
36+
cursorAccent: surface2,
37+
selectionBackground: 'rgba(128, 128, 128, 0.3)',
38+
black: surface2,
39+
red,
40+
green,
41+
yellow: orange,
42+
blue,
43+
magenta: purple,
44+
cyan: textTertiary,
45+
white: textDefault,
46+
brightBlack: surface5,
47+
brightRed: red,
48+
brightGreen: green,
49+
brightYellow: orange,
50+
brightBlue: blue,
51+
brightMagenta: purple,
52+
brightCyan: textTertiary,
53+
brightWhite: textPrimary,
54+
scrollbarSliderBackground: surface5,
55+
scrollbarSliderHoverBackground: surface5,
56+
scrollbarSliderActiveBackground: surface5,
57+
}
58+
}
59+
60+
export interface UseTerminalOptions {
61+
container: Ref<HTMLElement | null>
62+
options?: ITerminalOptions
63+
scrollback?: number
64+
onReady?: (terminal: Terminal) => void
65+
}
66+
67+
export interface UseTerminalReturn {
68+
terminal: ShallowRef<Terminal | null>
69+
fitAddon: ShallowRef<FitAddon | null>
70+
searchAddon: ShallowRef<SearchAddon | null>
71+
isAtBottom: Ref<boolean>
72+
write: (data: string) => void
73+
writeln: (data: string) => void
74+
clear: () => void
75+
reset: () => void
76+
fit: () => void
77+
scrollToBottom: () => void
78+
}
79+
80+
export function useTerminal(options: UseTerminalOptions): UseTerminalReturn {
81+
const terminal = shallowRef<Terminal | null>(null)
82+
const fitAddon = shallowRef<FitAddon | null>(null)
83+
const searchAddon = shallowRef<SearchAddon | null>(null)
84+
const isAtBottom = ref(true)
85+
86+
let resizeObserver: ResizeObserver | null = null
87+
let themeObserver: MutationObserver | null = null
88+
let hasWritten = false
89+
const pendingWrites: Array<{ data: string; newline: boolean }> = []
90+
91+
const write = (data: string) => {
92+
if (terminal.value) {
93+
terminal.value.write(data)
94+
hasWritten = true
95+
} else {
96+
pendingWrites.push({ data, newline: false })
97+
}
98+
}
99+
100+
const writeln = (data: string) => {
101+
if (terminal.value) {
102+
if (hasWritten) {
103+
terminal.value.write('\r\n' + data)
104+
} else {
105+
terminal.value.write(data)
106+
hasWritten = true
107+
}
108+
} else {
109+
pendingWrites.push({ data, newline: true })
110+
}
111+
}
112+
113+
const clear = () => {
114+
terminal.value?.clear()
115+
hasWritten = false
116+
}
117+
118+
const reset = () => {
119+
terminal.value?.reset()
120+
hasWritten = false
121+
}
122+
123+
const fit = () => {
124+
const fa = fitAddon.value
125+
const term = terminal.value
126+
if (!fa || !term) return
127+
const dims = fa.proposeDimensions()
128+
if (dims) {
129+
term.resize(dims.cols, dims.rows + 1)
130+
}
131+
}
132+
133+
const scrollToBottom = () => {
134+
terminal.value?.scrollToBottom()
135+
isAtBottom.value = true
136+
137+
// dont even ask, shit is broken as hell
138+
// scrollToBottom is unreliable so we have to spam it to make sure it actually goes to the bottom
139+
let calls = 0
140+
const interval = setInterval(() => {
141+
terminal.value?.scrollToBottom()
142+
if (++calls >= 10) clearInterval(interval)
143+
}, 25)
144+
}
145+
146+
const checkIfAtBottom = () => {
147+
const term = terminal.value
148+
if (!term) return
149+
const buffer = term.buffer.active
150+
isAtBottom.value = buffer.baseY - buffer.viewportY <= 2
151+
}
152+
153+
onMounted(async () => {
154+
const container = options.container.value
155+
if (!container) return
156+
157+
const [{ Terminal }, { FitAddon }, { SearchAddon }] = await Promise.all([
158+
import('@xterm/xterm'),
159+
import('@xterm/addon-fit'),
160+
import('@xterm/addon-search'),
161+
])
162+
163+
await import('@xterm/xterm/css/xterm.css')
164+
165+
const term = new Terminal({
166+
disableStdin: true,
167+
scrollback: options.scrollback ?? 10000,
168+
convertEol: true,
169+
smoothScrollDuration: 125,
170+
fontFamily: 'monospace',
171+
fontSize: 14,
172+
lineHeight: 1.5,
173+
theme: buildTerminalTheme(),
174+
...options.options,
175+
})
176+
177+
const fit = new FitAddon()
178+
const search = new SearchAddon()
179+
180+
term.loadAddon(fit)
181+
term.loadAddon(search)
182+
term.open(container)
183+
await nextTick()
184+
const dims = fit.proposeDimensions()
185+
if (dims) {
186+
term.resize(dims.cols, dims.rows + 1)
187+
}
188+
189+
term.options.disableStdin = true
190+
term.write('\x1b[?25l')
191+
192+
term.onScroll(() => checkIfAtBottom())
193+
term.onWriteParsed(() => {
194+
if (isAtBottom.value) {
195+
term.scrollToBottom()
196+
}
197+
})
198+
199+
terminal.value = term
200+
fitAddon.value = fit
201+
searchAddon.value = search
202+
203+
for (const pending of pendingWrites) {
204+
if (pending.newline) {
205+
writeln(pending.data)
206+
} else {
207+
write(pending.data)
208+
}
209+
}
210+
pendingWrites.length = 0
211+
212+
resizeObserver = new ResizeObserver(() => {
213+
const d = fit.proposeDimensions()
214+
if (d) {
215+
term.resize(d.cols, d.rows + 1)
216+
}
217+
})
218+
resizeObserver.observe(container)
219+
220+
themeObserver = new MutationObserver(() => {
221+
term.options.theme = buildTerminalTheme()
222+
})
223+
themeObserver.observe(document.documentElement, {
224+
attributes: true,
225+
attributeFilter: ['data-theme', 'class'],
226+
})
227+
228+
options.onReady?.(term)
229+
})
230+
231+
onBeforeUnmount(() => {
232+
resizeObserver?.disconnect()
233+
resizeObserver = null
234+
themeObserver?.disconnect()
235+
themeObserver = null
236+
terminal.value?.dispose()
237+
terminal.value = null
238+
})
239+
240+
return {
241+
terminal,
242+
fitAddon,
243+
searchAddon,
244+
isAtBottom,
245+
write,
246+
writeln,
247+
clear,
248+
reset,
249+
fit,
250+
scrollToBottom,
251+
}
252+
}

0 commit comments

Comments
 (0)