feat:移除了弹窗,服务器添加sls

This commit is contained in:
2026-09-08 22:39:45 +08:00
commit 6a295f9a7a
4082 changed files with 1322534 additions and 0 deletions

View File

@ -0,0 +1,86 @@
<template>
<div class="flex items-center gap-1">
<ButtonStyled v-if="showClear && hasLogs" type="transparent">
<button
v-tooltip="clearDisabled ? clearDisabledTooltip : undefined"
:disabled="clearDisabled"
@click="emit('clear')"
>
<XIcon />
{{ formatMessage(commonMessages.clearButton) }}
</button>
</ButtonStyled>
<ButtonStyled v-if="showDelete" type="transparent" hover-color-fill="background" color="red">
<button
v-tooltip="deleteDisabled ? deleteDisabledTooltip : undefined"
:disabled="deleteDisabled"
@click="emit('delete')"
>
<TrashIcon />
{{ formatMessage(commonMessages.deleteLabel) }}
</button>
</ButtonStyled>
<ButtonStyled v-if="hasLogs" type="transparent">
<button
v-tooltip="shareDisabled ? shareDisabledTooltip : undefined"
:disabled="shareDisabled || sharing"
@click="emit('share')"
>
<SpinnerIcon v-if="sharing" class="animate-spin" />
<ShareIcon v-else />
{{ formatMessage(messages.share) }}
</button>
</ButtonStyled>
<ButtonStyled type="transparent">
<button @click="emit('toggle-fullscreen')">
<ContractIcon v-if="fullscreen" />
<ExpandIcon v-else />
{{ formatMessage(fullscreen ? messages.collapse : messages.expand) }}
</button>
</ButtonStyled>
</div>
</template>
<script setup lang="ts">
import {
ContractIcon,
ExpandIcon,
ShareIcon,
SpinnerIcon,
TrashIcon,
XIcon,
} from '@modrinth/assets'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { commonMessages } from '#ui/utils/common-messages'
defineProps<{
showClear?: boolean
hasLogs?: boolean
shareDisabled?: boolean
shareDisabledTooltip?: string
sharing?: boolean
fullscreen?: boolean
clearDisabled?: boolean
clearDisabledTooltip?: string
showDelete?: boolean
deleteDisabled?: boolean
deleteDisabledTooltip?: string
}>()
const emit = defineEmits<{
clear: []
share: []
'toggle-fullscreen': []
delete: []
}>()
const { formatMessage } = useVIntl()
const messages = defineMessages({
share: { id: 'console.action.share', defaultMessage: 'Share' },
expand: { id: 'console.action.expand', defaultMessage: 'Expand' },
collapse: { id: 'console.action.collapse', defaultMessage: 'Collapse' },
})
</script>

View File

@ -0,0 +1,56 @@
<template>
<div class="flex flex-wrap items-center gap-1.5">
<button
v-for="option in filterOptions"
:key="option.id"
class="cursor-pointer rounded-full px-3 py-1.5 text-base font-semibold leading-5 transition-all duration-100 active:scale-[0.97]"
:class="
modelValue.has(option.id)
? 'bg-brand-highlight text-brand'
: 'bg-surface-4 text-primary hover:bg-surface-5'
"
:aria-pressed="modelValue.has(option.id)"
@click="handleToggle(option.id)"
>
{{ option.label }}
</button>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import type { LogLevel } from '../types'
type FilterValue = LogLevel
const modelValue = defineModel<Set<FilterValue>>({ required: true })
const emit = defineEmits<{
toggle: [value: FilterValue]
}>()
const { formatMessage } = useVIntl()
const messages = defineMessages({
error: { id: 'console.filter.error', defaultMessage: 'Error' },
warn: { id: 'console.filter.warn', defaultMessage: 'Warn' },
info: { id: 'console.filter.info', defaultMessage: 'Info' },
})
const FILTER_OPTIONS = [
{ id: 'error' as const, message: messages.error },
{ id: 'warn' as const, message: messages.warn },
{ id: 'info' as const, message: messages.info },
]
const filterOptions = computed(() =>
FILTER_OPTIONS.map((option) => ({ id: option.id, label: formatMessage(option.message) })),
)
function handleToggle(id: FilterValue) {
emit('toggle', id)
}
</script>

View File

@ -0,0 +1,681 @@
<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>

View File

