Files
Starlight_Lancher/packages/ui/src/layouts/shared/console/components/JLineCommandInput.vue

682 lines
21 KiB
Vue

<template>
<div ref="root" class="relative w-full font-mono text-base">
<StyledInput
v-if="!enhanced"
v-model="fallbackValue"
:icon="TerminalSquareIcon"
:placeholder="placeholder"
:disabled="disabled"
wrapper-class="w-full"
input-class="!h-9"
autocomplete="off"
:spellcheck="false"
@keydown.enter="submitFallback"
/>
<div
v-else
class="relative min-h-9 overflow-hidden rounded-xl bg-surface-4 pl-10 pr-3 ring-brand-shadow focus-within:ring-4"
>
<TerminalSquareIcon
class="pointer-events-none absolute left-3 top-2 h-5 w-5 text-secondary"
aria-hidden="true"
/>
<div
class="pointer-events-none min-h-9 whitespace-pre-wrap break-all py-2 font-medium leading-5 text-primary"
aria-hidden="true"
>
<span v-for="(segment, index) in styledInput" :key="index" :style="segment.style">{{
segment.text
}}</span>
</div>
<textarea
ref="input"
:value="prompt?.value ?? ''"
:placeholder="placeholder"
:disabled="disabled"
rows="1"
class="absolute inset-0 h-full min-h-9 w-full resize-none overflow-hidden border-0 bg-transparent py-2 pl-10 pr-3 font-mono font-medium leading-5 text-transparent caret-[var(--color-text-default)] outline-none placeholder:text-secondary"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
:spellcheck="false"
@keydown="handleKeydown"
@click="syncPointerCursor"
@pointerdown="pointerSelecting = true"
@beforeinput="handleBeforeInput"
@paste="handlePaste"
@compositionstart="handleCompositionStart"
@compositionend="handleCompositionEnd"
/>
</div>
<div
v-if="candidates.length"
class="absolute bottom-full left-0 z-20 mb-2 flex w-full min-w-64 max-w-lg flex-col overflow-hidden rounded-lg border border-solid border-surface-4 bg-surface-3 shadow-lg"
>
<div class="border-0 border-b border-solid border-surface-4 p-2">
<StyledInput
v-model="candidateSearchQuery"
:icon="SearchIcon"
:placeholder="formatMessage(messages.searchCompletions)"
wrapper-class="w-full"
input-class="!h-9 font-sans"
autocomplete="off"
:spellcheck="false"
clearable
@keydown.down.prevent="moveCandidateFocus(1)"
@keydown.up.prevent="moveCandidateFocus(-1)"
@keydown.tab.prevent="moveCandidateFocus($event.shiftKey ? -1 : 1)"
@keydown.enter.prevent="selectFocusedCandidate"
@keydown.escape.prevent="closeCandidateSearch"
/>
</div>
<div
v-if="filteredCandidates.length"
role="listbox"
class="flex max-h-56 flex-col gap-1 overflow-y-auto overscroll-contain p-2"
>
<button
v-for="(candidate, index) in filteredCandidates"
:key="`${candidate.row}:${candidate.column}:${candidate.text}`"
type="button"
role="option"
:aria-selected="index === focusedCandidateIndex"
:data-completion-focused="index === focusedCandidateIndex"
class="min-h-9 min-w-0 shrink-0 rounded-md border-0 px-3 py-2 text-left font-mono text-sm transition-colors hover:bg-surface-4"
:class="
index === focusedCandidateIndex
? 'bg-surface-4 text-contrast'
: 'bg-transparent text-primary'
"
:style="candidateStyleToCss(candidate, index === focusedCandidateIndex)"
@mouseenter="focusedCandidateIndex = index"
@mousedown.prevent
@click="selectCandidate(candidate)"
>
<span class="block truncate">{{ candidate.text }}</span>
</button>
</div>
<div v-else class="px-3 py-4 text-center font-sans text-sm text-secondary">
{{ formatMessage(messages.noCompletions) }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { SearchIcon, TerminalSquareIcon } from '@modrinth/assets'
import type { IBufferCell, Terminal } from '@xterm/xterm'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import StyledInput from '#ui/components/base/StyledInput.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import {
applyJLineCandidate,
createJLineLineReplacementSequence,
extractJLinePrompt,
jlineKeySequence,
type JLineCandidate,
type JLineCell,
type JLineCellStyle,
type JLineRow,
parseJLineCandidateConfirmation,
parseJLineCandidates,
replaceJLineSelection,
} from '../jline'
const props = defineProps<{
disabled?: boolean
placeholder?: string
sendInput: (data: Uint8Array) => void | Promise<void>
sendCommand: (command: string) => void | Promise<void>
resizeConsole: (cols: number, rows: number) => void | Promise<void>
}>()
const root = ref<HTMLElement | null>(null)
const input = ref<HTMLTextAreaElement | null>(null)
const fallbackValue = ref('')
const prompt = ref<ReturnType<typeof extractJLinePrompt>>(null)
const shellPrompt = ref<ReturnType<typeof extractJLinePrompt>>(null)
const completionPrompt = ref<ReturnType<typeof extractJLinePrompt>>(null)
const candidates = ref<JLineCandidate[]>([])
const candidateSearchQuery = ref('')
const focusedCandidateIndex = ref(-1)
const enhanced = ref(false)
const composing = ref(false)
const menuRequested = ref(false)
const pointerSelecting = ref(false)
let terminal: Terminal | null = null
let resizeObserver: ResizeObserver | null = null
let pendingWrites: Uint8Array[] = []
let inputWriteQueue = Promise.resolve()
const encoder = new TextEncoder()
const TERMINAL_ROWS = 12
const INITIAL_CANDIDATE_TERMINAL_ROWS = 512
const MAX_CANDIDATE_TERMINAL_ROWS = 8192
let terminalRows = TERMINAL_ROWS
let candidateConfirmationPending = false
let suppressCompositionEnter = false
let compositionEnterTimer: ReturnType<typeof setTimeout> | null = null
let compositionSelection: { start: number; end: number } | null = null
const { formatMessage } = useVIntl()
const messages = defineMessages({
searchCompletions: {
id: 'console.command-completions.search',
defaultMessage: 'Search command completions',
},
noCompletions: {
id: 'console.command-completions.no-results',
defaultMessage: 'No matching completions',
},
})
const filteredCandidates = computed(() => {
const query = candidateSearchQuery.value.trim().toLowerCase()
if (!query) return candidates.value
return candidates.value.filter((candidate) => candidate.text.toLowerCase().includes(query))
})
watch(candidates, (list) => {
if (list.length === 0) {
candidateSearchQuery.value = ''
focusedCandidateIndex.value = -1
return
}
focusedCandidateIndex.value = filteredCandidates.value.length ? 0 : -1
})
watch(candidateSearchQuery, () => {
focusedCandidateIndex.value = filteredCandidates.value.length > 0 ? 0 : -1
})
const styledInput = computed(() => {
if (!prompt.value) return []
const cells = prompt.value.rows.flatMap((row) => row.cells).slice(2)
const segments: Array<{ text: string; style: Record<string, string> }> = []
let remaining = prompt.value.value.length
for (const cell of cells) {
if (remaining <= 0 || !cell.text) continue
const text = cell.text.slice(0, remaining)
remaining -= text.length
const style = styleToCss(cell.style)
const previous = segments.at(-1)
if (previous && JSON.stringify(previous.style) === JSON.stringify(style)) {
previous.text += text
} else {
segments.push({ text, style })
}
}
return segments
})
onMounted(async () => {
window.addEventListener('pointerup', finishPointerSelection)
window.addEventListener('pointercancel', finishPointerSelection)
const { Terminal } = await import('@xterm/xterm')
terminal = new Terminal({
cols: 80,
rows: terminalRows,
scrollback: 0,
convertEol: false,
allowProposedApi: true,
})
for (const bytes of pendingWrites) terminal.write(bytes, updateFromTerminal)
pendingWrites = []
resizeObserver = new ResizeObserver(resize)
if (root.value) resizeObserver.observe(root.value)
resize()
await sendText('\x0c')
})
onBeforeUnmount(() => {
if (compositionEnterTimer) window.clearTimeout(compositionEnterTimer)
window.removeEventListener('pointerup', finishPointerSelection)
window.removeEventListener('pointercancel', finishPointerSelection)
resizeObserver?.disconnect()
terminal?.dispose()
})
function write(data: Uint8Array) {
if (!terminal) {
pendingWrites.push(data)
return
}
terminal.write(data, updateFromTerminal)
}
function resize() {
if (!root.value || !terminal) return
const fontSize = Number.parseFloat(getComputedStyle(root.value).fontSize) || 16
const cellWidth = fontSize * 0.61
const cols = Math.max(20, Math.floor((root.value.clientWidth - 52) / cellWidth))
if (terminal.cols !== cols || terminal.rows !== terminalRows) {
terminal.resize(cols, terminalRows)
}
void Promise.resolve(props.resizeConsole(cols, terminalRows)).catch(() => {})
}
function updateFromTerminal() {
if (!terminal) return
const restoreFocus = document.activeElement === input.value
const buffer = terminal.buffer.active
const cursorRow = buffer.baseY + buffer.cursorY
const rows: JLineRow[] = []
for (let index = buffer.viewportY; index < buffer.viewportY + terminal.rows; index++) {
const line = buffer.getLine(index)
if (!line) continue
const cells: JLineCell[] = []
const reusable = buffer.getNullCell()
for (let column = 0; column < terminal.cols; column++) {
const cell = line.getCell(column, reusable)
if (!cell) continue
cells.push({
text: cell.getWidth() === 0 ? '' : cell.getChars() || ' ',
width: cell.getWidth(),
style: cellStyle(cell),
})
}
rows.push({ index, wrapped: line.isWrapped, cells })
}
const nextPrompt = extractJLinePrompt(rows, cursorRow, buffer.cursorX)
if (nextPrompt) shellPrompt.value = nextPrompt
prompt.value = menuRequested.value && completionPrompt.value ? completionPrompt.value : nextPrompt
if (nextPrompt) enhanced.value = true
if (menuRequested.value) {
const confirmation = parseJLineCandidateConfirmation(rows)
if (confirmation && !candidateConfirmationPending) {
candidateConfirmationPending = true
void confirmCandidateMenu(confirmation.lineCount)
return
}
const parsedCandidates = parseJLineCandidates(rows, cursorRow)
if (parsedCandidates.length > 0) {
candidateConfirmationPending = false
if (!sameCandidates(candidates.value, parsedCandidates)) candidates.value = parsedCandidates
} else if (parsedCandidates.length === 0 && candidates.value.length === 0) {
if (nextPrompt) {
completionPrompt.value = nextPrompt
prompt.value = nextPrompt
}
}
}
void nextTick(() => {
if (!input.value || !prompt.value || composing.value) return
if (pointerSelecting.value || hasTextSelection()) return
if (restoreFocus) input.value.focus()
const cursor = Math.min(prompt.value.cursor, prompt.value.value.length)
input.value.setSelectionRange(cursor, cursor)
})
}
function cellStyle(cell: IBufferCell): JLineCellStyle {
return {
foreground: resolveColor(cell, 'foreground'),
background: resolveColor(cell, 'background'),
bold: Boolean(cell.isBold()),
italic: Boolean(cell.isItalic()),
underline: Boolean(cell.isUnderline()),
dim: Boolean(cell.isDim()),
inverse: Boolean(cell.isInverse()),
}
}
function resolveColor(cell: IBufferCell, kind: 'foreground' | 'background') {
const rgb = kind === 'foreground' ? cell.isFgRGB() : cell.isBgRGB()
const palette = kind === 'foreground' ? cell.isFgPalette() : cell.isBgPalette()
const color = kind === 'foreground' ? cell.getFgColor() : cell.getBgColor()
if (rgb) return `#${color.toString(16).padStart(6, '0')}`
if (!palette) return undefined
return ansiPaletteColor(color)
}
function styleToCss(style: JLineCellStyle): Record<string, string> {
let foreground = style.foreground ?? 'var(--color-text-default)'
let background = style.background ?? 'var(--surface-4)'
if (style.inverse) [foreground, background] = [background, foreground]
return {
...(foreground ? { color: foreground } : {}),
...(background ? { backgroundColor: background } : {}),
...(style.bold ? { fontWeight: '700' } : {}),
...(style.italic ? { fontStyle: 'italic' } : {}),
...(style.underline ? { textDecoration: 'underline' } : {}),
...(style.dim ? { opacity: '0.65' } : {}),
}
}
function candidateStyleToCss(candidate: JLineCandidate, focused: boolean): Record<string, string> {
const style = candidate.style
return {
...(!focused && style.foreground ? { color: style.foreground } : {}),
...(style.bold ? { fontWeight: '700' } : {}),
...(style.italic ? { fontStyle: 'italic' } : {}),
...(style.underline ? { textDecoration: 'underline' } : {}),
...(style.dim ? { opacity: '0.65' } : {}),
}
}
function sameCandidates(current: JLineCandidate[], next: JLineCandidate[]) {
return (
current.length === next.length &&
current.every(
(candidate, index) =>
candidate.text === next[index]?.text &&
candidate.row === next[index]?.row &&
candidate.column === next[index]?.column,
)
)
}
function ansiPaletteColor(index: number): string {
if (ANSI_COLORS[index]) return ANSI_COLORS[index]
if (index >= 16 && index <= 231) {
const value = index - 16
const red = Math.floor(value / 36)
const green = Math.floor((value % 36) / 6)
const blue = value % 6
const channel = (part: number) => (part === 0 ? 0 : 55 + part * 40)
return `rgb(${channel(red)}, ${channel(green)}, ${channel(blue)})`
}
const gray = 8 + Math.max(0, Math.min(23, index - 232)) * 10
return `rgb(${gray}, ${gray}, ${gray})`
}
function handleKeydown(event: KeyboardEvent) {
if (composing.value || event.isComposing || event.keyCode === 229) return
if (event.key === 'Enter' && suppressCompositionEnter) {
event.preventDefault()
suppressCompositionEnter = false
if (compositionEnterTimer) window.clearTimeout(compositionEnterTimer)
return
}
suppressCompositionEnter = false
const selection = inputSelection()
if (
selection &&
selection.start !== selection.end &&
(event.key === 'Backspace' || event.key === 'Delete')
) {
event.preventDefault()
replaceSelection('', selection.start, selection.end)
return
}
if (candidates.value.length > 0) {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
moveCandidateFocus(event.key === 'ArrowDown' ? 1 : -1)
return
}
if (event.key === 'Tab') {
event.preventDefault()
moveCandidateFocus(event.shiftKey ? -1 : 1)
return
}
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault()
selectFocusedCandidate()
return
}
}
if (event.key === 'Tab' && menuRequested.value && candidates.value.length > 0) {
event.preventDefault()
return
}
const sequence = keySequence(event.key)
if (!sequence) return
event.preventDefault()
if (menuRequested.value) {
if (event.key === 'Tab') {
void sendText(sequence)
return
}
continueFromCompletion(event.key === 'Escape' ? '' : sequence)
return
}
if (event.key === 'Tab' && !menuRequested.value) {
completionPrompt.value = prompt.value
menuRequested.value = true
void openCandidateMenu(sequence)
return
}
clearCompletion()
void sendText(sequence)
}
function syncPointerCursor() {
if (!input.value || !prompt.value) return
if (hasTextSelection()) return
const target = input.value.selectionStart ?? prompt.value.cursor
const distance = target - prompt.value.cursor
if (distance === 0) return
const key = distance < 0 ? 'ArrowLeft' : 'ArrowRight'
const sequence = keySequence(key)
if (sequence) void sendText(sequence.repeat(Math.abs(distance)))
}
function keySequence(key: string) {
return jlineKeySequence(key, terminal?.modes.applicationCursorKeysMode ?? false)
}
function handleBeforeInput(event: InputEvent) {
if (composing.value || event.inputType === 'insertFromPaste') return
const selection = inputSelection()
if (event.inputType.startsWith('delete')) {
if (!selection) return
let { start, end } = selection
if (start === end && event.inputType.includes('Backward') && start > 0) start--
if (
start === end &&
event.inputType.includes('Forward') &&
end < (prompt.value?.value.length ?? 0)
) {
end++
}
if (start === end) return
event.preventDefault()
replaceSelection('', start, end)
return
}
if (!event.inputType.startsWith('insert') || !event.data) return
event.preventDefault()
if (selection && (selection.start !== selection.end || menuRequested.value)) {
replaceSelection(event.data, selection.start, selection.end)
return
}
clearCompletion()
void sendText(event.data)
}
function handlePaste(event: ClipboardEvent) {
const text = event.clipboardData?.getData('text')
if (!text) return
event.preventDefault()
const selection = inputSelection()
if (selection && (selection.start !== selection.end || menuRequested.value)) {
replaceSelection(text, selection.start, selection.end)
return
}
clearCompletion()
void sendText(text)
}
function handleCompositionStart() {
composing.value = true
compositionSelection = inputSelection()
suppressCompositionEnter = false
if (compositionEnterTimer) window.clearTimeout(compositionEnterTimer)
}
function handleCompositionEnd(event: CompositionEvent) {
composing.value = false
suppressCompositionEnter = true
if (compositionEnterTimer) window.clearTimeout(compositionEnterTimer)
compositionEnterTimer = window.setTimeout(() => {
suppressCompositionEnter = false
compositionEnterTimer = null
}, 100)
const selection = compositionSelection
compositionSelection = null
if (event.data && selection && (selection.start !== selection.end || menuRequested.value)) {
replaceSelection(event.data, selection.start, selection.end)
} else {
clearCompletion()
if (event.data) void sendText(event.data)
}
}
function sendText(text: string) {
const data = encoder.encode(text)
inputWriteQueue = inputWriteQueue
.then(() => props.sendInput(data))
.catch(() => {
// A stop can race the final keystroke; keep the queue usable for the next write.
})
return inputWriteQueue
}
function selectCandidate(target: JLineCandidate) {
const base = completionPrompt.value ?? prompt.value
const current = shellPrompt.value ?? prompt.value
if (!base || !current) return
const next = applyJLineCandidate(base, target.text)
clearCompletion()
void sendText(
createJLineLineReplacementSequence(
current.value,
next,
terminal?.modes.applicationCursorKeysMode ?? false,
),
)
input.value?.focus()
}
function selectFocusedCandidate() {
const candidate = filteredCandidates.value[focusedCandidateIndex.value]
if (candidate) void selectCandidate(candidate)
}
function moveCandidateFocus(delta: number) {
const count = filteredCandidates.value.length
if (count === 0) return
focusedCandidateIndex.value = (focusedCandidateIndex.value + delta + count) % count
void nextTick(() => {
root.value
?.querySelector('[data-completion-focused="true"]')
?.scrollIntoView({ block: 'nearest' })
})
}
function closeCandidateSearch() {
candidateSearchQuery.value = ''
input.value?.focus()
}
function replaceSelection(replacement: string, start: number, end: number) {
const displayed = prompt.value
const current = shellPrompt.value ?? displayed
if (!displayed || !current) return
const next = replaceJLineSelection(displayed, start, end, replacement)
clearCompletion()
void sendText(
createJLineLineReplacementSequence(
current.value,
next,
terminal?.modes.applicationCursorKeysMode ?? false,
),
)
}
function continueFromCompletion(sequence: string) {
const base = completionPrompt.value ?? prompt.value
const current = shellPrompt.value ?? prompt.value
if (!base || !current) return
const next = { value: base.value, cursor: base.cursor }
clearCompletion()
void sendText(
`${createJLineLineReplacementSequence(current.value, next, terminal?.modes.applicationCursorKeysMode ?? false)}${sequence}`,
)
}
async function openCandidateMenu(sequence: string) {
if (terminal) {
terminalRows = INITIAL_CANDIDATE_TERMINAL_ROWS
if (terminal.rows !== terminalRows) terminal.resize(terminal.cols, terminalRows)
await Promise.resolve(props.resizeConsole(terminal.cols, terminalRows)).catch(() => {})
}
await sendText(sequence)
}
async function confirmCandidateMenu(lineCount: number) {
if (!terminal || lineCount <= 0 || lineCount + 4 > MAX_CANDIDATE_TERMINAL_ROWS) {
await sendText('n')
clearCompletion()
return
}
terminalRows = Math.max(INITIAL_CANDIDATE_TERMINAL_ROWS, lineCount + 4)
if (terminal.rows !== terminalRows) terminal.resize(terminal.cols, terminalRows)
await Promise.resolve(props.resizeConsole(terminal.cols, terminalRows)).catch(() => {})
await sendText('y')
}
function clearCompletion() {
candidateConfirmationPending = false
menuRequested.value = false
completionPrompt.value = null
candidates.value = []
candidateSearchQuery.value = ''
resetTerminalRows()
}
function resetTerminalRows() {
if (!terminal || terminalRows === TERMINAL_ROWS) return
terminalRows = TERMINAL_ROWS
if (terminal.rows !== terminalRows) terminal.resize(terminal.cols, terminalRows)
void Promise.resolve(props.resizeConsole(terminal.cols, terminalRows)).catch(() => {})
}
function inputSelection() {
if (!input.value) return null
return {
start: input.value.selectionStart ?? 0,
end: input.value.selectionEnd ?? 0,
}
}
function hasTextSelection() {
const selection = inputSelection()
return Boolean(selection && selection.start !== selection.end)
}
function finishPointerSelection() {
pointerSelecting.value = false
}
function submitFallback() {
const command = fallbackValue.value.trim()
if (!command || props.disabled) return
void Promise.resolve(props.sendCommand(command)).catch(() => {})
fallbackValue.value = ''
}
defineExpose({ write })
const ANSI_COLORS = [
'#1d1f23',
'#ff496e',
'#1bd96a',
'#ffa347',
'#4a9eff',
'#bc3fbc',
'#96a2b0',
'#b0bac5',
'#42444a',
'#ff496e',
'#1bd96a',
'#ffa347',
'#4a9eff',
'#bc3fbc',
'#96a2b0',
'#ffffff',
]
</script>