fix: 修复实例日志窗口停更与日志行重叠
Some checks failed
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Some checks failed
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
日志停更(实例启动后日志不再刷新): - process.rs 的 XML 日志分支原先使用 quick-xml 流式异步 Reader。 该解析器在任何 I/O/解析错误或瞬时 Eof 后会把内部状态永久置为 ParseState::Done(quick-xml #513),此后永远只返回 Eof,导致 日志转发在客户端启动、日志量变大后彻底停止。 - 改为把原始字节累积到缓冲区,切出完整的 <log4j:Event …>… </log4j:Event> 帧后逐帧同步解析;坏帧/分片帧只跳过,不再毒化 解析状态,日志转发持续进行。 - 新增 take_next_log4j_frame(切帧)与 handle_log4j_frame(单帧 解析与转发)两个辅助函数。 日志行重叠: - LogViewport.vue 原先用手动虚拟滚动,按估算高度为每行设置固定 height;估算与实际渲染高度不符(高亮、wrap 折行等)时,绝对 定位的行会溢出并相互重叠。 - 改用原生虚拟化 content-visibility: auto + contain-intrinsic-size, 全量渲染由浏览器跳过屏外行的布局绘制,进入视口时以真实高度 修正,从根本上消除估算误差导致的重叠。 - 保留 scrollToBottom / 自动跟随底部 / 搜索高亮 / 错误警告配色 等既有行为与对外接口。
This commit is contained in:
@ -616,220 +616,62 @@ impl Process {
|
|||||||
let mut buf_reader = BufReader::new(reader);
|
let mut buf_reader = BufReader::new(reader);
|
||||||
|
|
||||||
if xml_logging {
|
if xml_logging {
|
||||||
let mut reader = Reader::from_reader(buf_reader);
|
// NOTE: we deliberately do NOT use quick-xml's streaming async reader
|
||||||
reader.config_mut().enable_all_checks(false);
|
// here. Its parser marks itself `ParseState::Done` permanently after
|
||||||
|
// any I/O/parse error or a transient `Eof` (see quick-xml #513), so a
|
||||||
let mut buf = Vec::new();
|
// single split XML frame on the live pipe would silently kill all
|
||||||
let mut current_event = Log4jEvent::default();
|
// further log forwarding — which is exactly the "logs stop after the
|
||||||
let mut in_event = false;
|
// client finished starting" bug.
|
||||||
let mut in_message = false;
|
//
|
||||||
let mut in_throwable = false;
|
// Instead we accumulate raw bytes into a buffer and cut out complete
|
||||||
let mut current_content = String::new();
|
// `<log4j:Event ...>…</log4j:Event>` frames, parsing each frame in one
|
||||||
|
// synchronous pass. Malformed or partial frames are skipped without
|
||||||
|
// poisoning the stream, so forwarding always continues.
|
||||||
|
let mut pending = String::new();
|
||||||
|
let mut chunk = [0u8; 8192];
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
match reader.read_event_into_async(&mut buf).await {
|
let read = match tokio::io::AsyncReadExt::read(&mut buf_reader, &mut chunk).await {
|
||||||
|
Ok(0) => break,
|
||||||
|
Ok(n) => n,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!(
|
tracing::warn!("Live log read error: {e}");
|
||||||
"Error at position {}: {:?}",
|
|
||||||
reader.buffer_position(),
|
|
||||||
e
|
|
||||||
);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// exits the loop when reaching end of file
|
|
||||||
Ok(Event::Eof) => break,
|
|
||||||
|
|
||||||
Ok(Event::Start(e)) => {
|
|
||||||
match e.name().as_ref() {
|
|
||||||
b"log4j:Event" => {
|
|
||||||
// Reset for new event
|
|
||||||
current_event = Log4jEvent::default();
|
|
||||||
in_event = true;
|
|
||||||
|
|
||||||
// Extract attributes
|
|
||||||
for attr in e.attributes().flatten() {
|
|
||||||
let key = String::from_utf8_lossy(
|
|
||||||
attr.key.into_inner(),
|
|
||||||
)
|
|
||||||
.to_string();
|
|
||||||
let value =
|
|
||||||
String::from_utf8_lossy(&attr.value)
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
match key.as_str() {
|
|
||||||
"logger" => {
|
|
||||||
current_event.logger_name =
|
|
||||||
Some(value)
|
|
||||||
}
|
|
||||||
"level" => {
|
|
||||||
current_event.level = Some(value)
|
|
||||||
}
|
|
||||||
"thread" => {
|
|
||||||
current_event.thread_name =
|
|
||||||
Some(value)
|
|
||||||
}
|
|
||||||
"timestamp" => {
|
|
||||||
current_event.timestamp_millis =
|
|
||||||
value.parse::<i64>().ok()
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
b"log4j:Message" => {
|
|
||||||
in_message = true;
|
|
||||||
current_content = String::new();
|
|
||||||
}
|
|
||||||
b"log4j:Throwable" => {
|
|
||||||
in_throwable = true;
|
|
||||||
current_content = String::new();
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(Event::End(e)) => {
|
|
||||||
match e.name().as_ref() {
|
|
||||||
b"log4j:Message" => {
|
|
||||||
in_message = false;
|
|
||||||
current_event.message =
|
|
||||||
Some(current_content.clone());
|
|
||||||
}
|
|
||||||
b"log4j:Throwable" => {
|
|
||||||
in_throwable = false;
|
|
||||||
current_event.throwable =
|
|
||||||
if current_content.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(current_content.clone())
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Write log entry + throwable to file
|
pending.push_str(&String::from_utf8_lossy(&chunk[..read]));
|
||||||
if let Some(formatted_log) =
|
|
||||||
Self::format_log4j_entry(¤t_event)
|
|
||||||
{
|
|
||||||
if let Err(e) = Process::append_to_log_file(
|
|
||||||
&log_path,
|
|
||||||
&formatted_log,
|
|
||||||
) {
|
|
||||||
tracing::error!(
|
|
||||||
"Failed to write to log file: {}",
|
|
||||||
e
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(ref throwable) =
|
// Drain every complete frame currently buffered.
|
||||||
current_event.throwable
|
while let Some(frame) = take_next_log4j_frame(&mut pending) {
|
||||||
&& let Err(e) =
|
Self::handle_log4j_frame(
|
||||||
Process::append_to_log_file(
|
|
||||||
&log_path, throwable,
|
|
||||||
)
|
|
||||||
{
|
|
||||||
tracing::error!(
|
|
||||||
"Failed to write throwable to log file: {}",
|
|
||||||
e
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Self::emit_log4j_event(
|
|
||||||
instance_id,
|
|
||||||
¤t_event,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
b"log4j:Event" => {
|
|
||||||
in_event = false;
|
|
||||||
// If no throwable was present, write the log entry at the end of the event
|
|
||||||
if current_event.message.is_some()
|
|
||||||
&& current_event.throwable.is_none()
|
|
||||||
{
|
|
||||||
if let Some(formatted_log) =
|
|
||||||
Self::format_log4j_entry(¤t_event)
|
|
||||||
&& let Err(e) =
|
|
||||||
Process::append_to_log_file(
|
|
||||||
&log_path,
|
|
||||||
&formatted_log,
|
|
||||||
)
|
|
||||||
{
|
|
||||||
tracing::error!(
|
|
||||||
"Failed to write to log file: {}",
|
|
||||||
e
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(timestamp_millis) =
|
|
||||||
current_event.timestamp_millis
|
|
||||||
{
|
|
||||||
let timestamp =
|
|
||||||
timestamp_millis.to_string();
|
|
||||||
let message = current_event
|
|
||||||
.message
|
|
||||||
.as_deref()
|
|
||||||
.unwrap_or("")
|
|
||||||
.trim();
|
|
||||||
crate::api::multiplayer::observe_minecraft_log(
|
|
||||||
instance_id,
|
instance_id,
|
||||||
instance_name,
|
instance_name,
|
||||||
process_id,
|
process_id,
|
||||||
message,
|
&log_path,
|
||||||
|
&frame,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
if let Err(e) = Self::maybe_handle_server_join_logging(
|
}
|
||||||
instance_id,
|
|
||||||
×tamp,
|
// Guard against a runaway buffer if no frame delimiters ever
|
||||||
message,
|
// appear (e.g. raw non-XML output on a logging-configured
|
||||||
).await {
|
// instance). Flush it as legacy text so it is not lost.
|
||||||
tracing::error!("Failed to handle server join logging: {e}");
|
if pending.len() > MAX_PERSISTED_LOG_LINE_BYTES {
|
||||||
|
let text = std::mem::take(&mut pending);
|
||||||
|
if let Err(e) = Self::append_to_log_file(&log_path, &text) {
|
||||||
|
tracing::warn!("Failed to write to log file: {e}");
|
||||||
|
}
|
||||||
|
Self::emit_legacy_log(instance_id, text.trim_end());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Self::emit_log4j_event(
|
// Flush any trailing partial content on stream end.
|
||||||
instance_id,
|
if !pending.trim().is_empty() {
|
||||||
¤t_event,
|
if let Err(e) = Self::append_to_log_file(&log_path, &pending) {
|
||||||
);
|
tracing::warn!("Failed to write to log file: {e}");
|
||||||
}
|
}
|
||||||
}
|
Self::emit_legacy_log(instance_id, pending.trim_end());
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(Event::Text(mut e)) => {
|
|
||||||
if in_message || in_throwable {
|
|
||||||
if let Ok(text) = e.xml_content() {
|
|
||||||
append_bounded_log4j_content(
|
|
||||||
&mut current_content,
|
|
||||||
&text,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else if !in_event
|
|
||||||
&& !e.inplace_trim_end()
|
|
||||||
&& !e.inplace_trim_start()
|
|
||||||
&& let Ok(text) = e.xml_content()
|
|
||||||
{
|
|
||||||
if let Err(e) = Process::append_to_log_file(
|
|
||||||
&log_path,
|
|
||||||
&format!("{text}\n"),
|
|
||||||
) {
|
|
||||||
tracing::error!(
|
|
||||||
"Failed to write to log file: {}",
|
|
||||||
e
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Self::emit_legacy_log(instance_id, &text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(Event::CData(e)) => {
|
|
||||||
if (in_message || in_throwable)
|
|
||||||
&& let Ok(text) = e.xml_content()
|
|
||||||
{
|
|
||||||
append_bounded_log4j_content(
|
|
||||||
&mut current_content,
|
|
||||||
&text,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => (),
|
|
||||||
}
|
|
||||||
|
|
||||||
buf.clear();
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
while let Ok(Some(line)) =
|
while let Ok(Some(line)) =
|
||||||
@ -862,6 +704,164 @@ impl Process {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parses one complete `<log4j:Event …>…</log4j:Event>` frame and forwards
|
||||||
|
/// its content to the log file / frontend. A frame that fails to parse is
|
||||||
|
/// logged and dropped; it never stops the reader loop.
|
||||||
|
async fn handle_log4j_frame(
|
||||||
|
instance_id: &str,
|
||||||
|
instance_name: &str,
|
||||||
|
process_id: &str,
|
||||||
|
log_path: &Path,
|
||||||
|
frame: &str,
|
||||||
|
) {
|
||||||
|
let mut reader = Reader::from_str(frame);
|
||||||
|
reader.config_mut().enable_all_checks(false);
|
||||||
|
|
||||||
|
let mut current_event = Log4jEvent::default();
|
||||||
|
let mut in_message = false;
|
||||||
|
let mut in_throwable = false;
|
||||||
|
let mut current_content = String::new();
|
||||||
|
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
loop {
|
||||||
|
match reader.read_event_into(&mut buf) {
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("Malformed live log frame: {e}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Ok(Event::Eof) => break,
|
||||||
|
Ok(Event::Start(e)) => match e.name().as_ref() {
|
||||||
|
b"log4j:Event" => {
|
||||||
|
current_event = Log4jEvent::default();
|
||||||
|
for attr in e.attributes().flatten() {
|
||||||
|
let key =
|
||||||
|
String::from_utf8_lossy(attr.key.into_inner())
|
||||||
|
.to_string();
|
||||||
|
let value = String::from_utf8_lossy(&attr.value)
|
||||||
|
.to_string();
|
||||||
|
match key.as_str() {
|
||||||
|
"logger" => {
|
||||||
|
current_event.logger_name = Some(value)
|
||||||
|
}
|
||||||
|
"level" => current_event.level = Some(value),
|
||||||
|
"thread" => {
|
||||||
|
current_event.thread_name = Some(value)
|
||||||
|
}
|
||||||
|
"timestamp" => {
|
||||||
|
current_event.timestamp_millis =
|
||||||
|
value.parse::<i64>().ok()
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b"log4j:Message" => {
|
||||||
|
in_message = true;
|
||||||
|
current_content = String::new();
|
||||||
|
}
|
||||||
|
b"log4j:Throwable" => {
|
||||||
|
in_throwable = true;
|
||||||
|
current_content = String::new();
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
Ok(Event::End(e)) => match e.name().as_ref() {
|
||||||
|
b"log4j:Message" => {
|
||||||
|
in_message = false;
|
||||||
|
current_event.message = Some(current_content.clone());
|
||||||
|
}
|
||||||
|
b"log4j:Throwable" => {
|
||||||
|
in_throwable = false;
|
||||||
|
current_event.throwable =
|
||||||
|
if current_content.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(current_content.clone())
|
||||||
|
};
|
||||||
|
}
|
||||||
|
b"log4j:Event" => {
|
||||||
|
if let Some(formatted) =
|
||||||
|
Self::format_log4j_entry(¤t_event)
|
||||||
|
{
|
||||||
|
if let Err(e) =
|
||||||
|
Self::append_to_log_file(log_path, &formatted)
|
||||||
|
{
|
||||||
|
tracing::error!(
|
||||||
|
"Failed to write to log file: {e}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(ref throwable) = current_event.throwable
|
||||||
|
&& let Err(e) = Self::append_to_log_file(
|
||||||
|
log_path,
|
||||||
|
throwable,
|
||||||
|
)
|
||||||
|
{
|
||||||
|
tracing::error!(
|
||||||
|
"Failed to write throwable to log file: {e}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(timestamp_millis) =
|
||||||
|
current_event.timestamp_millis
|
||||||
|
{
|
||||||
|
let timestamp = timestamp_millis.to_string();
|
||||||
|
let message = current_event
|
||||||
|
.message
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim();
|
||||||
|
crate::api::multiplayer::observe_minecraft_log(
|
||||||
|
instance_id,
|
||||||
|
instance_name,
|
||||||
|
process_id,
|
||||||
|
message,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
if let Err(e) =
|
||||||
|
Self::maybe_handle_server_join_logging(
|
||||||
|
instance_id,
|
||||||
|
×tamp,
|
||||||
|
message,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::error!(
|
||||||
|
"Failed to handle server join logging: {e}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Self::emit_log4j_event(instance_id, ¤t_event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
Ok(Event::Text(e)) => {
|
||||||
|
if (in_message || in_throwable)
|
||||||
|
&& let Ok(text) = e.xml_content()
|
||||||
|
{
|
||||||
|
append_bounded_log4j_content(
|
||||||
|
&mut current_content,
|
||||||
|
&text,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Event::CData(e)) => {
|
||||||
|
if (in_message || in_throwable)
|
||||||
|
&& let Ok(text) = e.xml_content()
|
||||||
|
{
|
||||||
|
append_bounded_log4j_content(
|
||||||
|
&mut current_content,
|
||||||
|
&text,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => (),
|
||||||
|
}
|
||||||
|
buf.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn format_timestamp(timestamp_millis: Option<i64>) -> String {
|
fn format_timestamp(timestamp_millis: Option<i64>) -> String {
|
||||||
if let Some(timestamp_val) = timestamp_millis {
|
if let Some(timestamp_val) = timestamp_millis {
|
||||||
let datetime_utc = if timestamp_val > i32::MAX as i64 {
|
let datetime_utc = if timestamp_val > i32::MAX as i64 {
|
||||||
@ -1267,7 +1267,28 @@ impl Process {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/// Cuts the next complete `<log4j:Event …>…</log4j:Event>` frame out of the
|
||||||
|
/// live buffer and returns it as an owned string, leaving any trailing partial
|
||||||
|
/// frame in place.
|
||||||
|
///
|
||||||
|
/// Returns `None` when the buffer does not yet contain a full frame. This is a
|
||||||
|
/// plain string operation on purpose: it never poisons any parser state, so a
|
||||||
|
/// split or malformed frame on the live pipe cannot stop log forwarding.
|
||||||
|
fn take_next_log4j_frame(buffer: &mut String) -> Option<String> {
|
||||||
|
const OPEN: &str = "<log4j:Event";
|
||||||
|
const CLOSE: &str = "</log4j:Event>";
|
||||||
|
|
||||||
|
let start = buffer.find(OPEN)?;
|
||||||
|
// Discard anything before the frame (raw text, XML prolog, …).
|
||||||
|
if start > 0 {
|
||||||
|
buffer.drain(..start);
|
||||||
|
}
|
||||||
|
let close = buffer.find(CLOSE)?;
|
||||||
|
let end = close + CLOSE.len();
|
||||||
|
let frame = buffer[..end].to_string();
|
||||||
|
buffer.drain(..end);
|
||||||
|
Some(frame)
|
||||||
|
}
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod post_upgrade_tests {
|
mod post_upgrade_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@ -19,22 +19,21 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Native virtualization: every line is rendered, but `content-visibility:
|
||||||
|
auto` lets the browser skip layout/paint for off-screen lines, while
|
||||||
|
`contain-intrinsic-size` gives the skipped elements a placeholder size
|
||||||
|
so the scrollbar stays stable. This avoids the manual height estimation
|
||||||
|
that used to make tall (wrapped / highlighted) lines overlap.
|
||||||
|
-->
|
||||||
|
<div v-else class="log-viewport-spacer relative w-full min-w-max">
|
||||||
<div
|
<div
|
||||||
v-else
|
v-for="item in lines"
|
||||||
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"
|
:key="item.originalIndex"
|
||||||
:data-line="item.originalIndex + 1"
|
:data-line="item.originalIndex + 1"
|
||||||
class="log-line flex items-stretch whitespace-pre"
|
class="log-line log-line-cv flex items-stretch whitespace-pre"
|
||||||
:class="entryClass(item.line)"
|
:class="entryClass(item.line)"
|
||||||
:style="{ height: estimateHeight(item) + 'px' }"
|
:style="lineStyle"
|
||||||
>
|
>
|
||||||
<span
|
<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"
|
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"
|
||||||
@ -46,7 +45,6 @@
|
|||||||
></span>
|
></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<Transition name="scroll-to-bottom-fade">
|
<Transition name="scroll-to-bottom-fade">
|
||||||
<div v-if="lines.length > 0 && !stickToBottom" class="absolute bottom-4 right-4 z-10">
|
<div v-if="lines.length > 0 && !stickToBottom" class="absolute bottom-4 right-4 z-10">
|
||||||
@ -99,114 +97,21 @@ const props = withDefaults(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const viewportRef = ref<HTMLElement | null>(null)
|
const viewportRef = ref<HTMLElement | null>(null)
|
||||||
const scrollTop = ref(0)
|
|
||||||
const viewportHeight = ref(0)
|
|
||||||
const stickToBottom = ref(true)
|
const stickToBottom = ref(true)
|
||||||
|
|
||||||
// 行高:单行 = 字号 × 1.4(与等宽字体匹配),wrap 时按估算折行数放大
|
// Placeholder row height for `contain-intrinsic-size`. Native
|
||||||
const lineHeightPx = computed(() => Math.round(props.fontSize * 1.4))
|
// `content-visibility: auto` replaces this with the real measured height once a
|
||||||
// wrap 折行估算:0.6em 为等宽字符平均宽,乘 0.9 留保守余量(行高宁高勿矮,避免内容溢出重叠)
|
// line enters the viewport, so it only needs to be a reasonable estimate to
|
||||||
const charsPerLine = computed(() => {
|
// keep the scrollbar from jumping. Wrapped lines can be taller, so bias higher.
|
||||||
const vp = viewportRef.value
|
const intrinsicLineHeight = computed(() => {
|
||||||
if (!vp) return 120
|
const single = Math.round(props.fontSize * 1.4)
|
||||||
return Math.max(20, Math.floor((vp.clientWidth / (props.fontSize * 0.6)) * 0.9))
|
return props.wrap ? single * 2 : single
|
||||||
})
|
})
|
||||||
|
|
||||||
function estimateHeight(item: ViewportLine): number {
|
const lineStyle = computed(() => ({
|
||||||
if (!props.wrap) return lineHeightPx.value
|
'content-visibility': 'auto',
|
||||||
const lines = Math.max(1, Math.ceil(item.line.text.length / charsPerLine.value))
|
'contain-intrinsic-size': `auto ${intrinsicLineHeight.value}px`,
|
||||||
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 {
|
function entryClass(line: LogLine): string {
|
||||||
if (line.level === 'error') return 'entry-error'
|
if (line.level === 'error') return 'entry-error'
|
||||||
@ -235,43 +140,47 @@ function renderLine(item: ViewportLine): string {
|
|||||||
function handleScroll() {
|
function handleScroll() {
|
||||||
const vp = viewportRef.value
|
const vp = viewportRef.value
|
||||||
if (!vp) return
|
if (!vp) return
|
||||||
scrollTop.value = vp.scrollTop
|
stickToBottom.value = vp.scrollTop + vp.clientHeight >= vp.scrollHeight - 32
|
||||||
viewportHeight.value = vp.clientHeight
|
|
||||||
stickToBottom.value = vp.scrollTop + vp.clientHeight >= vp.scrollHeight - lineHeightPx.value * 2
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollToBottom() {
|
function scrollToBottom() {
|
||||||
const vp = viewportRef.value
|
const vp = viewportRef.value
|
||||||
if (!vp) return
|
if (!vp) return
|
||||||
vp.scrollTop = vp.scrollHeight
|
vp.scrollTop = vp.scrollHeight
|
||||||
scrollTop.value = vp.scrollTop
|
|
||||||
stickToBottom.value = true
|
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
|
let resizeObserver: ResizeObserver | null = null
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
syncViewportSize()
|
|
||||||
if (stickToBottom.value) nextTick(scrollToBottom)
|
if (stickToBottom.value) nextTick(scrollToBottom)
|
||||||
resizeObserver = new ResizeObserver(syncViewportSize)
|
resizeObserver = new ResizeObserver(() => {
|
||||||
|
if (stickToBottom.value) scrollToBottom()
|
||||||
|
})
|
||||||
if (viewportRef.value) resizeObserver.observe(viewportRef.value)
|
if (viewportRef.value) resizeObserver.observe(viewportRef.value)
|
||||||
window.addEventListener('resize', syncViewportSize)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
resizeObserver?.disconnect()
|
resizeObserver?.disconnect()
|
||||||
resizeObserver = null
|
resizeObserver = null
|
||||||
window.removeEventListener('resize', syncViewportSize)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Follow the tail while new lines stream in, but only when the user has not
|
||||||
|
// scrolled up. A fresh stream after an empty console (clear, restart, initial
|
||||||
|
// hydration) always resumes bottom-following.
|
||||||
|
watch(
|
||||||
|
() => props.lines,
|
||||||
|
(lines, previous) => {
|
||||||
|
if (previous && previous.length === 0 && lines.length > 0) {
|
||||||
|
stickToBottom.value = true
|
||||||
|
}
|
||||||
|
if (stickToBottom.value) {
|
||||||
|
nextTick(scrollToBottom)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
scrollToBottom,
|
scrollToBottom,
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user