feat:移除了弹窗,服务器添加sls
This commit is contained in:
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> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
}
|
||||
|
||||
const RE_HTML_ESCAPE = /[&<>"']/g
|
||||
|
||||
const RE_MARK_OPEN = /<mark>/gi
|
||||
const RE_MARK_CLOSE = /<\/mark>/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
|
||||
}
|
||||
Reference in new issue
Block a user