feat:移除了弹窗,服务器添加sls
This commit is contained in:
@ -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>
|
||||
Reference in New Issue
Block a user