@ -0,0 +1,595 @@
<template>
<div
ref="viewportRef"
class="log-viewport font-mono"
:class="{ 'log-viewport-wrap': wrap, 'overflow-x-hidden': wrap }"
:style="{ fontSize: fontSize + 'px' }"
@scroll="handleScroll"
>
<div v-if="lines.length === 0" class="flex items-center justify-center h-full">
<EmptyState
v-if="emptyStateType === 'instance'"
:heading="formatMessage(consoleMessages.emptyInstanceTitle)"
:description="formatMessage(consoleMessages.emptyInstanceDescription)"
/>
<EmptyState
v-else-if="emptyStateType === 'server'"
:heading="formatMessage(consoleMessages.emptyServerTitle)"
:description="formatMessage(consoleMessages.emptyServerDescription)"
/>
</div>
<div
v-else
class="log-viewport-spacer relative w-full min-w-max"
:style="{ height: totalHeight + 'px' }"
>
<div
class="absolute inset-x-0 top-0"
:style="{ transform: 'translateY(' + topOffset + 'px)' }"
>
<div
v-for="item in windowItems"
:key="item.originalIndex"
:data-line="item.originalIndex + 1"
class="log-line flex items-stretch whitespace-pre"
:class="entryClass(item.line)"
:style="{ height: estimateHeight(item) + 'px' }"
>
<span
class="flex shrink-0 w-[52px] items-center justify-end leading-none text-right text-secondary bg-surface-3 border-r border-solid border-surface-3 select-none overflow-hidden"
>{{ item.originalIndex + 1 }}</span
>
<span
class="log-line-content flex-1 px-2 break-all [overflow-wrap:anywhere]"
v-html="renderLine(item)"
></span>
</div>
</div>
</div>
<Transition name="scroll-to-bottom-fade">
<div v-if="lines.length > 0 && !stickToBottom" class="absolute bottom-4 right-4 z-10">
<ButtonStyled circular type="highlight" size="large">
<button aria-label="Scroll to bottom" @click="scrollToBottom">
<ChevronDownIcon />
</button>
</ButtonStyled>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
// 日志查看器:交互与布局参考 LogShare-Web-UI (src/views/LogView.vue)
// 逐行正则高亮来自 ./composables/log-highlight.ts移植自 logParser.worker.ts
// LogShare-Web-UI 为 MIT License, Copyright (c) 2024 LogShare.CN Team详见 packages/ui/COPYING.md。
import { ChevronDownIcon } from '@modrinth/assets'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import EmptyState from '#ui/components/base/EmptyState.vue'
import { useVIntl } from '#ui/composables/i18n'
import { highlightLine } from '../composables/log-highlight'
import { consoleMessages } from '../messages'
import type { LogLine } from '../types'
const { formatMessage } = useVIntl()
interface ViewportLine {
line: LogLine
originalIndex: number
}
const props = withDefaults(
defineProps<{
lines: ViewportLine[]
searchQuery?: string
wrap?: boolean
fontSize?: number
emptyStateType?: 'server' | 'instance'
}>(),
{
searchQuery: '',
wrap: false,
fontSize: 12,
emptyStateType: undefined,
},
)
const viewportRef = ref<HTMLElement | null>(null)
const scrollTop = ref(0)
const viewportHeight = ref(0)
const stickToBottom = ref(true)
// 行高:单行 = 字号 × 1.4与等宽字体匹配wrap 时按估算折行数放大
const lineHeightPx = computed(() => Math.round(props.fontSize * 1.4))
// wrap 折行估算0.6em 为等宽字符平均宽,乘 0.9 留保守余量(行高宁高勿矮,避免内容溢出重叠)
const charsPerLine = computed(() => {
const vp = viewportRef.value
if (!vp) return 120
return Math.max(20, Math.floor((vp.clientWidth / (props.fontSize * 0.6)) * 0.9))
})
function estimateHeight(item: ViewportLine): number {
if (!props.wrap) return lineHeightPx.value
const lines = Math.max(1, Math.ceil(item.line.text.length / charsPerLine.value))
return lines * lineHeightPx.value
}
// 高度前缀和缓存lines/wrap/fontSize 变化时重建O(n)滚动时二分查找O(log n)
// 总高度必须是响应式的:普通变量 + 无依赖 computed 会缓存过期值,
// 清空控制台后模板不再读取它,重启后 spacer 会以旧高度渲染(底部空白)。
let heightPrefix: number[] | null = null
const heightTotal = ref(0)
function rebuildHeights() {
const n = props.lines.length
if (!props.wrap) {
heightPrefix = null
heightTotal.value = n * lineHeightPx.value
return
}
const prefix = new Array<number>(n)
let acc = 0
for (let i = 0; i < n; i++) {
prefix[i] = acc
acc += estimateHeight(props.lines[i]!)
}
heightPrefix = prefix
heightTotal.value = acc
}
watch(
() => [props.lines, props.wrap, props.fontSize] as const,
([lines], previous) => {
rebuildHeights()
// A fresh stream after an empty console (clear, restart, initial
// hydration) always resumes bottom-following.
if (previous && previous[0].length === 0 && lines.length > 0) {
stickToBottom.value = true
}
if (lines.length === 0) {
// Reset the virtual window state along with the DOM scroll position;
// browsers may clamp silently without firing a scroll event.
scrollTop.value = 0
if (viewportRef.value) viewportRef.value.scrollTop = 0
}
if (stickToBottom.value) {
nextTick(scrollToBottom)
}
},
{ immediate: true },
)
const totalHeight = computed(() => heightTotal.value)
// 虚拟窗口:可见行 + 上下缓冲
const WINDOW_BUFFER = 15
function computeWindow(): { items: ViewportLine[]; startIndex: number } {
const n = props.lines.length
if (n === 0) return { items: [], startIndex: 0 }
let start = 0
let end = n - 1
if (n > WINDOW_BUFFER * 2) {
if (props.wrap && heightPrefix) {
let lo = 0
let hi = n - 1
while (lo < hi) {
const mid = (lo + hi + 1) >> 1
if (heightPrefix[mid]! <= scrollTop.value) lo = mid
else hi = mid - 1
}
start = Math.max(0, lo - WINDOW_BUFFER)
} else {
const first = Math.floor(scrollTop.value / lineHeightPx.value)
start = Math.max(0, first - WINDOW_BUFFER)
}
end = Math.min(
n - 1,
start + Math.ceil(viewportHeight.value / lineHeightPx.value) + WINDOW_BUFFER * 2,
)
}
return { items: props.lines.slice(start, end + 1), startIndex: start }
}
const windowState = computed(computeWindow)
const windowItems = computed(() => windowState.value.items)
const topOffset = computed(() => {
const { startIndex } = windowState.value
if (startIndex === 0) return 0
if (props.wrap && heightPrefix) return heightPrefix[startIndex]!
return startIndex * lineHeightPx.value
})
function entryClass(line: LogLine): string {
if (line.level === 'error') return 'entry-error'
if (line.level === 'warn') return 'entry-warning'
return 'entry-no-error'
}
function renderLine(item: ViewportLine): string {
let text = item.line.text
if (props.searchQuery) {
const terms = props.searchQuery
.trim()
.toLowerCase()
.split(/\s+/)
.filter((t) => t.length > 0)
if (terms.length > 0) {
for (const term of [...terms].sort((a, b) => b.length - a.length)) {
const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
text = text.replace(new RegExp(`(${escaped})`, 'gi'), '<mark>$1</mark>')
}
}
}
return highlightLine(text)
}
function handleScroll() {
const vp = viewportRef.value
if (!vp) return
scrollTop.value = vp.scrollTop
viewportHeight.value = vp.clientHeight
stickToBottom.value = vp.scrollTop + vp.clientHeight >= vp.scrollHeight - lineHeightPx.value * 2
}
function scrollToBottom() {
const vp = viewportRef.value
if (!vp) return
vp.scrollTop = vp.scrollHeight
scrollTop.value = vp.scrollTop
stickToBottom.value = true
}
function syncViewportSize() {
const vp = viewportRef.value
if (!vp) return
viewportHeight.value = vp.clientHeight
// 窗口宽度影响 wrap 折行估算resize 时重建高度缓存
if (props.wrap) rebuildHeights()
}
let resizeObserver: ResizeObserver | null = null
onMounted(() => {
syncViewportSize()
if (stickToBottom.value) nextTick(scrollToBottom)
resizeObserver = new ResizeObserver(syncViewportSize)
if (viewportRef.value) resizeObserver.observe(viewportRef.value)
window.addEventListener('resize', syncViewportSize)
})
onBeforeUnmount(() => {
resizeObserver?.disconnect()
resizeObserver = null
window.removeEventListener('resize', syncViewportSize)
})
defineExpose({
scrollToBottom,
})
</script>
<style>
.log-viewport {
height: 100%;
overflow-y: auto;
overflow-x: auto;
background-color: var(--surface-2);
color: var(--color-text-default);
line-height: 1.4;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
user-select: text;
}
.scroll-to-bottom-fade-enter-active,
.scroll-to-bottom-fade-leave-active {
transition: opacity 250ms ease-in-out;
}
.scroll-to-bottom-fade-enter-from,
.scroll-to-bottom-fade-leave-to {
opacity: 0;
}
.log-viewport-wrap .log-viewport-spacer {
min-width: 0;
}
.log-viewport-wrap .log-line {
white-space: normal;
}
.log-viewport-wrap .log-line-content {
min-width: 0;
}
.log-line.entry-error {
background-color: color-mix(in srgb, var(--color-red) 12%, transparent);
}
.log-line.entry-warning {
background-color: color-mix(in srgb, var(--color-orange) 12%, transparent);
}
[data-theme='dark'] .log-line.entry-error {
background-color: color-mix(in srgb, var(--color-red) 18%, transparent);
}
[data-theme='dark'] .log-line.entry-warning {
background-color: color-mix(in srgb, var(--color-orange) 18%, transparent);
}
.log-line mark {
padding: 0 0.1em;
background-color: color-mix(in srgb, var(--color-blue) 45%, transparent);
color: var(--color-text-primary);
border-radius: 2px;
font-weight: 500;
}
/* ===== LogShare token 高亮LogsAnalysis.css 移植,前景色用主题变量) ===== */
.level {
white-space: pre-wrap;
word-break: break-all;
overflow-wrap: anywhere;
}
.level-error,
.level-critical,
.level-emergency {
color: var(--color-red);
font-weight: 600;
}
.level-warning {
color: var(--color-orange);
}
.level-fatal {
color: var(--color-red);
font-weight: 700;
background-color: color-mix(in srgb, var(--color-red) 8%, transparent);
}
[data-theme='dark'] .level-fatal {
background-color: color-mix(in srgb, var(--color-red) 15%, transparent);
}
.level-debug,
.level-notice {
color: var(--color-text-secondary);
background-color: color-mix(in srgb, var(--color-blue) 5%, transparent);
}
.level-notice {
background-color: color-mix(in srgb, var(--color-blue) 10%, transparent);
}
.level-timestamp {
color: var(--color-blue);
font-weight: 500;
}
.level-info-prefix {
color: var(--color-green);
font-weight: 500;
}
.level-thread {
color: var(--color-blue);
opacity: 0.85;
}
.level-error-word {
color: var(--color-red);
font-weight: 600;
}
.level-warning-tag {
color: var(--color-orange);
font-weight: 600;
}
.level-plugin {
color: var(--color-green);
opacity: 0.85;
}
.level-filepath {
color: var(--color-blue);
opacity: 0.85;
}
.level-dimmed {
color: var(--color-text-tertiary);
opacity: 0.75;
}
.level-stack-frame {
color: var(--color-text-secondary);
}
.level-stack-class {
color: var(--color-orange);
font-weight: 500;
}
.level-stack-location {
color: var(--color-blue);
}
.level-stack-caused-by {
color: var(--color-red);
font-weight: 600;
}
.level-stack-exception {
color: var(--color-red);
font-weight: 600;
}
.level-mod-header {
color: var(--color-purple);
font-weight: 700;
}
.level-mod-id {
color: var(--color-blue);
font-weight: 500;
}
.level-mod-version {
color: #d4a72c;
font-weight: 500;
}
.level-mod-name {
color: var(--color-text-secondary);
}
.level-mod-status {
color: var(--color-text-tertiary);
}
.level-mod-status-ok {
color: var(--color-green);
font-weight: 600;
}
.level-mod-status-error {
color: var(--color-red);
font-weight: 600;
}
.level-mod-status-warn {
color: var(--color-orange);
font-weight: 600;
}
.level-mod-tree {
color: var(--color-blue);
}
.level-mod-dim {
color: var(--color-green);
}
.level-env-key {
color: var(--color-blue);
font-weight: 500;
}
.level-section-header {
color: var(--color-purple);
font-weight: 600;
}
.level-arg-flag {
color: #d4a72c;
font-weight: 500;
}
.level-section-marker {
color: var(--color-orange);
font-weight: 600;
}
/* Minecraft § 颜色码 */
.format-black {
color: #000000;
}
.format-darkblue {
color: #0000aa;
}
.format-darkgreen {
color: #00aa00;
}
.format-darkaqua {
color: #00aaaa;
}
.format-darkred {
color: #aa0000;
}
.format-darkpurple {
color: #aa00aa;
}
.format-gold {
color: #ffaa00;
}
.format-gray {
color: #aaaaaa;
}
.format-darkgray {
color: #555555;
}
.format-blue {
color: #5555ff;
}
.format-green {
color: #55ff55;
}
.format-aqua {
color: #55ffff;
}
.format-red {
color: #ff5555;
}
.format-lightpurple {
color: #ff55ff;
}
.format-yellow {
color: #ffff55;
}
.format-white {
color: #ffffff;
}
.format-reset {
color: var(--color-text-default);
font-weight: normal;
text-decoration: none;
font-style: normal;
}
.format-bold {
font-weight: bold;
}
.format-underline {
text-decoration: underline;
}
.format-italic {
font-style: italic;
}
.format-strike {
text-decoration: line-through;
}
</style>