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

This commit is contained in:
AxTps committed 2026-09-08 22:39:45 +08:00
commit 6a295f9a7a
4082 files changed
+1322534

No files matched your search

@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -0,0 +1,31 @@
import { ref } from 'vue'
import type { LogLevel, LogLine } from '../types'
export type FilterPredicate = (line: LogLine) => boolean
export type ConditionalLevel = 'debug' | 'trace'
export function useConsoleFilters() {
const activeFilters = ref<Set<LogLevel>>(new Set(['error', 'warn', 'info']))
function toggleFilter(level: LogLevel) {
const next = new Set(activeFilters.value)
if (next.has(level)) {
next.delete(level)
} else {
next.add(level)
}
activeFilters.value = next
}
function buildFilterPredicate(): FilterPredicate | null {
if (activeFilters.value.size === 0) return () => false
const allowed = activeFilters.value
return (line: LogLine) => {
return allowed.has(line.level ?? 'info')
}
}
return { activeFilters, toggleFilter, buildFilterPredicate }
}
@@ -0,0 +1,4 @@
export { type ConditionalLevel, type FilterPredicate, useConsoleFilters } from './console-filtering'
export type { LogHighlightMode } from './log-highlight'
export { clearHighlightCache, detectStrictLevel, highlightLine } from './log-highlight'
export { detectLogLevel } from './log-level'
@@ -0,0 +1,648 @@
import type { LogLevel } from '../types'
/**
* 日志高亮引擎 — 移植自 LogShare-Web-UI (src/lib/logParser.worker.ts)
*
* 逐行正则 + Trie 预筛 + LRU 缓存,输出 HTML(span + class),
* 样式类与 LogShare 的 LogsAnalysis.css 对应(由 LogViewport 提供样式)。
* LogShare-Web-UI 为 MIT License, Copyright (c) 2024 LogShare.CN Team,详见 packages/ui/COPYING.md。
* 零外部依赖,可在 Node 中独立运行(用于 benchmark)。
*/
export type LogHighlightMode = 'full' | 'lite' | 'line-only' | 'raw'
/**
* 单行长度保险:超过阈值跳过行内 token 高亮,只做级别检测;
* 超过硬上限完全不解析。防止超长行(Base64、巨型堆栈)拖垮渲染链路。
*/
const LONG_LINE_LIMIT = 4096
const LONG_LINE_HARD_LIMIT = 65536
const HTML_ESCAPE_MAP: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;',
}
const RE_HTML_ESCAPE = /[&<>"']/g
const RE_MARK_OPEN = /&lt;mark&gt;/gi
const RE_MARK_CLOSE = /&lt;\/mark&gt;/gi
/**
* Minecraft § 颜色码 → 样式类(与 LogShare CSS 的 .format-* 对应)
* k(混淆)在 LogShare CSS 中未定义样式,这里同样忽略
*/
const COLOR_STYLE_MAP: Record<string, string> = {
'0': 'format-black',
'1': 'format-darkblue',
'2': 'format-darkgreen',
'3': 'format-darkaqua',
'4': 'format-darkred',
'5': 'format-darkpurple',
'6': 'format-gold',
'7': 'format-gray',
'8': 'format-darkgray',
'9': 'format-blue',
a: 'format-green',
b: 'format-aqua',
c: 'format-red',
d: 'format-lightpurple',
e: 'format-yellow',
f: 'format-white',
l: 'format-bold',
m: 'format-strike',
n: 'format-underline',
o: 'format-italic',
r: 'format-reset',
}
const RE_COLOR_CODE = /§([0-9a-fk-or])/gi
const RE_WARN = /(?:\[|: |(?:\/\s))WARN(?:ING)?(?:]|:|\s)/i
const RE_ERROR_LEVEL = /(?:\[|: |(?:\/\s))(?:ERR(?:OR)?|FATAL|CRITICAL|EMERGENCY|SEVERE)(?:]|:|\s)/i
const RE_DEBUG = /(?:\[|: |(?:\/\s))DEBUG(?:]|:|\s)/i
const RE_TRACE = /(?:\[|: |(?:\/\s))TRACE(?:]|:|\s)/i
const RE_NOTICE = /(?:\[|: |(?:\/\s))NOTICE(?:]|:|\s)/i
const RE_EXCEPTION_NAME = /\b[A-Za-z0-9_$]*(?:Exception|Error|Throwable)\b/
const RE_STACK_AT = /^\s*at\s+/
const RE_CAUSED_BY = /^Caused by:\s*/
const RE_STACK_FRAME = /^(\s*)(at\s+)([^(]+)(\(([^)]+)\))?/
const RE_EXCEPTION_CLASS =
/([A-Za-z0-9_$]+(?:\.[A-Za-z0-9_$]+)*\.)?([A-Za-z0-9_$]*(?:Exception|Error|Throwable))\b/g
const RE_MORE_STACK = /^\s*\.\.\.\s+\d+\s+more\s*$/
const RE_SUPPRESSED = /^\s*Suppressed:\s+/
const RE_PYTHON_TRACEBACK = /^Traceback\s*\(most\s+recent\s+call\s+last\)\s*:\s*$/
const RE_PYTHON_FILE = /^\s*File\s+"[^"]*",\s*line\s+\d+/i
const RE_ERROR_PREFIX = /^(?:\s*\[?\s*)?(?:(?:ERROR?\s*[:;]|FATAL\s*[:;]|CRITICAL\s*[:;]))/i
const RE_FAIL_KEYWORDS =
/\b(?:Failed\s+to|Cannot\s+|Unable\s+to|Could\s+not|Illegal\s+|Invalid\s+|Unsupported\s+|Not\s+found\s*[:;]|Missing\s+)/i
const RE_THREAD_PREFIX = /(\[[^\]]+\/(?:INFO|WARN(?:ING)?|ERROR?|FATAL|DEBUG|TRACE|NOTICE)\])/g
const RE_BRACKET_TAG =
/(?:^|\s)(\[[A-Za-z0-9_\u00a1-\uffff][A-Za-z0-9_\u00a1-\uffff .\-/]{0,32}\])(?=\s|$)/g
const RE_FATAL_LEVEL = /\b(?:FATAL|CRITICAL|EMERGENCY)\b/i
const RE_MOD_LIST_HEADER = /(?:Loading\s+\d*\s*mods?\s*:?|--\s*Mod\s+List\s*--|Mod\s+List:?)\s*$/i
const RE_MOD_ENTRY = /(\s+[-*]\s+)([A-Za-z_][\w.-]*)((?:\s+|@))(\S[\S ]*)$/
const RE_MOD_TABLE_HEADER = /^\|\s*(?:Id|Name|Version|Status)\s*\|/i
const RE_MOD_TABLE_ROW =
/^(\|\s+)([\w.-]+)(\s+\|\s+)(\S[\S ]*?)(\s+\|\s+)([^|]+?)(\s+\|\s+)(\S[\S ]*?)(\s+\|)$/
const RE_FABRIC_LOADER_HEADER = /Loading\s+Minecraft\s+[\d.]+\s+with\s+Fabric\s+Loader\s+\S+/i
const RE_MOD_TREE_ENTRY = /^(\s*)([|\\]--\s+)([A-Za-z_][\w.-]*)((?:\s+|@))(\S[\S ]*)$/
const RE_NEOCRSH_MOD_ROW =
/^(.+?\.jar)\s*\|\s*([^|]+)\s*\|\s*([^|]+)\s*\|\s*([^|]+?)\s*\|\s*(Manifest:\s*\S+)/i
const RE_NEOMOD_LIST_HEADER = /^\s*Name\s+Version\s+\(Mod\s+Id\)\s*$/i
const RE_NEOMOD_LIST_ITEM = /^\s*(?!\[)(.+?)\s+\(([a-z_][\w.-]*)\)$/
const RE_ARGS_LINE = /(?:ModLauncher\s+running:\s*args|JVM\s*Args?:|^\s*--\w)/i
const RE_ARG_FLAG = /(--[A-Za-z_][\w.-]*)/g
class LRUCache<V> {
private capacity: number
private cache: Map<string, V>
constructor(capacity: number) {
this.capacity = capacity
this.cache = new Map()
}
get(key: string): V | undefined {
if (!this.cache.has(key)) return undefined
const value = this.cache.get(key)!
this.cache.delete(key)
this.cache.set(key, value)
return value
}
set(key: string, value: V): void {
if (this.cache.has(key)) {
this.cache.delete(key)
} else if (this.cache.size >= this.capacity) {
const firstKey = this.cache.keys().next().value
if (firstKey !== undefined) this.cache.delete(firstKey)
}
this.cache.set(key, value)
}
clear(): void {
this.cache.clear()
}
}
const formatCache = new LRUCache<string>(2000)
class TrieNode {
children: Map<string, TrieNode> = new Map()
id: number = -1
}
class Trie {
root: TrieNode = new TrieNode()
insert(word: string, id: number): void {
let node = this.root
for (const ch of word) {
let child = node.children.get(ch)
if (!child) {
child = new TrieNode()
node.children.set(ch, child)
}
node = child
}
node.id = id
}
}
function isWordChar(ch: string): boolean {
return (
(ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch === '_'
)
}
function trieCollect(text: string, trie: Trie): Array<{ start: number; end: number; id: number }> {
const results: Array<{ start: number; end: number; id: number }> = []
const len = text.length
for (let i = 0; i < len; i++) {
let node = trie.root
let j = i
while (j < len) {
const ch = text[j]!
const child = node.children.get(ch)
if (!child) break
node = child
j++
if (node.id !== -1) {
const prev = i > 0 ? text[i - 1]! : ' '
const next = j < len ? text[j]! : ' '
if (!isWordChar(prev) && !isWordChar(next)) {
results.push({ start: i, end: j, id: node.id })
}
}
}
}
return results
}
/**
* 用 Trie 一次扫描完成关键词高亮替换(matches 需已按 start 排序,忽略重叠)
*/
function trieHighlight(
text: string,
matches: Array<{ start: number; end: number; id: number }>,
classMap: Record<number, string>,
): string {
if (matches.length === 0) return text
const parts: string[] = []
let lastEnd = 0
for (const m of matches) {
if (m.start < lastEnd) continue
parts.push(text.slice(lastEnd, m.start))
parts.push('<span class="' + classMap[m.id] + '">')
parts.push(text.slice(m.start, m.end))
parts.push('</span>')
lastEnd = m.end
}
parts.push(text.slice(lastEnd))
return parts.join('')
}
const ENV_KEYWORD_MAP: Record<number, string> = {
0: 'level-mod-status-ok',
1: 'level-mod-status-ok',
2: 'level-mod-status-ok',
3: 'level-mod-status-error',
4: 'level-mod-status-error',
5: 'level-mod-status-error',
6: 'level-env-key',
7: 'level-env-key',
}
function buildEnvTrie(): Trie {
const t = new Trie()
t.insert('success', 0)
t.insert('Success', 1)
t.insert('SUCCESS', 2)
t.insert('failed', 3)
t.insert('Failed', 4)
t.insert('FAILED', 5)
t.insert('DLOPEN', 6)
t.insert('dlopen', 7)
return t
}
const envKeywordTrie = buildEnvTrie()
type EngineLevel = 'error' | 'warning' | 'debug' | 'info' | 'fatal' | 'trace'
function buildLevelTrie(): Trie {
const t = new Trie()
let id = 0
const add = (w: string) => t.insert(w, id++)
add('FATAL')
add('CRITICAL')
add('EMERGENCY')
add('SEVERE')
add('ERROR')
add('ERR')
add('WARN')
add('WARNING')
add('DEBUG')
add('TRACE')
add('NOTICE')
add('Exception')
add('Throwable')
add('Caused')
add('Suppressed')
add('Traceback')
add('Failed')
add('Cannot')
add('Unable')
add('Could')
add('Illegal')
add('Invalid')
add('Unsupported')
add('Missing')
add('Stacktrace:')
add('Details:')
return t
}
const levelTrie = buildLevelTrie()
function getLevel(line: string): EngineLevel {
if (RE_PYTHON_TRACEBACK.test(line)) return 'error'
if (RE_PYTHON_FILE.test(line)) return 'error'
if (RE_STACK_AT.test(line)) return 'error'
if (RE_CAUSED_BY.test(line)) return 'error'
if (RE_MORE_STACK.test(line)) return 'error'
if (RE_SUPPRESSED.test(line)) return 'error'
if (RE_EXCEPTION_NAME.test(line)) return 'error'
if (RE_ERROR_PREFIX.test(line)) return 'error'
if (/^\s*(?:Stacktrace|Details):/.test(line)) return 'error'
if (/^-- Affected level --$/.test(line)) return 'error'
const levelHits = trieCollect(line, levelTrie)
if (levelHits.length === 0) return 'info'
if (RE_FATAL_LEVEL.test(line)) return 'fatal'
if (RE_ERROR_LEVEL.test(line)) return 'error'
if (RE_WARN.test(line)) return 'warning'
if (RE_TRACE.test(line)) return 'trace'
if (RE_DEBUG.test(line)) return 'debug'
if (RE_NOTICE.test(line)) return 'info'
if (
/^\s*(?:Failed\s+to|Cannot\s+|Unable\s+to|Could\s+not|Illegal\s+|Invalid\s+|Unsupported\s+|Not\s+found\s*[:;]|Missing\s+)/i.test(
line,
)
) {
return 'error'
}
if (RE_FAIL_KEYWORDS.test(line)) return 'warning'
return 'info'
}
function mapLevel(level: EngineLevel): LogLevel {
if (level === 'warning') return 'warn'
if (level === 'fatal') return 'error'
return level
}
/**
* 合并常用高亮模式为单次扫描:时间戳 | ISO时间戳 | 文件路径 | IPv4 | 行内错误关键词
*/
const RE_COMMON_HIGHLIGHTS =
/(\[\d{1,2}:\d{2}:\d{2}(?:[.,]\d{3,6})?\])|(\b\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[+-]\d{2}:?\d{2}|Z)?\b)|(\/(?:[A-Za-z0-9_.@-]+(?:\/[A-Za-z0-9_.@-]+)+))|(\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(?::\d{2,5})?\b)|(\b(?:Error|Exception|Warning|Fatal|Critical)\b)/g
function applyCommonHighlights(out: string): string {
return out.replace(RE_COMMON_HIGHLIGHTS, (match, ts, isots, fp, ip, errword) => {
if (ts) return '<span class="level-timestamp">' + ts + '</span>'
if (isots) return '<span class="level-timestamp">' + isots + '</span>'
if (fp) return '<span class="level-filepath">' + fp + '</span>'
if (ip) return '<span class="level-dimmed">' + ip + '</span>'
if (errword) return '<span class="level-error-word">' + errword + '</span>'
return match
})
}
function applyStackHighlights(out: string, text: string): string {
if (RE_STACK_AT.test(text)) {
out = out.replace(RE_STACK_FRAME, (_, indent, atKw, className, _paren, location) => {
const locHtml = location ? '<span class="level-stack-location">(' + location + ')</span>' : ''
return (
indent +
'<span class="level-stack-frame">' +
atKw +
'</span><span class="level-stack-class">' +
className +
'</span>' +
locHtml
)
})
}
out = out.replace(
/^(Caused by:\s*)(.+)$/m,
'<span class="level-stack-caused-by">$1</span><span class="level-stack-exception">$2</span>',
)
out = out.replace(
/^(Suppressed:\s*)(.+)$/m,
'<span class="level-stack-caused-by">$1</span><span class="level-stack-exception">$2</span>',
)
if (!RE_STACK_AT.test(text) && RE_EXCEPTION_NAME.test(text)) {
out = out.replace(RE_EXCEPTION_CLASS, '<span class="level-stack-exception">$1$2</span>')
}
if (RE_PYTHON_FILE.test(text)) {
out = out.replace(
/^(\s*File\s+)("[^"]*")(\s*,\s*line\s+)(\d+)/i,
'<span class="level-stack-frame">$1</span><span class="level-stack-location">$2$3</span><span class="level-stack-class">$4</span>',
)
}
if (RE_PYTHON_TRACEBACK.test(text)) {
out = out.replace(
/^(Traceback\s*\()(most recent call last)(\)\s*:\s*)$/,
'<span class="level-stack-frame">$1</span><span class="level-stack-location">$2</span><span class="level-stack-frame">$3</span>',
)
}
return out
}
function applyModHighlights(out: string, text: string): string {
if (RE_MOD_LIST_HEADER.test(text)) {
if (/--\s*Mod\s+List\s*--/i.test(text)) {
out = out.replace(
/^(\s*)(--\s*Mod\s+List\s*--)(\s*)$/,
'$1<span class="level-mod-header">$2</span>$3',
)
} else if (/Mod\s+List:?/i.test(text)) {
out = out.replace(
/^(.*?)(Mod\s+List:?)(.*)$/i,
'$1<span class="level-mod-header">$2</span>$3',
)
} else {
out = out.replace(
/^(.*?)(Loading\s+\d*\s*mods?\s*:?)(.*)$/i,
'$1<span class="level-mod-header">$2</span>$3',
)
}
}
if (RE_MOD_ENTRY.test(text)) {
out = out.replace(
RE_MOD_ENTRY,
'$1<span class="level-mod-id">$2</span>$3<span class="level-mod-version">$4</span>',
)
}
if (RE_MOD_TABLE_HEADER.test(text)) {
out = out.replace(/^(\|)([\s\w|]+)(\|)$/, (_, open, body, close) => {
const cells = body.split('|').map((c: string) => c.trim())
const wrapped = cells
.map((c: string) => '<span class="level-mod-header">' + c + '</span>')
.join(' | ')
return open + ' ' + wrapped + ' ' + close
})
}
if (RE_MOD_TABLE_ROW.test(text)) {
out = out.replace(
RE_MOD_TABLE_ROW,
(_match, open, id, sep1, version, sep2, name, sep3, status, close) => {
const statusClean = status.trim().toLowerCase()
let statusClass = 'level-mod-status'
if (statusClean === 'ok' || statusClean === 'done') {
statusClass += ' level-mod-status-ok'
} else if (
statusClean === 'error' ||
statusClean === 'missing' ||
statusClean === 'outdated'
) {
statusClass += ' level-mod-status-error'
} else if (statusClean === 'warning') {
statusClass += ' level-mod-status-warn'
}
return (
open +
'<span class="level-mod-id">' +
id +
'</span>' +
sep1 +
'<span class="level-mod-version">' +
version +
'</span>' +
sep2 +
'<span class="level-mod-name">' +
name.trim() +
'</span>' +
sep3 +
'<span class="' +
statusClass +
'">' +
status.trim() +
'</span>' +
close
)
},
)
}
if (RE_FABRIC_LOADER_HEADER.test(text)) {
out = out.replace(RE_FABRIC_LOADER_HEADER, '<span class="level-mod-header">$&</span>')
}
if (RE_MOD_TREE_ENTRY.test(text)) {
out = out.replace(
RE_MOD_TREE_ENTRY,
'$1<span class="level-mod-tree">$2</span><span class="level-mod-id">$3</span>$4<span class="level-mod-version">$5</span>',
)
}
if (RE_NEOCRSH_MOD_ROW.test(text)) {
out = out.replace(
RE_NEOCRSH_MOD_ROW,
'<span class="level-mod-dim">$1</span> | <span class="level-mod-name">$2</span> | <span class="level-mod-id">$3</span> | <span class="level-mod-version">$4</span> | <span class="level-mod-status">$5</span>',
)
}
if (RE_NEOMOD_LIST_HEADER.test(text)) {
out = out.replace(RE_NEOMOD_LIST_HEADER, '<span class="level-mod-header">$&</span>')
}
if (RE_NEOMOD_LIST_ITEM.test(text)) {
out = out.replace(
RE_NEOMOD_LIST_ITEM,
'<span class="level-mod-name">$1</span> (<span class="level-mod-id">$2</span>)',
)
}
return out
}
/**
* 单行格式化:text → HTML(span + class)
*/
function formatLine(text: string, mode: LogHighlightMode): string {
const cached = formatCache.get(mode + text)
if (cached !== undefined) return cached
let out = escapeHtml(text)
// 恢复搜索高亮(搜索层注入的 <mark> 已转义)
out = out.replace(RE_MARK_OPEN, '<mark>').replace(RE_MARK_CLOSE, '</mark>')
// Minecraft § 颜色码
out = out.replace(RE_COLOR_CODE, (_match, code) => {
const cls = COLOR_STYLE_MAP[code.toLowerCase() as keyof typeof COLOR_STYLE_MAP]
return cls ? '<span class="' + cls + '">' : _match
})
if (mode === 'full') {
out = applyStackHighlights(out, text)
out = applyModHighlights(out, text)
}
// [Thread/LEVEL] 前缀
out = out.replace(RE_THREAD_PREFIX, (match) => {
const sepIdx = match.lastIndexOf('/')
if (sepIdx === -1) return match
const thread = match.slice(0, sepIdx) + '/'
const level = match.slice(sepIdx + 1, -1)
const levelLower = level.toLowerCase()
const levelClass =
levelLower === 'error' || levelLower === 'fatal'
? 'level-error-word'
: levelLower === 'warn' || levelLower === 'warning'
? 'level-warning-tag'
: 'level-info-prefix'
return (
'<span class="level-thread">' +
escapeHtml(thread) +
'</span><span class="' +
levelClass +
'">' +
escapeHtml(level) +
'</span>]'
)
})
// [插件名] 标记(保留前导空格,LogShare 原版会吞掉)
out = out.replace(RE_BRACKET_TAG, (match) => {
const leading = /^\s*/.exec(match)?.[0] ?? ''
const tag = match.trim()
if (tag.length > 40) return match
return leading + '<span class="level-plugin">' + escapeHtml(tag) + '</span>'
})
// 一次扫描完成:时间戳 / 文件路径 / IPv4 / 错误关键词
out = applyCommonHighlights(out)
// 分隔线 / 环境变量 / DLOPEN / 启动参数
out = out.replace(
/^(={5,})\s*([^=\n]*)\s*(={5,})$/m,
'<span class="level-section-header">$&</span>',
)
out = out.replace(
/^(\s*)((?:Added\s+)?[Ee]nv\s*:\s*)/m,
'$1<span class="level-env-key">$2</span>',
)
const envMatches = trieCollect(out, envKeywordTrie)
if (envMatches.length > 0) {
envMatches.sort((a, b) => a.start - b.start)
out = trieHighlight(out, envMatches, ENV_KEYWORD_MAP)
}
out = out.replace(
/(Environment:\s*)(Environment\[[^\]]*\])/g,
'$1<span class="level-env-key">$2</span>',
)
if (RE_ARGS_LINE.test(text)) {
out = out.replace(RE_ARG_FLAG, '<span class="level-arg-flag">$1</span>')
out = out.replace(/(^|\s)(-D[A-Za-z_][\w.]*)/gm, '$1<span class="level-arg-flag">$2</span>')
}
out = out.replace(/\b(Stacktrace:|Details:)/g, '<span class="level-section-marker">$1</span>')
out = out.replace(/^-- Affected level --$/m, '<span class="level-section-header">$&</span>')
formatCache.set(mode + text, out)
return out
}
function escapeHtml(text: string): string {
return text.replace(RE_HTML_ESCAPE, (ch) => HTML_ESCAPE_MAP[ch] || ch)
}
/**
* 行内级别整行着色(line-only 模式 / 降级兜底)
*/
export function colorizeByLevel(level: LogLevel | null, text: string): string {
const levelClass =
level === 'error'
? 'level-error'
: level === 'warn'
? 'level-warning'
: level === 'debug' || level === 'trace'
? 'level-debug'
: 'level-info'
return '<span class="level ' + levelClass + '">' + escapeHtml(text) + '</span>'
}
/**
* 单行高亮入口:text → HTML
*
* 内置行长度保险:超长行跳过行内 token(只做级别检测),超硬上限完全不解析。
*/
export function highlightLine(text: string, mode: LogHighlightMode = 'full'): string {
if (mode === 'raw') return escapeHtml(text)
if (text.length > LONG_LINE_HARD_LIMIT) return escapeHtml(text)
if (mode === 'line-only' || text.length > LONG_LINE_LIMIT) {
return colorizeByLevel(mapLevel(getLevel(text)), text)
}
return formatLine(text, mode)
}
/**
* 级别检测(LogShare 语义,供 line-only 模式与统计使用)
*/
export function detectStrictLevel(text: string): LogLevel | null {
if (text.length > LONG_LINE_HARD_LIMIT) return null
return mapLevel(getLevel(text))
}
/**
* 清空高亮缓存(一般不需要,测试用)
*/
export function clearHighlightCache(): void {
formatCache.clear()
}
@@ -0,0 +1,14 @@
import type { LogLevel } from '../types'
const ERROR_TRIGGERS = ['/ERROR', 'Exception:', ':?]', 'Error', '[thread', '\tat']
export function detectLogLevel(lineText: string): LogLevel | null {
if (lineText.includes('/INFO') || lineText.includes('[System] [CHAT]')) return 'info'
if (lineText.includes('/WARN')) return 'warn'
if (lineText.includes('/DEBUG')) return 'debug'
if (lineText.includes('/TRACE')) return 'trace'
for (const trigger of ERROR_TRIGGERS) {
if (lineText.includes(trigger)) return 'error'
}
return null
}
@@ -0,0 +1,5 @@
export { default as JLineCommandInput } from './components/JLineCommandInput.vue'
export * from './jline'
export { default as ConsolePageLayout } from './layout.vue'
export * from './providers'
export * from './types'
@@ -0,0 +1,118 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
applyJLineCandidate,
createJLineCandidateInsertion,
createJLineLineReplacementSequence,
extractJLinePrompt,
jlineKeySequence,
JLINE_KEY_SEQUENCES,
type JLineRow,
parseJLineCandidateConfirmation,
parseJLineCandidates,
replaceJLineSelection,
} from './jline.ts'
const row = (index: number, text: string, wrapped = false, inverse = ''): JLineRow => ({
index,
wrapped,
cells: [...text].map((character) => ({
text: character,
width: 1,
style: { inverse: inverse.includes(character) },
})),
})
test('extracts wrapped prompt input and cursor position', () => {
const rows = [row(2, '> say a very '), row(3, 'long command', true)]
assert.deepEqual(extractJLinePrompt(rows, 3, 4), {
value: 'say a very long command',
cursor: 15,
rows,
})
})
test('rejects a line that is not a JLine prompt', () => {
assert.equal(extractJLinePrompt([row(1, 'server output')], 1, 3), null)
})
test('preserves spaces entered at the end of the command', () => {
const rows = [row(1, '> say ')]
assert.equal(extractJLinePrompt(rows, 1, 6)?.value, 'say ')
})
test('builds a direct edit that replaces the candidate token without executing it', () => {
assert.deepEqual(
createJLineCandidateInsertion({ value: 'gamerule advance_t', cursor: 18 }, 'advance_weather'),
{ deleteBefore: 9, deleteAfter: 0, text: 'advance_weather ' },
)
assert.deepEqual(createJLineCandidateInsertion({ value: 'give stne 1', cursor: 7 }, 'stone'), {
deleteBefore: 2,
deleteAfter: 2,
text: 'stone',
})
assert.deepEqual(applyJLineCandidate({ value: 'gamerule keep_inventory ', cursor: 24 }, 'true'), {
value: 'gamerule keep_inventory true ',
cursor: 29,
})
})
test('replaces selections and creates a deterministic JLine redraw sequence', () => {
const edit = replaceJLineSelection({ value: 'gamerule keep_inventory true' }, 0, 28, '')
assert.deepEqual(edit, { value: '', cursor: 0 })
assert.equal(
createJLineLineReplacementSequence('give stne 1', { value: 'give stone 1', cursor: 10 }),
`\x01\x0bgive stone 1${JLINE_KEY_SEQUENCES.ArrowLeft.repeat(2)}`,
)
assert.ok(
createJLineLineReplacementSequence('g', { value: 'gamemode ', cursor: 8 }, true).endsWith(
'\x1bOD',
),
)
})
test('splits candidate columns and preserves terminal coordinates', () => {
const candidates = parseJLineCandidates(
[row(4, '> give @p '), row(5, 'stone stick', false, 't')],
4,
)
assert.deepEqual(
candidates.map(({ text, row, column, selected }) => ({ text, row, column, selected })),
[
{ text: 'stone', row: 5, column: 0, selected: true },
{ text: 'stick', row: 5, column: 7, selected: true },
],
)
})
test('rejects truncated candidate menus and defines terminal key bytes', () => {
assert.deepEqual(parseJLineCandidates([row(1, '> g'), row(2, 'one --More--')], 1), [])
assert.equal(JLINE_KEY_SEQUENCES.ArrowLeft, '\x1b[D')
assert.equal(jlineKeySequence('ArrowLeft', true), '\x1bOD')
assert.equal(JLINE_KEY_SEQUENCES.Backspace, '\x7f')
})
test('detects Forge candidate confirmation prompts and their expanded row count', () => {
assert.deepEqual(
parseJLineCandidateConfirmation([
row(1, 'Forge: do you wish to see all 51 '),
row(2, 'possibilities (13 lines)?', true),
]),
{ lineCount: 13 },
)
assert.deepEqual(
parseJLineCandidateConfirmation([
row(1, '[Server thread/INFO] Forge: display all 2048 possibilities (512 lines)?'),
]),
{ lineCount: 512 },
)
assert.equal(
parseJLineCandidateConfirmation([
row(1, 'Forge: display all 10 possibilities (2 lines)?'),
row(2, '> gamerule'),
]),
null,
)
assert.equal(parseJLineCandidateConfirmation([row(1, '> gamerule')]), null)
})
@@ -0,0 +1,229 @@
export interface JLineCellStyle {
foreground?: string
background?: string
bold?: boolean
italic?: boolean
underline?: boolean
dim?: boolean
inverse?: boolean
}
export interface JLineCell {
text: string
width: number
style: JLineCellStyle
}
export interface JLineRow {
index: number
wrapped: boolean
cells: JLineCell[]
}
export interface JLinePrompt {
value: string
cursor: number
rows: JLineRow[]
}
export interface JLineCandidate {
text: string
row: number
column: number
selected: boolean
style: JLineCellStyle
}
export interface JLineCandidateConfirmation {
lineCount: number
}
export interface JLineCandidateInsertion {
deleteBefore: number
deleteAfter: number
text: string
}
export interface JLineInputEdit {
value: string
cursor: number
}
export const JLINE_KEY_SEQUENCES: Record<string, string> = {
Tab: '\t',
ArrowUp: '\x1b[A',
ArrowDown: '\x1b[B',
ArrowRight: '\x1b[C',
ArrowLeft: '\x1b[D',
Home: '\x1b[H',
End: '\x1b[F',
Delete: '\x1b[3~',
Backspace: '\x7f',
Enter: '\r',
Escape: '\x1b',
}
export function jlineKeySequence(key: string, applicationCursorKeys = false): string | undefined {
if (applicationCursorKeys) {
const applicationSequences: Record<string, string> = {
ArrowUp: '\x1bOA',
ArrowDown: '\x1bOB',
ArrowRight: '\x1bOC',
ArrowLeft: '\x1bOD',
Home: '\x1bOH',
End: '\x1bOF',
}
if (applicationSequences[key]) return applicationSequences[key]
}
return JLINE_KEY_SEQUENCES[key]
}
export function extractJLinePrompt(
rows: JLineRow[],
cursorRow: number,
cursorColumn: number,
): JLinePrompt | null {
const cursorIndex = rows.findIndex((row) => row.index === cursorRow)
if (cursorIndex < 0) return null
let start = cursorIndex
while (start > 0 && rows[start]?.wrapped) start--
const logicalRows = rows.slice(start, cursorIndex + 1)
const logicalText = logicalRows.map(rowText).join('')
if (!logicalText.startsWith('> ')) return null
const charactersBeforeCursor =
logicalRows
.slice(0, -1)
.reduce(
(total, row) => total + row.cells.reduce((sum, cell) => sum + cell.text.length, 0),
0,
) +
(logicalRows.at(-1)?.cells ?? [])
.slice(0, cursorColumn)
.reduce((sum, cell) => sum + cell.text.length, 0)
const cursor = Math.max(0, charactersBeforeCursor - 2)
const commandText = logicalText.slice(2)
const valueEnd = Math.max(cursor, commandText.trimEnd().length)
return {
value: commandText.slice(0, valueEnd),
cursor,
rows: logicalRows,
}
}
export function createJLineCandidateInsertion(
prompt: Pick<JLinePrompt, 'value' | 'cursor'>,
candidateText: string,
): JLineCandidateInsertion {
const cursor = Math.max(0, Math.min(prompt.cursor, prompt.value.length))
let start = cursor
while (start > 0 && !/\s/.test(prompt.value[start - 1]!)) start--
let end = cursor
while (end < prompt.value.length && !/\s/.test(prompt.value[end]!)) end++
const needsTrailingSpace = end === prompt.value.length && !candidateText.endsWith(' ')
return {
deleteBefore: cursor - start,
deleteAfter: end - cursor,
text: `${candidateText}${needsTrailingSpace ? ' ' : ''}`,
}
}
export function applyJLineCandidate(
prompt: Pick<JLinePrompt, 'value' | 'cursor'>,
candidateText: string,
): JLineInputEdit {
const insertion = createJLineCandidateInsertion(prompt, candidateText)
const start = prompt.cursor - insertion.deleteBefore
const end = prompt.cursor + insertion.deleteAfter
return {
value: `${prompt.value.slice(0, start)}${insertion.text}${prompt.value.slice(end)}`,
cursor: start + insertion.text.length,
}
}
export function replaceJLineSelection(
prompt: Pick<JLinePrompt, 'value'>,
selectionStart: number,
selectionEnd: number,
replacement: string,
): JLineInputEdit {
const start = Math.max(0, Math.min(selectionStart, selectionEnd, prompt.value.length))
const end = Math.max(start, Math.min(Math.max(selectionStart, selectionEnd), prompt.value.length))
return {
value: `${prompt.value.slice(0, start)}${replacement}${prompt.value.slice(end)}`,
cursor: start + replacement.length,
}
}
export function createJLineLineReplacementSequence(
currentValue: string,
next: JLineInputEdit,
applicationCursorKeys = false,
): string {
const cursor = Math.max(0, Math.min(next.cursor, next.value.length))
return (
(currentValue ? '\x01\x0b' : '') +
next.value +
jlineKeySequence('ArrowLeft', applicationCursorKeys)!.repeat(next.value.length - cursor)
)
}
export function parseJLineCandidates(rows: JLineRow[], cursorRow: number): JLineCandidate[] {
const candidates: JLineCandidate[] = []
for (const row of rows) {
if (row.index <= cursorRow) continue
const text = rowText(row)
if (!text.trim()) continue
if (/--more--|\.\.\.$/i.test(text.trim())) return []
let start = 0
while (start < row.cells.length) {
while (start < row.cells.length && !row.cells[start]?.text.trim()) start++
if (start >= row.cells.length) break
let end = start
let blankRun = 0
while (end < row.cells.length) {
if (row.cells[end]?.text.trim()) {
blankRun = 0
} else {
blankRun++
if (blankRun >= 2) break
}
end++
}
const contentEnd = Math.max(start, end - blankRun + 1)
const candidateText = row.cells
.slice(start, contentEnd)
.map((cell) => cell.text)
.join('')
.trim()
if (candidateText) {
const first = row.cells[start]!
candidates.push({
text: candidateText,
row: row.index,
column: start,
selected: row.cells.slice(start, contentEnd).some((cell) => cell.style.inverse),
style: first.style,
})
}
start = end + 1
}
}
return candidates
}
export function parseJLineCandidateConfirmation(
rows: JLineRow[],
): JLineCandidateConfirmation | null {
const text = rows.map(rowText).join(' ').replace(/\s+/g, ' ').trim()
const match = text.match(
/(?:do you wish to see all|display all)\s+\d+\s+possibilit(?:y|ies)\b.*?\((\d+)\s+lines?\)\?\s*$/i,
)
if (!match) return null
const lineCount = Number.parseInt(match[1] ?? '', 10)
return { lineCount: Number.isFinite(lineCount) ? lineCount : 0 }
}
function rowText(row: JLineRow): string {
return row.cells.map((cell) => cell.text).join('')
}
@@ -0,0 +1,913 @@
<template>
<div
class="flex min-h-0 flex-1 flex-col gap-4"
:class="
isFullscreen ? `fixed inset-0 z-[15] bg-surface-1 p-6 py-8 ${isApp ? 'pt-12' : ''}` : ''
"
>
<div
v-if="
(ctx.localCrashAnalysis?.value?.findings.length ||
ctx.localCrashAnalysis?.value?.mod_changes.length) &&
!isFullscreen
"
class="flex flex-col gap-2"
>
<CollapsibleAdmonition type="critical" :header="localCrashHeader" :items="localCrashItems" />
<div class="flex justify-end">
<ButtonStyled type="outlined">
<button :disabled="exportingCrashContext" @click="handleExportCrashContext">
<DownloadIcon />
{{ formatMessage(consoleMessages.exportCrashContext) }}
</button>
</ButtonStyled>
</div>
</div>
<CollapsibleAdmonition
v-if="ctx.crashAnalysis?.value && !isFullscreen"
type="critical"
:header="crashHeader"
:items="crashItems"
dismissible
@dismiss="ctx.onDismissCrash?.()"
/>
<div class="flex items-center gap-2">
<StyledInput
v-model="searchQuery"
:icon="SearchIcon"
:placeholder="formatMessage(consoleMessages.searchLogs)"
wrapper-class="flex-1"
input-class="!h-10"
clearable
/>
<div v-if="ctx.logSources?.value && ctx.activeLogSourceIndex" class="w-[220px]">
<Combobox
:model-value="ctx.activeLogSourceIndex.value"
:options="logSourceOptions"
@update:model-value="(v) => (ctx.activeLogSourceIndex!.value = v)"
/>
</div>
</div>
<div class="flex items-center gap-2">
<ConsoleFilterPills v-model="activeFilters" @toggle="handleFilterToggle" />
<div class="ml-auto flex items-center gap-2">
<ButtonStyled type="transparent" :highlighted="wrapLines">
<button
:aria-pressed="wrapLines"
:title="formatMessage(consoleMessages.toggleWrap)"
@click="wrapLines = !wrapLines"
>
<WrapTextIcon />
{{ formatMessage(consoleMessages.wrapLabel) }}
</button>
</ButtonStyled>
<div class="w-28">
<Combobox
:model-value="logFontSize"
:options="fontSizeOptions"
@update:model-value="(v) => (logFontSize = v)"
/>
</div>
<ConsoleActionButtons
:show-clear="isLiveSource"
:has-logs="hasLogs"
:share-disabled="resolvedShareDisabled"
:sharing="isSharing"
:fullscreen="isFullscreen"
:clear-disabled="resolvedClearDisabled"
:clear-disabled-tooltip="resolvedClearDisabledTooltip"
:show-delete="showDelete"
:delete-disabled="resolvedDeleteDisabled"
:delete-disabled-tooltip="resolvedDeleteDisabledTooltip"
@clear="handleClear"
@share="handleShare"
@toggle-fullscreen="toggleFullscreen"
@delete="handleDelete"
/>
</div>
</div>
<div class="relative min-h-0 flex-1 overflow-hidden rounded-[20px]">
<LogViewport
ref="viewportRef"
class="h-full"
:lines="filteredLines"
:search-query="searchQuery"
:wrap="wrapLines"
:font-size="logFontSize"
:empty-state-type="ctx.emptyStateType"
/>
<Transition name="terminal-loading-fade">
<div
v-if="resolvedLoading"
class="pointer-events-none absolute inset-0 z-20 flex items-center justify-center bg-surface-3/80 px-8"
aria-hidden="true"
>
<LoadingIndicator />
</div>
</Transition>
</div>
<slot
v-if="showCommandInput && customCommandInput"
name="command-input"
:disabled="commandDisabled"
:placeholder="commandPlaceholder"
:submit-command="submitProvidedCommand"
/>
<StyledInput
v-else-if="showCommandInput"
v-model="commandInput"
v-tooltip="commandDisabled ? commandDisabledTooltip : undefined"
:icon="TerminalSquareIcon"
:placeholder="commandPlaceholder"
:disabled="commandDisabled"
wrapper-class="w-full"
input-class="!h-9"
autocomplete="off"
:spellcheck="false"
@keydown.enter="submitCommand"
/>
</div>
<ShareModal
ref="shareModal"
:header="formatMessage(consoleMessages.shareLogs)"
link
:social-buttons="false"
/>
<NewModal
ref="deleteModal"
:header="formatMessage(consoleMessages.deleteLogFile)"
:fade="'danger'"
max-width="500px"
>
<div class="flex flex-col gap-6">
<Admonition type="critical" :header="formatMessage(consoleMessages.deleteIrreversible)">
{{ formatMessage(consoleMessages.deleteConfirmation) }}
</Admonition>
</div>
<template #actions>
<div class="flex justify-end gap-2">
<ButtonStyled type="outlined">
<button @click="deleteModal?.hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="red">
<button :disabled="isDeleting" @click="confirmDelete">
<TrashIcon />
{{ formatMessage(commonMessages.deleteLabel) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import {
DownloadIcon,
SearchIcon,
TerminalSquareIcon,
TrashIcon,
WrapTextIcon,
XIcon,
} from '@modrinth/assets'
import { computed, isRef, onBeforeUnmount, ref } from 'vue'
import Admonition from '#ui/components/base/Admonition.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import type { CollapsibleAdmonitionItem } from '#ui/components/base/CollapsibleAdmonition.vue'
import CollapsibleAdmonition from '#ui/components/base/CollapsibleAdmonition.vue'
import type { ComboboxOption } from '#ui/components/base/Combobox.vue'
import Combobox from '#ui/components/base/Combobox.vue'
import LoadingIndicator from '#ui/components/base/LoadingIndicator.vue'
import StyledInput from '#ui/components/base/StyledInput.vue'
import NewModal from '#ui/components/modal/NewModal.vue'
import ShareModal from '#ui/components/modal/ShareModal.vue'
import { useVIntl } from '#ui/composables/i18n'
import { injectModrinthClient } from '#ui/providers'
import { injectModalBehavior } from '#ui/providers/modal-behavior'
import { injectPageContext } from '#ui/providers/page-context'
import { injectNotificationManager } from '#ui/providers/web-notifications.ts'
import { commonMessages } from '#ui/utils/common-messages'
import { shareLogs } from '#ui/utils/log-share'
import ConsoleActionButtons from './components/ConsoleActionButtons.vue'
import ConsoleFilterPills from './components/ConsoleFilterPills.vue'
import LogViewport from './components/LogViewport.vue'
import { useConsoleFilters } from './composables'
import { consoleMessages, localFindingMessages } from './messages'
import { injectConsoleManager } from './providers'
import type { LogLevel, LogLine } from './types'
const ctx = injectConsoleManager()
defineProps<{
customCommandInput?: boolean
}>()
const client = injectModrinthClient()
const modalBehavior = injectModalBehavior()
const pageContext = injectPageContext(null)
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
const localFindingCopy = {
jvm_arguments: {
title: localFindingMessages.jvmArgumentsTitle,
action: localFindingMessages.jvmArgumentsAction,
},
out_of_memory: {
title: localFindingMessages.outOfMemoryTitle,
action: localFindingMessages.outOfMemoryAction,
},
opengl_unsupported: {
title: localFindingMessages.openglUnsupportedTitle,
action: localFindingMessages.openglUnsupportedAction,
},
pixel_format: {
title: localFindingMessages.pixelFormatTitle,
action: localFindingMessages.pixelFormatAction,
},
openj9: {
title: localFindingMessages.openj9Title,
action: localFindingMessages.openj9Action,
},
java_too_new: {
title: localFindingMessages.javaTooNewTitle,
action: localFindingMessages.javaTooNewAction,
},
java_incompatible: {
title: localFindingMessages.javaIncompatibleTitle,
action: localFindingMessages.javaIncompatibleAction,
},
jdk_runtime: {
title: localFindingMessages.jdkRuntimeTitle,
action: localFindingMessages.jdkRuntimeAction,
},
java_32bit: {
title: localFindingMessages.java32BitTitle,
action: localFindingMessages.java32BitAction,
},
java_11_required: {
title: localFindingMessages.java11RequiredTitle,
action: localFindingMessages.java11RequiredAction,
},
forge_incomplete: {
title: localFindingMessages.forgeIncompleteTitle,
action: localFindingMessages.forgeIncompleteAction,
},
duplicate_mod: {
title: localFindingMessages.duplicateModTitle,
action: localFindingMessages.duplicateModAction,
},
incompatible_mods: {
title: localFindingMessages.incompatibleModsTitle,
action: localFindingMessages.incompatibleModsAction,
},
missing_dependency: {
title: localFindingMessages.missingDependencyTitle,
action: localFindingMessages.missingDependencyAction,
},
disk_space: {
title: localFindingMessages.diskSpaceTitle,
action: localFindingMessages.diskSpaceAction,
},
file_in_use: {
title: localFindingMessages.fileInUseTitle,
action: localFindingMessages.fileInUseAction,
},
connector_incompatible_fabric_mods: {
title: localFindingMessages.connectorIncompatibleFabricModsTitle,
action: localFindingMessages.connectorIncompatibleFabricModsAction,
},
missing_embeddium: {
title: localFindingMessages.missingEmbeddiumTitle,
action: localFindingMessages.missingEmbeddiumAction,
},
missing_indium: {
title: localFindingMessages.missingIndiumTitle,
action: localFindingMessages.missingIndiumAction,
},
mod_id_limit: {
title: localFindingMessages.modIdLimitTitle,
action: localFindingMessages.modIdLimitAction,
},
forge_error: {
title: localFindingMessages.forgeErrorTitle,
action: localFindingMessages.forgeErrorAction,
},
mod_loader_error: {
title: localFindingMessages.modLoaderErrorTitle,
action: localFindingMessages.modLoaderErrorAction,
},
mod_loader_failure: {
title: localFindingMessages.modLoaderFailureTitle,
action: localFindingMessages.modLoaderFailureAction,
},
stack_analysis: {
title: localFindingMessages.stackAnalysisTitle,
action: localFindingMessages.stackAnalysisAction,
},
short_output: {
title: localFindingMessages.shortOutputTitle,
action: localFindingMessages.shortOutputAction,
},
extracted_mod: {
title: localFindingMessages.extractedModTitle,
action: localFindingMessages.extractedModAction,
},
mixin_bootstrap: {
title: localFindingMessages.mixinBootstrapTitle,
action: localFindingMessages.mixinBootstrapAction,
},
mixin_failure: {
title: localFindingMessages.mixinFailureTitle,
action: localFindingMessages.mixinFailureAction,
},
fabric_solution: {
title: localFindingMessages.fabricSolutionTitle,
action: localFindingMessages.fabricSolutionAction,
},
mod_config: {
title: localFindingMessages.modConfigTitle,
action: localFindingMessages.modConfigAction,
},
optifine_incompatible: {
title: localFindingMessages.optifineIncompatibleTitle,
action: localFindingMessages.optifineIncompatibleAction,
},
resource_pack: {
title: localFindingMessages.resourcePackTitle,
action: localFindingMessages.resourcePackAction,
},
large_resource_pack: {
title: localFindingMessages.largeResourcePackTitle,
action: localFindingMessages.largeResourcePackAction,
},
shaders_optifine: {
title: localFindingMessages.shadersOptifineTitle,
action: localFindingMessages.shadersOptifineAction,
},
multiple_forge_versions: {
title: localFindingMessages.multipleForgeVersionsTitle,
action: localFindingMessages.multipleForgeVersionsAction,
},
forge_java_incompatible: {
title: localFindingMessages.forgeJavaIncompatibleTitle,
action: localFindingMessages.forgeJavaIncompatibleAction,
},
content_verification: {
title: localFindingMessages.contentVerificationTitle,
action: localFindingMessages.contentVerificationAction,
},
optifine_world: {
title: localFindingMessages.optifineWorldTitle,
action: localFindingMessages.optifineWorldAction,
},
nightconfig_bug: {
title: localFindingMessages.nightconfigBugTitle,
action: localFindingMessages.nightconfigBugAction,
},
mod_filename: {
title: localFindingMessages.modFilenameTitle,
action: localFindingMessages.modFilenameAction,
},
definite_mod: {
title: localFindingMessages.definiteModTitle,
action: localFindingMessages.definiteModAction,
},
definite_mod_fabric: {
title: localFindingMessages.definiteModFabricTitle,
action: localFindingMessages.definiteModFabricAction,
},
intel_driver: {
title: localFindingMessages.intelDriverTitle,
action: localFindingMessages.intelDriverAction,
},
amd_driver: {
title: localFindingMessages.amdDriverTitle,
action: localFindingMessages.amdDriverAction,
},
nvidia_driver: {
title: localFindingMessages.nvidiaDriverTitle,
action: localFindingMessages.nvidiaDriverAction,
},
manual_debug_crash: {
title: localFindingMessages.manualDebugCrashTitle,
action: localFindingMessages.manualDebugCrashAction,
},
suspected_mod: {
title: localFindingMessages.suspectedModTitle,
action: localFindingMessages.suspectedModAction,
},
mod_initialization: {
title: localFindingMessages.modInitializationTitle,
action: localFindingMessages.modInitializationAction,
},
specific_block: {
title: localFindingMessages.specificBlockTitle,
action: localFindingMessages.specificBlockAction,
},
specific_entity: {
title: localFindingMessages.specificEntityTitle,
action: localFindingMessages.specificEntityAction,
},
hs_err_al_lib_alc_cleanup: {
title: consoleMessages.knownSignatureTitle,
action: consoleMessages.knownSignatureAction,
},
hs_err_glfw_driver: {
title: consoleMessages.knownSignatureTitle,
action: consoleMessages.knownSignatureAction,
},
hs_err_intel_driver: {
title: consoleMessages.knownSignatureTitle,
action: consoleMessages.knownSignatureAction,
},
hs_err_java_too_high: {
title: consoleMessages.knownSignatureTitle,
action: consoleMessages.knownSignatureAction,
},
hs_err_jvm: {
title: consoleMessages.knownSignatureTitle,
action: consoleMessages.knownSignatureAction,
},
hs_err_openal: {
title: consoleMessages.knownSignatureTitle,
action: consoleMessages.knownSignatureAction,
},
hs_err_macos_shader: {
title: consoleMessages.knownSignatureTitle,
action: consoleMessages.knownSignatureAction,
},
hs_err_apple_jdk: {
title: consoleMessages.knownSignatureTitle,
action: consoleMessages.knownSignatureAction,
},
hs_err_gpu_driver: {
title: consoleMessages.knownSignatureTitle,
action: consoleMessages.knownSignatureAction,
},
create_addons: {
title: consoleMessages.knownSignatureTitle,
action: consoleMessages.knownSignatureAction,
},
ctov_missing_lithostitched: {
title: consoleMessages.knownSignatureTitle,
action: consoleMessages.knownSignatureAction,
},
curseforge_corrupted: {
title: consoleMessages.knownSignatureTitle,
action: consoleMessages.knownSignatureAction,
},
epic_fight_addons: {
title: consoleMessages.knownSignatureTitle,
action: consoleMessages.knownSignatureAction,
},
feature_order_cycle: {
title: consoleMessages.knownSignatureTitle,
action: consoleMessages.knownSignatureAction,
},
ferrite_core_neighbor_table: {
title: consoleMessages.knownSignatureTitle,
action: consoleMessages.knownSignatureAction,
},
geckolib_oculus_compat: {
title: consoleMessages.knownSignatureTitle,
action: consoleMessages.knownSignatureAction,
},
groovy_mod_loader_ipv6: {
title: consoleMessages.knownSignatureTitle,
action: consoleMessages.knownSignatureAction,
},
kubejs_datapack: {
title: consoleMessages.knownSignatureTitle,
action: consoleMessages.knownSignatureAction,
},
language_provider_mismatch: {
title: consoleMessages.knownSignatureTitle,
action: consoleMessages.knownSignatureAction,
},
legacy_too_many_ids: {
title: consoleMessages.knownSignatureTitle,
action: consoleMessages.knownSignatureAction,
},
module_resolution: {
title: consoleMessages.knownSignatureTitle,
action: consoleMessages.knownSignatureAction,
},
modernfix_watchdog: {
title: consoleMessages.knownSignatureTitle,
action: consoleMessages.knownSignatureAction,
},
neoforge_1_20_1: {
title: consoleMessages.knownSignatureTitle,
action: consoleMessages.knownSignatureAction,
},
resource_location: {
title: consoleMessages.knownSignatureTitle,
action: consoleMessages.knownSignatureAction,
},
rubidium_deprecated: {
title: consoleMessages.knownSignatureTitle,
action: consoleMessages.knownSignatureAction,
},
server_config_corrupted: {
title: consoleMessages.knownSignatureTitle,
action: consoleMessages.knownSignatureAction,
},
version_1_21: {
title: consoleMessages.knownSignatureTitle,
action: consoleMessages.knownSignatureAction,
},
used_by_another_process: {
title: consoleMessages.knownSignatureTitle,
action: consoleMessages.knownSignatureAction,
},
windows_closed_process: {
title: consoleMessages.knownSignatureTitle,
action: consoleMessages.knownSignatureAction,
},
} as const
const localCrashHeader = computed(() => {
const analysis = ctx.localCrashAnalysis?.value
const findings = analysis?.findings.length ?? 0
const sources = analysis?.sources.length ?? 0
return formatMessage(consoleMessages.localCrashHeader, { findings, sources })
})
const localCrashItems = computed<CollapsibleAdmonitionItem[]>(() => {
const analysis = ctx.localCrashAnalysis?.value
if (!analysis) return []
const items = analysis.findings.map((finding) => {
const copy = localFindingCopy[finding.id as keyof typeof localFindingCopy]
const title = copy
? formatMessage(copy.title)
: formatMessage(consoleMessages.fallbackFindingTitle, { finding: finding.id })
const action = copy
? formatMessage(copy.action)
: formatMessage(consoleMessages.fallbackFindingAction)
const evidence = finding.evidence.map((item) => `${item.filename}:${item.line} - ${item.text}`)
const mods = analysis.mods.map((mod) => {
const identity = mod.name || mod.id || mod.file_name
const modId = mod.id && mod.id !== identity ? ` (${mod.id})` : ''
return formatMessage(consoleMessages.matchedMod, {
identity,
modId,
fileName: mod.file_name,
})
})
return {
title,
descriptions: [action, ...mods, ...evidence],
}
})
if (analysis.mod_changes.length > 0) {
const counts = analysis.mod_change_counts
const changeKindMessages = {
added: consoleMessages.modChangeAdded,
removed: consoleMessages.modChangeRemoved,
modified: consoleMessages.modChangeModified,
} as const
items.push({
title: formatMessage(consoleMessages.modChangesTitle),
descriptions: [
formatMessage(consoleMessages.modChangesSummary, counts),
...analysis.mod_changes.map((change) =>
formatMessage(consoleMessages.modChange, {
kind: formatMessage(changeKindMessages[change.kind]),
filename: change.filename,
}),
),
],
})
}
if (analysis.windows_events.length > 0) {
items.push({
title: formatMessage(consoleMessages.windowsEventsTitle),
descriptions: analysis.windows_events.map(
(event) => `Event ${event.event_id} · ${event.provider}: ${event.message}`,
),
})
}
return items
})
const crashHeader = computed(() => {
const analysis = ctx.crashAnalysis?.value
const findings = analysis?.findings.length ?? 0
return formatMessage(consoleMessages.crashHeader, { findings })
})
const crashItems = computed<CollapsibleAdmonitionItem[]>(() => {
const analysis = ctx.crashAnalysis?.value
if (!analysis) return []
return analysis.findings.map((finding) => {
const copy = localFindingCopy[finding.id as keyof typeof localFindingCopy]
const title = copy
? formatMessage(copy.title)
: formatMessage(consoleMessages.fallbackFindingTitle, { finding: finding.id })
const action = copy
? formatMessage(copy.action)
: formatMessage(consoleMessages.fallbackFindingAction)
const evidence = finding.evidence.map((item) => `${item.filename}:${item.line} - ${item.text}`)
const mods = analysis.mods.map((mod) => {
const identity = mod.name || mod.id || mod.file_name
const modId = mod.id && mod.id !== identity ? ` (${mod.id})` : ''
return formatMessage(consoleMessages.matchedMod, {
identity,
modId,
fileName: mod.file_name,
})
})
return {
title,
descriptions: [action, ...mods, ...evidence],
}
})
})
const viewportRef = ref<InstanceType<typeof LogViewport> | null>(null)
const shareModal = ref<InstanceType<typeof ShareModal> | null>(null)
const deleteModal = ref<InstanceType<typeof NewModal> | null>(null)
const isDeleting = ref(false)
const exportingCrashContext = ref(false)
const searchQuery = ref('')
const wrapLines = ref(false)
const logFontSize = ref(12)
const FONT_SIZES = [8, 10, 12, 14, 16, 18, 20, 24] as const
const fontSizeOptions = computed<ComboboxOption<number>[]>(() =>
FONT_SIZES.map((size) => ({ value: size, label: `${size}px` })),
)
const isFullscreen = ref(false)
const fullscreenBodyClass = 'modrinth-console-fullscreen-active'
const fullscreenIntercomPadding = 20
const fullscreenIntercomPaddingRequestId = Symbol('console-fullscreen')
const isApp =
typeof window !== 'undefined' && !!(window as Record<string, unknown>).__TAURI_INTERNALS__
const isSharing = ref(false)
const { activeFilters, toggleFilter, buildFilterPredicate } = useConsoleFilters()
const hasLogs = computed(() => ctx.logLines.value.length > 0)
const isLiveSource = computed(() => {
const sources = ctx.logSources?.value
const index = ctx.activeLogSourceIndex?.value
if (!sources || index === undefined) return true
return sources[index]?.live ?? true
})
const logSourceOptions = computed(() =>
(ctx.logSources?.value ?? []).map((s, i) => ({ value: i, label: s.name })),
)
async function handleExportCrashContext() {
if (!ctx.onExportCrashContext || exportingCrashContext.value) return
exportingCrashContext.value = true
try {
await ctx.onExportCrashContext()
} finally {
exportingCrashContext.value = false
}
}
function buildCombinedPredicate(): ((line: LogLine) => boolean) | null {
const levelPred = buildFilterPredicate()
const query = searchQuery.value.trim().toLowerCase()
if (!levelPred && !query) return null
return (line: LogLine) => {
if (levelPred && !levelPred(line)) return false
if (query && !line.text.toLowerCase().includes(query)) return false
return true
}
}
const filteredLines = computed(() => {
const predicate = buildCombinedPredicate()
const src = ctx.logLines.value
if (!predicate) {
return src.map((line, i) => ({ line, originalIndex: i }))
}
const out: Array<{ line: LogLine; originalIndex: number }> = []
for (let i = 0; i < src.length; i++) {
if (predicate(src[i]!)) out.push({ line: src[i]!, originalIndex: i })
}
return out
})
onBeforeUnmount(() => {
if (isFullscreen.value) {
document.body.style.overflow = ''
document.body.classList.remove(fullscreenBodyClass)
pageContext?.intercomBubble?.requestHorizontalPadding?.(
fullscreenIntercomPaddingRequestId,
null,
)
modalBehavior?.onHide?.()
}
})
// needs historical log start/end flags on ws to be properly useful
const resolvedLoading = computed(() => {
const v = ctx.loading
if (!v) return false
return v.value
})
const resolvedShareDisabled = computed(() => {
const v = ctx.shareDisabled
if (!v) return false
return isRef(v) ? v.value : v
})
const commandInput = ref('')
const showCommandInput = computed(() => {
if (!ctx.sendCommand) return false
return unwrapMaybeRef(ctx.showCommandInput) ?? false
})
const commandDisabled = computed(() => unwrapMaybeRef(ctx.disableCommandInput) ?? false)
const commandDisabledTooltip = computed(() => ctx.disableCommandInputTooltip?.value)
const commandPlaceholder = computed(() => {
if (!commandDisabled.value) return formatMessage(consoleMessages.commandPlaceholder)
return formatMessage(
ctx.emptyStateType === 'server'
? consoleMessages.serverNotRunning
: consoleMessages.commandInputDisabled,
)
})
function submitCommand() {
const command = commandInput.value.trim()
if (!command || commandDisabled.value || !ctx.sendCommand) return
ctx.sendCommand(command)
commandInput.value = ''
// The user just interacted with the console: pin the view to the bottom
// so the command echo and its response are visible immediately.
viewportRef.value?.scrollToBottom()
}
function submitProvidedCommand(command: string) {
if (!command.trim() || commandDisabled.value || !ctx.sendCommand) return
ctx.sendCommand(command.trim())
viewportRef.value?.scrollToBottom()
}
// Re-pins the viewport to the bottom (and re-enables bottom-following).
// Exposed so hosts can react to external events such as a server starting.
function scrollToBottom() {
viewportRef.value?.scrollToBottom()
}
defineExpose({
scrollToBottom,
})
const showDelete = computed(() => !isLiveSource.value && ctx.onDelete != null)
const resolvedDeleteDisabled = computed(() => {
const v = ctx.deleteDisabled
if (!v) return false
return isRef(v) ? v.value : v
})
function unwrapMaybeRef<T>(value: T | { value: T } | undefined): T | undefined {
if (value === undefined) return undefined
return isRef(value) ? value.value : value
}
const resolvedDeleteDisabledTooltip = computed(() =>
resolvedDeleteDisabled.value ? unwrapMaybeRef(ctx.deleteDisabledTooltip) : undefined,
)
const resolvedClearDisabled = computed(() => {
const v = ctx.clearDisabled
if (!v) return false
return isRef(v) ? v.value : v
})
const resolvedClearDisabledTooltip = computed(() =>
resolvedClearDisabled.value ? unwrapMaybeRef(ctx.clearDisabledTooltip) : undefined,
)
function handleFilterToggle(value: LogLevel) {
toggleFilter(value)
}
function toggleFullscreen() {
isFullscreen.value = !isFullscreen.value
if (isFullscreen.value) {
document.body.style.overflow = 'hidden'
document.body.classList.add(fullscreenBodyClass)
pageContext?.intercomBubble?.requestHorizontalPadding?.(
fullscreenIntercomPaddingRequestId,
fullscreenIntercomPadding,
)
modalBehavior?.onShow?.()
} else {
document.body.style.overflow = ''
document.body.classList.remove(fullscreenBodyClass)
pageContext?.intercomBubble?.requestHorizontalPadding?.(
fullscreenIntercomPaddingRequestId,
null,
)
modalBehavior?.onHide?.()
}
}
function handleClear() {
if (resolvedClearDisabled.value) return
ctx.onClear?.()
}
function handleDelete() {
deleteModal.value?.show()
}
async function confirmDelete() {
if (!ctx.onDelete) return
isDeleting.value = true
try {
await ctx.onDelete()
deleteModal.value?.hide()
} catch (err) {
console.error('Failed to delete log file:', err)
addNotification({
type: 'error',
title: formatMessage(consoleMessages.deleteFailedTitle),
text: typeof err === 'string' ? err : formatMessage(consoleMessages.unknownError),
})
} finally {
isDeleting.value = false
}
}
async function handleShare() {
const predicate = buildCombinedPredicate()
const lines = predicate ? ctx.logLines.value.filter(predicate) : ctx.logLines.value
const content = lines.map((l) => l.text).join('\n')
isSharing.value = true
try {
const result = await shareLogs(client, content)
if (result.truncated) {
addNotification({
type: 'warning',
title: formatMessage(consoleMessages.shareTruncatedWarning),
})
}
if (result.url) {
shareModal.value?.show(result.url)
}
} catch (err) {
console.error('Failed to share logs:', err)
addNotification({
type: 'error',
title: formatMessage(consoleMessages.shareFailedTitle),
text: typeof err === 'string' ? err : formatMessage(consoleMessages.unknownError),
})
} finally {
isSharing.value = false
}
}
</script>
<style>
.terminal-loading-fade-enter-active,
.terminal-loading-fade-leave-active {
transition: opacity 250ms ease-in-out;
}
.terminal-loading-fade-enter-from,
.terminal-loading-fade-leave-to {
opacity: 0;
}
.modrinth-console-fullscreen-active .intercom-lightweight-app,
.modrinth-console-fullscreen-active .intercom-lightweight-app-launcher,
.modrinth-console-fullscreen-active .intercom-lightweight-app-messenger,
.modrinth-console-fullscreen-active .intercom-launcher-frame,
.modrinth-console-fullscreen-active .intercom-messenger-frame,
.modrinth-console-fullscreen-active #intercom-container,
.modrinth-console-fullscreen-active #intercom-frame,
.modrinth-console-fullscreen-active iframe[name='intercom-launcher-frame'],
.modrinth-console-fullscreen-active iframe[name='intercom-messenger-frame'] {
z-index: 14 !important;
}
.modrinth-console-fullscreen-active .loading-indicator-container,
.modrinth-console-fullscreen-active .app-contents::before {
z-index: 14 !important;
}
.modrinth-console-fullscreen-active .app-grid-navbar,
.modrinth-console-fullscreen-active .app-grid-statusbar {
z-index: 0 !important;
}
</style>
@@ -0,0 +1,560 @@
import { defineMessages } from '#ui/composables/i18n'
export const consoleMessages = defineMessages({
exportCrashContext: {
id: 'console.crash.export-context',
defaultMessage: 'Export crash context',
},
searchLogs: { id: 'console.search.placeholder', defaultMessage: 'Search logs' },
toggleWrap: { id: 'console.log.toggle-wrap', defaultMessage: 'Toggle line wrapping' },
wrapLabel: { id: 'console.log.wrap-label', defaultMessage: 'Wrap' },
emptyInstanceTitle: {
id: 'console.empty.instance-title',
defaultMessage: 'No logs yet',
},
emptyInstanceDescription: {
id: 'console.empty.instance-description',
defaultMessage: 'Click the Play button to start receiving live logs.',
},
emptyServerTitle: {
id: 'console.empty.server-title',
defaultMessage: 'Welcome to your Modrinth server!',
},
emptyServerDescription: {
id: 'console.empty.server-description',
defaultMessage: 'Click the start button to start the server!',
},
shareLogs: { id: 'console.share-modal.title', defaultMessage: 'Share Logs' },
deleteLogFile: { id: 'console.delete-modal.title', defaultMessage: 'Delete log file' },
deleteIrreversible: {
id: 'console.delete-modal.irreversible-title',
defaultMessage: 'This is irreversible',
},
deleteConfirmation: {
id: 'console.delete-modal.confirmation',
defaultMessage: 'Deleting this log file cannot be undone. Are you sure you want to continue?',
},
localCrashHeader: {
id: 'console.crash.local-header',
defaultMessage:
'{findings, plural, one {# local diagnosis result} other {# local diagnosis results}} from {sources, plural, one {# related file} other {# related files}}',
},
fallbackFindingAction: {
id: 'console.crash.finding.fallback-action',
defaultMessage: 'Review the evidence below and the Mods matched from the local instance.',
},
fallbackFindingTitle: {
id: 'console.crash.finding.fallback-title',
defaultMessage: 'Unknown diagnosis: {finding}',
},
knownSignatureTitle: {
id: 'console.crash.finding.known-signature.title',
defaultMessage: 'A known crash signature was detected',
},
knownSignatureAction: {
id: 'console.crash.finding.known-signature.action',
defaultMessage:
'Review the evidence and the related Minecraft, loader, Java, graphics, or Mod versions before changing the instance.',
},
matchedMod: {
id: 'console.crash.finding.matched-mod',
defaultMessage: 'Matched Mod: {identity}{modId} - {fileName}',
},
modChange: {
id: 'console.crash.mod-change',
defaultMessage: '{kind}: {filename}',
},
modChangesTitle: {
id: 'console.crash.mod-changes.title',
defaultMessage: 'Mod files changed since the last successful launch',
},
modChangesSummary: {
id: 'console.crash.mod-changes.summary',
defaultMessage: '{added} added, {removed} removed, {modified} modified',
},
modChangeAdded: {
id: 'console.crash.mod-change.added',
defaultMessage: 'Added',
},
modChangeRemoved: {
id: 'console.crash.mod-change.removed',
defaultMessage: 'Removed',
},
modChangeModified: {
id: 'console.crash.mod-change.modified',
defaultMessage: 'Modified',
},
windowsEventsTitle: {
id: 'console.crash.windows-events.title',
defaultMessage: 'Related Windows application events',
},
problemsDetected: {
id: 'console.crash.problems-detected',
defaultMessage: '{count, plural, one {# problem detected} other {# problems detected}}',
},
commandInputDisabled: {
id: 'console.command.disabled-placeholder',
defaultMessage: 'Command input disabled',
},
commandPlaceholder: {
id: 'console.command.placeholder',
defaultMessage: 'Send a command',
},
serverNotRunning: {
id: 'console.command.server-not-running-placeholder',
defaultMessage: 'Server is not running',
},
deleteFailedTitle: {
id: 'console.notification.delete-failed',
defaultMessage: 'Failed to delete log file',
},
shareFailedTitle: {
id: 'console.notification.share-failed',
defaultMessage: 'Failed to share logs',
},
shareTruncatedWarning: {
id: 'console.notification.share-truncated',
defaultMessage: 'The log is too large, so only the last 9 MB was uploaded.',
},
unknownError: { id: 'console.notification.unknown-error', defaultMessage: 'Unknown error.' },
})
export const localFindingMessages = defineMessages({
jvmArgumentsTitle: {
id: 'console.crash.finding.jvm-arguments.title',
defaultMessage: 'Invalid JVM arguments',
},
jvmArgumentsAction: {
id: 'console.crash.finding.jvm-arguments.action',
defaultMessage: 'Remove the reported custom JVM argument, then launch the instance again.',
},
outOfMemoryTitle: {
id: 'console.crash.finding.out-of-memory.title',
defaultMessage: 'Minecraft ran out of memory',
},
outOfMemoryAction: {
id: 'console.crash.finding.out-of-memory.action',
defaultMessage:
'Increase the instance memory allocation or remove memory-heavy mods and resource packs.',
},
openglUnsupportedTitle: {
id: 'console.crash.finding.opengl-unsupported.title',
defaultMessage: 'OpenGL is not supported by the active graphics driver',
},
openglUnsupportedAction: {
id: 'console.crash.finding.opengl-unsupported.action',
defaultMessage:
'Install the graphics driver from the GPU manufacturer and ensure Minecraft uses the intended GPU.',
},
pixelFormatTitle: {
id: 'console.crash.finding.pixel-format.title',
defaultMessage: 'The graphics driver could not set a pixel format',
},
pixelFormatAction: {
id: 'console.crash.finding.pixel-format.action',
defaultMessage:
'Update or reinstall the graphics driver and disable conflicting overlays before retrying.',
},
openj9Title: {
id: 'console.crash.finding.openj9.title',
defaultMessage: 'The selected OpenJ9 runtime is incompatible',
},
openj9Action: {
id: 'console.crash.finding.openj9.action',
defaultMessage:
'Select a HotSpot-based Java runtime such as Eclipse Temurin or the bundled Minecraft runtime.',
},
javaTooNewTitle: {
id: 'console.crash.finding.java-too-new.title',
defaultMessage: 'The Java runtime is too new for this instance',
},
javaTooNewAction: {
id: 'console.crash.finding.java-too-new.action',
defaultMessage:
'Select the Java major version expected by this Minecraft and mod-loader version.',
},
javaIncompatibleTitle: {
id: 'console.crash.finding.java-incompatible.title',
defaultMessage: 'A mod requires a different Java version',
},
javaIncompatibleAction: {
id: 'console.crash.finding.java-incompatible.action',
defaultMessage:
'Use a compatible Java runtime or install a build of the reported mod for this Java version.',
},
jdkRuntimeTitle: {
id: 'console.crash.finding.jdk-runtime.title',
defaultMessage: 'A JDK runtime was selected instead of a JRE',
},
jdkRuntimeAction: {
id: 'console.crash.finding.jdk-runtime.action',
defaultMessage: 'Select a standard HotSpot Java runtime for this Minecraft version.',
},
java32BitTitle: {
id: 'console.crash.finding.java-32bit.title',
defaultMessage: 'A 32-bit Java runtime cannot allocate the requested memory',
},
java32BitAction: {
id: 'console.crash.finding.java-32bit.action',
defaultMessage: 'Install and select a 64-bit Java runtime, then retry the launch.',
},
java11RequiredTitle: {
id: 'console.crash.finding.java-11-required.title',
defaultMessage: 'A Mod requires Java 11',
},
java11RequiredAction: {
id: 'console.crash.finding.java-11-required.action',
defaultMessage:
'Select Java 11 or install a Mod build compatible with the selected Java version.',
},
forgeIncompleteTitle: {
id: 'console.crash.finding.forge-incomplete.title',
defaultMessage: 'The Forge installation is incomplete',
},
forgeIncompleteAction: {
id: 'console.crash.finding.forge-incomplete.action',
defaultMessage: 'Repair or reinstall the Forge loader for this instance.',
},
duplicateModTitle: {
id: 'console.crash.finding.duplicate-mod.title',
defaultMessage: 'Duplicate Mods are installed',
},
duplicateModAction: {
id: 'console.crash.finding.duplicate-mod.action',
defaultMessage: 'Keep only one compatible version of each Mod in the mods folder.',
},
incompatibleModsTitle: {
id: 'console.crash.finding.incompatible-mods.title',
defaultMessage: 'The installed Mods are incompatible',
},
incompatibleModsAction: {
id: 'console.crash.finding.incompatible-mods.action',
defaultMessage:
'Follow the compatibility details in the evidence and update, remove, or replace the conflicting Mods.',
},
missingDependencyTitle: {
id: 'console.crash.finding.missing-dependency.title',
defaultMessage: 'A Mod dependency is missing or unsupported',
},
missingDependencyAction: {
id: 'console.crash.finding.missing-dependency.action',
defaultMessage:
'Install the required dependency version or use a Mod build matching this Minecraft version.',
},
diskSpaceTitle: {
id: 'console.crash.finding.disk-space.title',
defaultMessage: 'The disk ran out of free space',
},
diskSpaceAction: {
id: 'console.crash.finding.disk-space.action',
defaultMessage:
'Free space on the drive containing the instance, then retry the launch or installation.',
},
fileInUseTitle: {
id: 'console.crash.finding.file-in-use.title',
defaultMessage: 'Another process is using a required file',
},
fileInUseAction: {
id: 'console.crash.finding.file-in-use.action',
defaultMessage:
'Close the program named in the evidence, including other launchers, backup tools, or antivirus scans, then retry.',
},
connectorIncompatibleFabricModsTitle: {
id: 'console.crash.finding.connector-incompatible-fabric-mods.title',
defaultMessage: 'Sinytra Connector found incompatible Fabric Mods',
},
connectorIncompatibleFabricModsAction: {
id: 'console.crash.finding.connector-incompatible-fabric-mods.action',
defaultMessage:
'Remove or replace the Fabric Mods named in the Connector error with Forge-compatible alternatives.',
},
missingEmbeddiumTitle: {
id: 'console.crash.finding.missing-embeddium.title',
defaultMessage: 'Oculus requires Embeddium',
},
missingEmbeddiumAction: {
id: 'console.crash.finding.missing-embeddium.action',
defaultMessage:
'Install the Embeddium version required by Oculus for this Minecraft and Forge version.',
},
missingIndiumTitle: {
id: 'console.crash.finding.missing-indium.title',
defaultMessage: 'A Mod requires Indium',
},
missingIndiumAction: {
id: 'console.crash.finding.missing-indium.action',
defaultMessage:
'Install the Indium version compatible with the installed Fabric Loader and Sodium version.',
},
modIdLimitTitle: {
id: 'console.crash.finding.mod-id-limit.title',
defaultMessage: 'Too many Mods exceeded the ID limit',
},
modIdLimitAction: {
id: 'console.crash.finding.mod-id-limit.action',
defaultMessage:
'Remove unused Mods or split the installation into smaller compatible profiles.',
},
forgeErrorTitle: {
id: 'console.crash.finding.forge-error.title',
defaultMessage: 'Forge reported a game error',
},
forgeErrorAction: {
id: 'console.crash.finding.forge-error.action',
defaultMessage:
'Review the Forge failure evidence and test the named Mod without recent changes.',
},
modLoaderErrorTitle: {
id: 'console.crash.finding.mod-loader-error.title',
defaultMessage: 'The Mod loader reported a failure',
},
modLoaderErrorAction: {
id: 'console.crash.finding.mod-loader-error.action',
defaultMessage:
'Repair the loader installation and verify that the listed Mod files match this game version.',
},
modLoaderFailureTitle: {
id: 'console.crash.finding.mod-loader-failure.title',
defaultMessage: 'The Mod loader failed before identifying a Mod file',
},
modLoaderFailureAction: {
id: 'console.crash.finding.mod-loader-failure.action',
defaultMessage:
'Repair the loader installation and follow the failure message shown in the evidence.',
},
stackAnalysisTitle: {
id: 'console.crash.finding.stack-analysis.title',
defaultMessage: 'The stack trace points to an installed Mod',
},
stackAnalysisAction: {
id: 'console.crash.finding.stack-analysis.action',
defaultMessage: 'Update or temporarily remove the matched Mod, then test the instance again.',
},
shortOutputTitle: {
id: 'console.crash.finding.short-output.title',
defaultMessage: 'The game stopped before producing a useful log',
},
shortOutputAction: {
id: 'console.crash.finding.short-output.action',
defaultMessage:
'Retry once, then verify Java, the loader installation, and the launcher output for an earlier error.',
},
extractedModTitle: {
id: 'console.crash.finding.extracted-mod.title',
defaultMessage: 'An extracted Mod was found',
},
extractedModAction: {
id: 'console.crash.finding.extracted-mod.action',
defaultMessage:
'Remove the extracted directory from the mods folder and install the original jar file.',
},
mixinBootstrapTitle: {
id: 'console.crash.finding.mixin-bootstrap.title',
defaultMessage: 'Mixin bootstrap is missing',
},
mixinBootstrapAction: {
id: 'console.crash.finding.mixin-bootstrap.action',
defaultMessage:
'Repair the mod loader installation and verify that every mod targets the installed loader.',
},
mixinFailureTitle: {
id: 'console.crash.finding.mixin-failure.title',
defaultMessage: 'A Mod Mixin failed to apply',
},
mixinFailureAction: {
id: 'console.crash.finding.mixin-failure.action',
defaultMessage:
'Update or remove the matched Mod and check that its Minecraft and loader versions are compatible.',
},
fabricSolutionTitle: {
id: 'console.crash.finding.fabric-solution.title',
defaultMessage: 'Fabric found an incompatible Mod or missing dependency',
},
fabricSolutionAction: {
id: 'console.crash.finding.fabric-solution.action',
defaultMessage: 'Apply the dependency changes listed in the evidence before launching again.',
},
modConfigTitle: {
id: 'console.crash.finding.mod-config.title',
defaultMessage: 'A Mod configuration file could not be read',
},
modConfigAction: {
id: 'console.crash.finding.mod-config.action',
defaultMessage: 'Back up and remove the named configuration file so the Mod can regenerate it.',
},
optifineIncompatibleTitle: {
id: 'console.crash.finding.optifine-incompatible.title',
defaultMessage: 'OptiFine conflicts with the installed loader or Mod',
},
optifineIncompatibleAction: {
id: 'console.crash.finding.optifine-incompatible.action',
defaultMessage:
'Install a compatible OptiFine build or remove OptiFine and the conflicting shader Mod.',
},
resourcePackTitle: {
id: 'console.crash.finding.resource-pack.title',
defaultMessage: 'A shader or resource pack triggered a graphics error',
},
resourcePackAction: {
id: 'console.crash.finding.resource-pack.action',
defaultMessage:
'Disable the active shader and resource packs, then re-enable them one at a time.',
},
largeResourcePackTitle: {
id: 'console.crash.finding.large-resource-pack.title',
defaultMessage: 'The active resource pack is too large for the graphics configuration',
},
largeResourcePackAction: {
id: 'console.crash.finding.large-resource-pack.action',
defaultMessage: 'Disable the resource pack or use a lower-resolution version.',
},
shadersOptifineTitle: {
id: 'console.crash.finding.shaders-optifine.title',
defaultMessage: 'Shaders Mod and OptiFine are installed together',
},
shadersOptifineAction: {
id: 'console.crash.finding.shaders-optifine.action',
defaultMessage:
'Remove the separate Shaders Mod because OptiFine already provides shader support.',
},
multipleForgeVersionsTitle: {
id: 'console.crash.finding.multiple-forge-versions.title',
defaultMessage: 'The version profile contains multiple Forge versions',
},
multipleForgeVersionsAction: {
id: 'console.crash.finding.multiple-forge-versions.action',
defaultMessage:
'Repair the instance so its version profile contains only one Forge installation.',
},
forgeJavaIncompatibleTitle: {
id: 'console.crash.finding.forge-java-incompatible.title',
defaultMessage: 'This Forge version is incompatible with the selected Java runtime',
},
forgeJavaIncompatibleAction: {
id: 'console.crash.finding.forge-java-incompatible.action',
defaultMessage: 'Use the Java version expected by this Forge release or update Forge.',
},
contentVerificationTitle: {
id: 'console.crash.finding.content-verification.title',
defaultMessage: 'A jar failed signature verification',
},
contentVerificationAction: {
id: 'console.crash.finding.content-verification.action',
defaultMessage: 'Remove and reinstall the file named in the evidence from a trusted source.',
},
optifineWorldTitle: {
id: 'console.crash.finding.optifine-world.title',
defaultMessage: 'OptiFine prevented the world from loading',
},
optifineWorldAction: {
id: 'console.crash.finding.optifine-world.action',
defaultMessage:
'Remove OptiFine or install a build compatible with this Minecraft and Forge version.',
},
nightconfigBugTitle: {
id: 'console.crash.finding.nightconfig-bug.title',
defaultMessage: 'NightConfig could not read a configuration file',
},
nightconfigBugAction: {
id: 'console.crash.finding.nightconfig-bug.action',
defaultMessage:
'Back up the config folder, remove the damaged configuration, and let the Mod regenerate it.',
},
modFilenameTitle: {
id: 'console.crash.finding.mod-filename.title',
defaultMessage: 'A Mod filename contains unsupported characters',
},
modFilenameAction: {
id: 'console.crash.finding.mod-filename.action',
defaultMessage: 'Rename or reinstall the Mod jar using a simple Latin-letter filename.',
},
definiteModTitle: {
id: 'console.crash.finding.definite-mod.title',
defaultMessage: 'A specific Mod caused the crash',
},
definiteModAction: {
id: 'console.crash.finding.definite-mod.action',
defaultMessage:
'Update, repair, or temporarily remove the Mod identified by the evidence and matched jar.',
},
definiteModFabricTitle: {
id: 'console.crash.finding.definite-mod-fabric.title',
defaultMessage: 'Fabric identified a specific Mod failure',
},
definiteModFabricAction: {
id: 'console.crash.finding.definite-mod-fabric.action',
defaultMessage:
'Update or temporarily remove the Mod identified by the Fabric loader evidence.',
},
intelDriverTitle: {
id: 'console.crash.finding.intel-driver.title',
defaultMessage: 'The Intel graphics driver crashed',
},
intelDriverAction: {
id: 'console.crash.finding.intel-driver.action',
defaultMessage:
'Install a current Intel graphics driver or run Minecraft on another available GPU.',
},
amdDriverTitle: {
id: 'console.crash.finding.amd-driver.title',
defaultMessage: 'The AMD graphics driver crashed',
},
amdDriverAction: {
id: 'console.crash.finding.amd-driver.action',
defaultMessage:
'Clean-install a current AMD graphics driver and retry without graphics overlays.',
},
nvidiaDriverTitle: {
id: 'console.crash.finding.nvidia-driver.title',
defaultMessage: 'The NVIDIA graphics driver crashed',
},
nvidiaDriverAction: {
id: 'console.crash.finding.nvidia-driver.action',
defaultMessage:
'Clean-install a current NVIDIA graphics driver and retry without graphics overlays.',
},
manualDebugCrashTitle: {
id: 'console.crash.finding.manual-debug-crash.title',
defaultMessage: 'The debug crash shortcut was triggered',
},
manualDebugCrashAction: {
id: 'console.crash.finding.manual-debug-crash.action',
defaultMessage: 'Launch again and avoid holding the manual debug-crash key combination.',
},
suspectedModTitle: {
id: 'console.crash.finding.suspected-mod.title',
defaultMessage: 'The crash report suspects one or more Mods',
},
suspectedModAction: {
id: 'console.crash.finding.suspected-mod.action',
defaultMessage:
'Update or temporarily remove the suspected and locally matched Mods, then retry.',
},
modInitializationTitle: {
id: 'console.crash.finding.mod-initialization.title',
defaultMessage: 'A Mod failed to initialize',
},
modInitializationAction: {
id: 'console.crash.finding.mod-initialization.action',
defaultMessage:
'Update the named Mod and verify that all of its required dependencies are installed.',
},
specificBlockTitle: {
id: 'console.crash.finding.specific-block.title',
defaultMessage: 'A specific block caused the crash',
},
specificBlockAction: {
id: 'console.crash.finding.specific-block.action',
defaultMessage:
'Use a world backup or a world editor to remove the block at the coordinates in the evidence.',
},
specificEntityTitle: {
id: 'console.crash.finding.specific-entity.title',
defaultMessage: 'A specific entity caused the crash',
},
specificEntityAction: {
id: 'console.crash.finding.specific-entity.action',
defaultMessage:
'Use a world backup or a world editor to remove the entity at the coordinates in the evidence.',
},
})
@@ -0,0 +1,74 @@
import type { ComputedRef, Ref } from 'vue'
import { createContext } from '#ui/providers/create-context'
import type { LogLine, LogSource } from '../types'
export interface LocalCrashAnalysis {
crashed: boolean
sources: Array<{ filename: string; source_type: string; line_count: number }>
findings: Array<{
id: string
confidence: string
evidence: Array<{ filename: string; line: number; text: string }>
}>
mods: Array<{
file_name: string
id?: string
name?: string
matched_class?: string
}>
mod_changes: Array<{
kind: 'added' | 'removed' | 'modified'
filename: string
previous_size?: number
current_size?: number
current_sha256?: string
project_id?: string
project_title?: string
icon_url?: string
version_id?: string
version_number?: string
}>
mod_change_counts: { added: number; removed: number; modified: number }
windows_events: Array<{
event_id: number
provider: string
time_created: string
message: string
}>
}
export interface ConsoleManagerContext {
logLines: Ref<LogLine[]>
logSources?: ComputedRef<LogSource[]>
activeLogSourceIndex?: Ref<number>
sendCommand?: (cmd: string) => void
showCommandInput?: boolean | Ref<boolean> | ComputedRef<boolean>
disableCommandInput?: boolean | Ref<boolean> | ComputedRef<boolean>
disableCommandInputTooltip?: string | Ref<string | undefined> | ComputedRef<string | undefined>
loading?: Ref<boolean> | ComputedRef<boolean>
onClear?: () => void
clearDisabled?: Ref<boolean> | ComputedRef<boolean>
clearDisabledTooltip?: string | Ref<string | undefined> | ComputedRef<string | undefined>
onDelete?: () => Promise<void>
deleteDisabled?: Ref<boolean> | ComputedRef<boolean>
deleteDisabledTooltip?: string | Ref<string | undefined> | ComputedRef<string | undefined>
shareDisabled?: Ref<boolean> | ComputedRef<boolean>
emptyStateType?: 'server' | 'instance'
localCrashAnalysis?: Ref<LocalCrashAnalysis | null>
crashAnalysisLoading?: Ref<boolean>
onExportCrashContext?: () => Promise<void>
}
export const [injectConsoleManager, provideConsoleManager] = createContext<ConsoleManagerContext>(
'ConsolePageLayout',
'consoleManagerContext',
)
@@ -0,0 +1 @@
export * from './console-manager'
@@ -0,0 +1,21 @@
export type LogLevel = 'error' | 'warn' | 'info' | 'debug' | 'trace'
export interface LogLine {
text: string
level: LogLevel | null
}
export interface Log4jEvent {
logger_name?: string
level?: string
thread_name?: string
timestamp_millis?: number
message?: string
throwable?: string
}
export interface LogSource {
id: string
name: string
live: boolean
}