Compare commits

..

1 Commits

Author SHA1 Message Date
ddidderr 20404d0145 Add application log viewer 2026-06-07 16:17:31 +02:00
7 changed files with 206 additions and 452 deletions
Generated
+33 -33
View File
@@ -155,9 +155,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.13.0" version = "2.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a"
dependencies = [ dependencies = [
"serde_core", "serde_core",
] ]
@@ -243,7 +243,7 @@ version = "0.18.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2"
dependencies = [ dependencies = [
"bitflags 2.13.0", "bitflags 2.12.1",
"cairo-sys-rs", "cairo-sys-rs",
"glib", "glib",
"libc", "libc",
@@ -432,7 +432,7 @@ version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
dependencies = [ dependencies = [
"bitflags 2.13.0", "bitflags 2.12.1",
"core-foundation", "core-foundation",
"core-graphics-types", "core-graphics-types",
"foreign-types", "foreign-types",
@@ -445,7 +445,7 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
dependencies = [ dependencies = [
"bitflags 2.13.0", "bitflags 2.12.1",
"core-foundation", "core-foundation",
"libc", "libc",
] ]
@@ -689,7 +689,7 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
dependencies = [ dependencies = [
"bitflags 2.13.0", "bitflags 2.12.1",
"block2", "block2",
"libc", "libc",
"objc2", "objc2",
@@ -1323,7 +1323,7 @@ version = "0.18.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5"
dependencies = [ dependencies = [
"bitflags 2.13.0", "bitflags 2.12.1",
"futures-channel", "futures-channel",
"futures-core", "futures-core",
"futures-executor", "futures-executor",
@@ -1478,9 +1478,9 @@ dependencies = [
[[package]] [[package]]
name = "hashlink" name = "hashlink"
version = "0.11.1" version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230"
dependencies = [ dependencies = [
"hashbrown 0.16.1", "hashbrown 0.16.1",
] ]
@@ -1804,7 +1804,7 @@ version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "533e68a5842e734946fe159fb03fc9bbbb254f590dd0d8ad321ae5ff7beca2c1" checksum = "533e68a5842e734946fe159fb03fc9bbbb254f590dd0d8ad321ae5ff7beca2c1"
dependencies = [ dependencies = [
"bitflags 2.13.0", "bitflags 2.12.1",
"inotify-sys", "inotify-sys",
"libc", "libc",
] ]
@@ -1975,7 +1975,7 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a"
dependencies = [ dependencies = [
"bitflags 2.13.0", "bitflags 2.12.1",
"serde", "serde",
"unicode-segmentation", "unicode-segmentation",
] ]
@@ -1996,7 +1996,7 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087"
dependencies = [ dependencies = [
"bitflags 2.13.0", "bitflags 2.12.1",
"libc", "libc",
] ]
@@ -2339,7 +2339,7 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
dependencies = [ dependencies = [
"bitflags 2.13.0", "bitflags 2.12.1",
"jni-sys 0.3.1", "jni-sys 0.3.1",
"log", "log",
"ndk-sys", "ndk-sys",
@@ -2369,7 +2369,7 @@ version = "8.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3"
dependencies = [ dependencies = [
"bitflags 2.13.0", "bitflags 2.12.1",
"fsevent-sys", "fsevent-sys",
"inotify", "inotify",
"kqueue", "kqueue",
@@ -2387,7 +2387,7 @@ version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a"
dependencies = [ dependencies = [
"bitflags 2.13.0", "bitflags 2.12.1",
] ]
[[package]] [[package]]
@@ -2481,7 +2481,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
dependencies = [ dependencies = [
"bitflags 2.13.0", "bitflags 2.12.1",
"block2", "block2",
"objc2", "objc2",
"objc2-core-foundation", "objc2-core-foundation",
@@ -2494,7 +2494,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c"
dependencies = [ dependencies = [
"bitflags 2.13.0", "bitflags 2.12.1",
"objc2", "objc2",
"objc2-foundation", "objc2-foundation",
] ]
@@ -2515,7 +2515,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [ dependencies = [
"bitflags 2.13.0", "bitflags 2.12.1",
"dispatch2", "dispatch2",
"objc2", "objc2",
] ]
@@ -2526,7 +2526,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
dependencies = [ dependencies = [
"bitflags 2.13.0", "bitflags 2.12.1",
"dispatch2", "dispatch2",
"objc2", "objc2",
"objc2-core-foundation", "objc2-core-foundation",
@@ -2559,7 +2559,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
dependencies = [ dependencies = [
"bitflags 2.13.0", "bitflags 2.12.1",
"objc2", "objc2",
"objc2-core-foundation", "objc2-core-foundation",
"objc2-core-graphics", "objc2-core-graphics",
@@ -2586,7 +2586,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [ dependencies = [
"bitflags 2.13.0", "bitflags 2.12.1",
"block2", "block2",
"libc", "libc",
"objc2", "objc2",
@@ -2599,7 +2599,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
dependencies = [ dependencies = [
"bitflags 2.13.0", "bitflags 2.12.1",
"objc2", "objc2",
"objc2-core-foundation", "objc2-core-foundation",
] ]
@@ -2610,7 +2610,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
dependencies = [ dependencies = [
"bitflags 2.13.0", "bitflags 2.12.1",
"objc2", "objc2",
"objc2-core-foundation", "objc2-core-foundation",
"objc2-foundation", "objc2-foundation",
@@ -2622,7 +2622,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
dependencies = [ dependencies = [
"bitflags 2.13.0", "bitflags 2.12.1",
"block2", "block2",
"objc2", "objc2",
"objc2-cloud-kit", "objc2-cloud-kit",
@@ -2653,7 +2653,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f"
dependencies = [ dependencies = [
"bitflags 2.13.0", "bitflags 2.12.1",
"block2", "block2",
"objc2", "objc2",
"objc2-app-kit", "objc2-app-kit",
@@ -2858,7 +2858,7 @@ version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [ dependencies = [
"bitflags 2.13.0", "bitflags 2.12.1",
"crc32fast", "crc32fast",
"fdeflate", "fdeflate",
"flate2", "flate2",
@@ -3067,7 +3067,7 @@ version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [ dependencies = [
"bitflags 2.13.0", "bitflags 2.12.1",
] ]
[[package]] [[package]]
@@ -3223,7 +3223,7 @@ version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [ dependencies = [
"bitflags 2.13.0", "bitflags 2.12.1",
"errno", "errno",
"libc", "libc",
"linux-raw-sys", "linux-raw-sys",
@@ -3511,7 +3511,7 @@ version = "0.36.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c"
dependencies = [ dependencies = [
"bitflags 2.13.0", "bitflags 2.12.1",
"cssparser", "cssparser",
"derive_more", "derive_more",
"log", "log",
@@ -4100,7 +4100,7 @@ version = "0.35.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9"
dependencies = [ dependencies = [
"bitflags 2.13.0", "bitflags 2.12.1",
"block2", "block2",
"core-foundation", "core-foundation",
"core-graphics", "core-graphics",
@@ -4770,7 +4770,7 @@ version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
dependencies = [ dependencies = [
"bitflags 2.13.0", "bitflags 2.12.1",
"bytes", "bytes",
"futures-util", "futures-util",
"http", "http",
@@ -5201,7 +5201,7 @@ version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [ dependencies = [
"bitflags 2.13.0", "bitflags 2.12.1",
"hashbrown 0.15.5", "hashbrown 0.15.5",
"indexmap 2.14.0", "indexmap 2.14.0",
"semver", "semver",
@@ -5892,7 +5892,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bitflags 2.13.0", "bitflags 2.12.1",
"indexmap 2.14.0", "indexmap 2.14.0",
"log", "log",
"serde", "serde",
+1 -1
View File
@@ -39,12 +39,12 @@ tauri = { version = "2", features = [] }
tauri-plugin-dialog = "2" tauri-plugin-dialog = "2"
tauri-plugin-shell = "2" tauri-plugin-shell = "2"
tauri-plugin-store = "2" tauri-plugin-store = "2"
time = { version = "0.3", features = ["local-offset"] }
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
tokio-util = { version = "0.7", features = ["codec", "rt"] } tokio-util = { version = "0.7", features = ["codec", "rt"] }
tracing = "0.1" tracing = "0.1"
tracing-log = "0.2" tracing-log = "0.2"
tracing-subscriber = "0.3" tracing-subscriber = "0.3"
time = { version = "0.3", features = ["local-offset"] }
uuid = { version = "1", features = ["v7"] } uuid = { version = "1", features = ["v7"] }
walkdir = "2" walkdir = "2"
windows = { windows = {
@@ -33,12 +33,12 @@ tauri = { workspace = true }
tauri-plugin-dialog = { workspace = true } tauri-plugin-dialog = { workspace = true }
tauri-plugin-shell = { workspace = true } tauri-plugin-shell = { workspace = true }
tauri-plugin-store = { workspace = true } tauri-plugin-store = { workspace = true }
time = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
tokio-util = { workspace = true } tokio-util = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
tracing-log = { workspace = true } tracing-log = { workspace = true }
tracing-subscriber = { workspace = true } tracing-subscriber = { workspace = true }
time = { workspace = true }
walkdir = { workspace = true } walkdir = { workspace = true }
[build-dependencies] [build-dependencies]
@@ -1,7 +1,7 @@
use std::{ use std::{
collections::{HashMap, HashSet}, collections::{HashMap, HashSet},
fs::{self, OpenOptions}, fs::{self, OpenOptions},
io::{self, Read as _, Seek as _, SeekFrom, Write as _}, io::{Read as _, Seek as _, SeekFrom, Write as _},
net::SocketAddr, net::SocketAddr,
path::{Component, Path, PathBuf}, path::{Component, Path, PathBuf},
sync::{Arc, Mutex, OnceLock}, sync::{Arc, Mutex, OnceLock},
@@ -87,7 +87,6 @@ struct LanSpreadState {
catalog: Arc<RwLock<GameCatalog>>, catalog: Arc<RwLock<GameCatalog>>,
unpack_logs: Arc<RwLock<Vec<UnpackLogEntry>>>, unpack_logs: Arc<RwLock<Vec<UnpackLogEntry>>>,
state_dir: OnceLock<PathBuf>, state_dir: OnceLock<PathBuf>,
main_log_sink: OnceLock<MainLogSink>,
active_outbound_transfers: OutboundTransfers, active_outbound_transfers: OutboundTransfers,
outbound_transfer_emit: Arc<RwLock<OutboundTransferEmitState>>, outbound_transfer_emit: Arc<RwLock<OutboundTransferEmitState>>,
} }
@@ -145,14 +144,6 @@ struct UnpackLogEntry {
struct MainLogLinePayload { struct MainLogLinePayload {
line: String, line: String,
level: String, level: String,
sequence: Option<u64>,
}
#[derive(Clone, Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct MainLogHistoryPayload {
contents: String,
last_sequence: u64,
} }
struct SidecarUnpacker { struct SidecarUnpacker {
@@ -183,27 +174,15 @@ async fn get_unpack_logs(
} }
#[tauri::command] #[tauri::command]
async fn get_main_logs( async fn get_main_logs(app_handle: tauri::AppHandle) -> tauri::Result<String> {
app_handle: tauri::AppHandle,
state: tauri::State<'_, LanSpreadState>,
) -> tauri::Result<MainLogHistoryPayload> {
if let Some(sink) = state.inner().main_log_sink.get() {
return Ok(sink.read_history()?);
}
let state_dir = app_handle.path().app_data_dir()?; let state_dir = app_handle.path().app_data_dir()?;
fs::create_dir_all(&state_dir)?; fs::create_dir_all(&state_dir)?;
let path = main_log_path(&state_dir); let path = main_log_path(&state_dir);
trim_main_log_file(&path)?;
match read_main_log_file_to_limit(&path, MAX_MAIN_LOG_BYTES) { match fs::read_to_string(&path) {
Ok(contents) => Ok(MainLogHistoryPayload { Ok(contents) => Ok(contents),
contents, Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(String::new()),
last_sequence: 0,
}),
Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(MainLogHistoryPayload {
contents: String::new(),
last_sequence: 0,
}),
Err(err) => Err(err.into()), Err(err) => Err(err.into()),
} }
} }
@@ -1405,70 +1384,33 @@ fn main_log_path(state_dir: &Path) -> PathBuf {
state_dir.join(MAIN_LOG_FILE_NAME) state_dir.join(MAIN_LOG_FILE_NAME)
} }
#[cfg(test)] fn trim_main_log_file(path: &Path) -> std::io::Result<()> {
fn trim_main_log_file_to_limit(path: &Path, max_bytes: u64) -> io::Result<()> { trim_main_log_file_to_limit(path, MAX_MAIN_LOG_BYTES)
let mut file = match OpenOptions::new().read(true).write(true).open(path) { }
Ok(file) => file,
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(()), fn trim_main_log_file_to_limit(path: &Path, max_bytes: u64) -> std::io::Result<()> {
let metadata = match fs::metadata(path) {
Ok(metadata) => metadata,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(err) => return Err(err), Err(err) => return Err(err),
}; };
trim_main_log_file_to_limit_with_file(&mut file, max_bytes)
}
fn trim_main_log_file_to_limit_with_file(file: &mut fs::File, max_bytes: u64) -> io::Result<()> {
let metadata = file.metadata()?;
if metadata.len() <= max_bytes { if metadata.len() <= max_bytes {
file.seek(SeekFrom::End(0))?;
return Ok(()); return Ok(());
} }
let tail = if max_bytes == 0 { if max_bytes == 0 {
String::new() fs::write(path, [])?;
} else { return Ok(());
}
let mut file = fs::File::open(path)?;
file.seek(SeekFrom::Start(metadata.len() - max_bytes))?; file.seek(SeekFrom::Start(metadata.len() - max_bytes))?;
let mut bytes = Vec::with_capacity(usize::try_from(max_bytes).unwrap_or(usize::MAX)); let mut bytes = Vec::with_capacity(usize::try_from(max_bytes).unwrap_or(usize::MAX));
file.read_to_end(&mut bytes)?; file.read_to_end(&mut bytes)?;
valid_utf8_tail(bytes) let tail = valid_utf8_tail(bytes);
}; fs::write(path, tail.as_bytes())
file.set_len(0)?;
file.seek(SeekFrom::Start(0))?;
file.write_all(tail.as_bytes())?;
file.seek(SeekFrom::End(0))?;
Ok(())
}
fn read_main_log_file_to_limit(path: &Path, max_bytes: u64) -> io::Result<String> {
let mut file = fs::File::open(path)?;
read_main_log_file_to_limit_with_file(&mut file, max_bytes)
}
fn read_main_log_file_to_limit_with_file(
file: &mut fs::File,
max_bytes: u64,
) -> io::Result<String> {
let metadata = file.metadata()?;
if metadata.len() == 0 || max_bytes == 0 {
file.seek(SeekFrom::End(0))?;
return Ok(String::new());
}
let start = metadata.len().saturating_sub(max_bytes);
file.seek(SeekFrom::Start(start))?;
let capacity = usize::try_from(metadata.len() - start).unwrap_or(usize::MAX);
let mut bytes = Vec::with_capacity(capacity);
file.read_to_end(&mut bytes)?;
file.seek(SeekFrom::End(0))?;
if start == 0 {
Ok(String::from_utf8_lossy(&bytes).into_owned())
} else {
Ok(valid_utf8_tail(bytes))
}
} }
fn valid_utf8_tail(bytes: Vec<u8>) -> String { fn valid_utf8_tail(bytes: Vec<u8>) -> String {
@@ -1485,13 +1427,7 @@ fn valid_utf8_tail(bytes: Vec<u8>) -> String {
struct MainLogSink { struct MainLogSink {
app_handle: AppHandle, app_handle: AppHandle,
path: PathBuf, path: PathBuf,
file_state: Arc<Mutex<MainLogFileState>>, file_lock: Arc<Mutex<()>>,
}
#[derive(Default)]
struct MainLogFileState {
file: Option<fs::File>,
last_sequence: u64,
} }
impl MainLogSink { impl MainLogSink {
@@ -1499,101 +1435,54 @@ impl MainLogSink {
Self { Self {
app_handle, app_handle,
path, path,
file_state: Arc::new(Mutex::new(MainLogFileState::default())), file_lock: Arc::new(Mutex::new(())),
} }
} }
fn write_line(&self, line: String, level: Level) { fn write_line(&self, line: String, level: Level) {
write_main_log_stdout(&line); write_main_log_stdout(&line);
let sequence = self.append_file_line(&line); self.append_file_line(&line);
let _ = self.app_handle.emit( let _ = self.app_handle.emit(
"main-log-line", "main-log-line",
MainLogLinePayload { MainLogLinePayload {
line, line,
level: level.as_str().to_string(), level: level.as_str().to_string(),
sequence,
}, },
); );
} }
fn read_history(&self) -> io::Result<MainLogHistoryPayload> { fn append_file_line(&self, line: &str) {
let mut file_state = self let Ok(_guard) = self.file_lock.lock() else {
.file_state return;
.lock()
.map_err(|_| io::Error::other("main log file lock poisoned"))?;
if file_state.file.is_none() && !self.path.exists() {
return Ok(MainLogHistoryPayload {
contents: String::new(),
last_sequence: file_state.last_sequence,
});
}
let contents = {
let file = self.cached_file(&mut file_state.file)?;
trim_main_log_file_to_limit_with_file(file, MAX_MAIN_LOG_BYTES)?;
read_main_log_file_to_limit_with_file(file, MAX_MAIN_LOG_BYTES)?
}; };
Ok(MainLogHistoryPayload { if let Some(parent) = self.path.parent() {
contents, let _ = fs::create_dir_all(parent);
last_sequence: file_state.last_sequence,
})
} }
fn append_file_line(&self, line: &str) -> Option<u64> { let Ok(mut file) = OpenOptions::new()
let Ok(mut file_state) = self.file_state.lock() else {
return None;
};
let write_result = self.cached_file(&mut file_state.file).and_then(|file| {
file.seek(SeekFrom::End(0))
.and_then(|_| writeln!(file, "{line}"))
});
if write_result.is_err() {
file_state.file = None;
return None;
}
file_state.last_sequence = file_state.last_sequence.saturating_add(1);
let sequence = file_state.last_sequence;
let should_trim = file_state.file.as_ref().is_some_and(|file| {
file.metadata().is_ok_and(|metadata| {
metadata.len() > MAX_MAIN_LOG_BYTES.saturating_add(MAIN_LOG_TRIM_SLACK_BYTES)
})
});
if should_trim && let Some(file) = file_state.file.as_mut() {
let _ = trim_main_log_file_to_limit_with_file(file, MAX_MAIN_LOG_BYTES);
}
Some(sequence)
}
fn cached_file<'a>(&self, file: &'a mut Option<fs::File>) -> io::Result<&'a mut fs::File> {
if file.is_none() {
*file = Some(open_main_log_file(&self.path)?);
}
file.as_mut()
.ok_or_else(|| io::Error::other("main log file was not opened"))
}
}
fn open_main_log_file(path: &Path) -> io::Result<fs::File> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
OpenOptions::new()
.create(true) .create(true)
.truncate(false) .append(true)
.read(true) .open(&self.path)
.write(true) else {
.open(path) return;
};
if writeln!(file, "{line}").is_err() {
return;
}
let should_trim = file
.metadata()
.is_ok_and(|metadata| {
metadata.len() > MAX_MAIN_LOG_BYTES.saturating_add(MAIN_LOG_TRIM_SLACK_BYTES)
});
if should_trim {
let _ = trim_main_log_file(&self.path);
}
}
} }
struct MainLogLayer { struct MainLogLayer {
@@ -1628,8 +1517,13 @@ where
.unwrap_or_else(|| metadata.target().to_string()); .unwrap_or_else(|| metadata.target().to_string());
let message = visitor.into_message(); let message = visitor.into_message();
let (date, time) = current_main_log_timestamp(); let (date, time) = current_main_log_timestamp();
let line = let line = format_main_log_line_parts(
format_main_log_line_parts(&date, &time, &target, metadata.level().as_str(), &message); &date,
&time,
&target,
metadata.level().as_str(),
&message,
);
self.sink.write_line(line, *metadata.level()); self.sink.write_line(line, *metadata.level());
} }
@@ -1728,9 +1622,8 @@ fn format_main_log_line_parts(
message: &str, message: &str,
) -> String { ) -> String {
format!( format!(
"[{date}][{time}][{}][{level}] {}", "[{date}][{time}][{}][{level}] {message}",
normalize_main_log_target(target), normalize_main_log_target(target)
normalize_main_log_message(message)
) )
} }
@@ -1748,8 +1641,12 @@ fn write_main_log_stdout(line: &str) {
let _ = writeln!(stdout, "{line}"); let _ = writeln!(stdout, "{line}");
} }
fn init_main_logging(sink: MainLogSink) -> Result<(), Box<dyn std::error::Error>> { fn init_main_logging(
let subscriber = tracing_subscriber::registry().with(MainLogLayer::new(sink)); app_handle: AppHandle,
path: PathBuf,
) -> Result<(), Box<dyn std::error::Error>> {
let subscriber =
tracing_subscriber::registry().with(MainLogLayer::new(MainLogSink::new(app_handle, path)));
tracing::subscriber::set_global_default(subscriber)?; tracing::subscriber::set_global_default(subscriber)?;
tracing_log::LogTracer::builder() tracing_log::LogTracer::builder()
.with_max_level(log::LevelFilter::Info) .with_max_level(log::LevelFilter::Info)
@@ -2697,12 +2594,8 @@ pub fn run() {
.setup(move |app| { .setup(move |app| {
let state_dir = app.path().app_data_dir()?; let state_dir = app.path().app_data_dir()?;
std::fs::create_dir_all(&state_dir)?; std::fs::create_dir_all(&state_dir)?;
let main_log_sink = MainLogSink::new(app.handle().clone(), main_log_path(&state_dir)); init_main_logging(app.handle().clone(), main_log_path(&state_dir))?;
let state = app.state::<LanSpreadState>(); let state = app.state::<LanSpreadState>();
if state.main_log_sink.set(main_log_sink.clone()).is_err() {
log::warn!("main log sink was already initialized");
}
init_main_logging(main_log_sink)?;
let unpack_logs = load_unpack_logs(&state_dir); let unpack_logs = load_unpack_logs(&state_dir);
tauri::async_runtime::block_on(async { tauri::async_runtime::block_on(async {
*state.unpack_logs.write().await = unpack_logs; *state.unpack_logs.write().await = unpack_logs;
@@ -3,28 +3,114 @@ import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event'; import { listen } from '@tauri-apps/api/event';
import { SegmentedRadio } from './components/SegmentedRadio'; import { SegmentedRadio } from './components/SegmentedRadio';
import {
capLogRows,
consumeLoadedHistoryRow,
dedupeBufferedRows,
formatCount,
LEVEL_FILTER_MIN,
LEVEL_FILTER_OPTIONS,
LEVEL_ORDER,
lineCountsFromRows,
type LevelFilter,
type MainLogHistoryPayload,
type MainLogLinePayload,
type MainLogRow,
rowFromPayload,
rowsFromHistory,
} from './lib/mainLogs';
import './MainLogsWindow.css'; import './MainLogsWindow.css';
export const isMainLogsView = (): boolean => export const isMainLogsView = (): boolean =>
new URLSearchParams(window.location.search).get('view') === 'main-logs'; new URLSearchParams(window.location.search).get('view') === 'main-logs';
const LOG_LEVELS = ['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR'] as const;
type LogLevel = typeof LOG_LEVELS[number];
type LevelFilter = 'all' | 'debug' | 'info' | 'warn' | 'error';
interface MainLogLinePayload {
line: string;
level: string;
}
interface MainLogRow {
line: string;
level: LogLevel;
}
const LEVEL_ORDER: Record<LogLevel, number> = {
TRACE: 0,
DEBUG: 1,
INFO: 2,
WARN: 3,
ERROR: 4,
};
const LEVEL_FILTER_MIN: Record<LevelFilter, number> = {
all: LEVEL_ORDER.TRACE,
debug: LEVEL_ORDER.DEBUG,
info: LEVEL_ORDER.INFO,
warn: LEVEL_ORDER.WARN,
error: LEVEL_ORDER.ERROR,
};
const LEVEL_FILTER_OPTIONS: ReadonlyArray<{ value: LevelFilter; label: string }> = [
{ value: 'all', label: 'All' },
{ value: 'debug', label: 'Debug+' },
{ value: 'info', label: 'Info+' },
{ value: 'warn', label: 'Warn+' },
{ value: 'error', label: 'Error only' },
];
const MAX_IN_MEMORY_LOG_ROWS = 12_000;
const MAX_IN_MEMORY_LOG_CHARS = 2 * 1024 * 1024;
const isLogLevel = (value: string | undefined): value is LogLevel =>
typeof value === 'string' && (LOG_LEVELS as readonly string[]).includes(value);
const normalizeLogLevel = (value: string | undefined): LogLevel => {
const upper = value?.toUpperCase();
return isLogLevel(upper) ? upper : 'INFO';
};
const parseLogLevelFromLine = (line: string): LogLevel => {
const match = line.match(/\[(TRACE|DEBUG|INFO|WARN|ERROR)\](?:\s|$)/);
return normalizeLogLevel(match?.[1]);
};
const rowFromPayload = (payload: MainLogLinePayload): MainLogRow => ({
line: payload.line,
level: normalizeLogLevel(payload.level),
});
const rowsFromHistory = (text: string): MainLogRow[] =>
text
.split(/\r?\n/)
.filter(line => line.length > 0)
.map(line => ({ line, level: parseLogLevelFromLine(line) }));
const capLogRows = (rows: MainLogRow[]): MainLogRow[] => {
let charCount = 0;
const capped: MainLogRow[] = [];
for (let index = rows.length - 1; index >= 0; index -= 1) {
const row = rows[index];
const rowChars = row.line.length + 1;
const wouldExceedRows = capped.length >= MAX_IN_MEMORY_LOG_ROWS;
const wouldExceedChars = charCount + rowChars > MAX_IN_MEMORY_LOG_CHARS;
if (wouldExceedRows || (wouldExceedChars && capped.length > 0)) {
break;
}
capped.push(row);
charCount += rowChars;
}
return capped.reverse();
};
const dedupeBufferedRows = (historyRows: MainLogRow[], bufferedRows: MainLogRow[]): MainLogRow[] => {
const lineCounts = new Map<string, number>();
historyRows.forEach(row => {
lineCounts.set(row.line, (lineCounts.get(row.line) ?? 0) + 1);
});
return bufferedRows.filter(row => {
const count = lineCounts.get(row.line) ?? 0;
if (count <= 0) return true;
lineCounts.set(row.line, count - 1);
return false;
});
};
const formatCount = (count: number, noun: string): string =>
`${count.toLocaleString()} ${noun}${count === 1 ? '' : 's'}`;
export const MainLogsWindow = () => { export const MainLogsWindow = () => {
const [logs, setLogs] = useState<MainLogRow[]>([]); const [logs, setLogs] = useState<MainLogRow[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -41,8 +127,6 @@ export const MainLogsWindow = () => {
const initialBufferRef = useRef<MainLogRow[]>([]); const initialBufferRef = useRef<MainLogRow[]>([]);
const pausedBufferRef = useRef<MainLogRow[]>([]); const pausedBufferRef = useRef<MainLogRow[]>([]);
const pausedRef = useRef(false); const pausedRef = useRef(false);
const lastHistorySequenceRef = useRef(0);
const historyLineCountsRef = useRef<Map<string, number>>(new Map());
const appendVisibleRows = useCallback((rows: MainLogRow[]) => { const appendVisibleRows = useCallback((rows: MainLogRow[]) => {
setLogs(current => capLogRows([...current, ...rows])); setLogs(current => capLogRows([...current, ...rows]));
@@ -62,15 +146,6 @@ export const MainLogsWindow = () => {
initialBufferRef.current = capLogRows([...initialBufferRef.current, row]); initialBufferRef.current = capLogRows([...initialBufferRef.current, row]);
return; return;
} }
if (
consumeLoadedHistoryRow(
historyLineCountsRef.current,
row,
lastHistorySequenceRef.current,
)
) {
return;
}
if (pausedRef.current) { if (pausedRef.current) {
bufferPausedRows([row]); bufferPausedRows([row]);
return; return;
@@ -84,19 +159,12 @@ export const MainLogsWindow = () => {
handleIncomingRow(rowFromPayload(event.payload)); handleIncomingRow(rowFromPayload(event.payload));
}); });
const history = await invoke<MainLogHistoryPayload>('get_main_logs'); const history = await invoke<string>('get_main_logs');
if (cancelled) return; if (cancelled) return;
lastHistorySequenceRef.current = history.lastSequence; const historyRows = rowsFromHistory(history);
const historyRows = rowsFromHistory(history.contents); const liveRows = dedupeBufferedRows(historyRows, initialBufferRef.current);
const historyLineCounts = lineCountsFromRows(historyRows);
const liveRows = dedupeBufferedRows(
historyLineCounts,
initialBufferRef.current,
lastHistorySequenceRef.current,
);
initialBufferRef.current = []; initialBufferRef.current = [];
historyLineCountsRef.current = historyLineCounts;
historyLoadedRef.current = true; historyLoadedRef.current = true;
if (pausedRef.current) { if (pausedRef.current) {
@@ -124,8 +192,6 @@ export const MainLogsWindow = () => {
cancelled = true; cancelled = true;
historyLoadedRef.current = false; historyLoadedRef.current = false;
initialBufferRef.current = []; initialBufferRef.current = [];
lastHistorySequenceRef.current = 0;
historyLineCountsRef.current = new Map();
unlisten?.(); unlisten?.();
}; };
}, [appendVisibleRows, bufferPausedRows]); }, [appendVisibleRows, bufferPausedRows]);
@@ -149,8 +215,6 @@ export const MainLogsWindow = () => {
}); });
}, [levelFilter, logs, regex]); }, [levelFilter, logs, regex]);
const lastVisibleRow = filteredRows.length > 0 ? filteredRows[filteredRows.length - 1] : null;
useEffect(() => { useEffect(() => {
if (!autoScroll) return; if (!autoScroll) return;
const viewport = viewportRef.current; const viewport = viewportRef.current;
@@ -159,7 +223,7 @@ export const MainLogsWindow = () => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
viewport.scrollTop = viewport.scrollHeight; viewport.scrollTop = viewport.scrollHeight;
}); });
}, [autoScroll, filteredRows.length, lastVisibleRow?.id]); }, [autoScroll, filteredRows.length]);
const flushPausedRows = useCallback(() => { const flushPausedRows = useCallback(() => {
const buffered = pausedBufferRef.current; const buffered = pausedBufferRef.current;
@@ -264,9 +328,9 @@ export const MainLogsWindow = () => {
<div className="main-log-empty"> <div className="main-log-empty">
{logs.length === 0 ? 'No application logs recorded yet.' : 'No log lines match the current filters.'} {logs.length === 0 ? 'No application logs recorded yet.' : 'No log lines match the current filters.'}
</div> </div>
) : filteredRows.map(row => ( ) : filteredRows.map((row, index) => (
<div <div
key={row.id} key={`${index}-${row.line}`}
className={`main-log-line level-${row.level.toLowerCase()}`} className={`main-log-line level-${row.level.toLowerCase()}`}
> >
{row.line} {row.line}
@@ -1,148 +0,0 @@
export const LOG_LEVELS = ['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR'] as const;
export type LogLevel = typeof LOG_LEVELS[number];
export type LevelFilter = 'all' | 'debug' | 'info' | 'warn' | 'error';
export interface MainLogLinePayload {
line: string;
level: string;
sequence?: number | null;
}
export interface MainLogHistoryPayload {
contents: string;
lastSequence: number;
}
export interface MainLogRow {
id: string;
line: string;
level: LogLevel;
sequence?: number;
}
export const LEVEL_ORDER: Record<LogLevel, number> = {
TRACE: 0,
DEBUG: 1,
INFO: 2,
WARN: 3,
ERROR: 4,
};
export const LEVEL_FILTER_MIN: Record<LevelFilter, number> = {
all: LEVEL_ORDER.TRACE,
debug: LEVEL_ORDER.DEBUG,
info: LEVEL_ORDER.INFO,
warn: LEVEL_ORDER.WARN,
error: LEVEL_ORDER.ERROR,
};
export const LEVEL_FILTER_OPTIONS: ReadonlyArray<{ value: LevelFilter; label: string }> = [
{ value: 'all', label: 'All' },
{ value: 'debug', label: 'Debug+' },
{ value: 'info', label: 'Info+' },
{ value: 'warn', label: 'Warn+' },
{ value: 'error', label: 'Error only' },
];
const MAX_IN_MEMORY_LOG_ROWS = 12_000;
const MAX_IN_MEMORY_LOG_CHARS = 2 * 1024 * 1024;
let nextSyntheticLogRowId = 0;
const syntheticLogRowId = (): string => {
nextSyntheticLogRowId += 1;
return `live-synthetic-${nextSyntheticLogRowId}`;
};
const isLogLevel = (value: string | undefined): value is LogLevel =>
typeof value === 'string' && (LOG_LEVELS as readonly string[]).includes(value);
export const normalizeLogLevel = (value: string | undefined): LogLevel => {
const upper = value?.toUpperCase();
return isLogLevel(upper) ? upper : 'INFO';
};
export const parseLogLevelFromLine = (line: string): LogLevel => {
const match = line.match(/\[(TRACE|DEBUG|INFO|WARN|ERROR)\](?:\s|$)/);
return normalizeLogLevel(match?.[1]);
};
export const rowFromPayload = (payload: MainLogLinePayload): MainLogRow => {
const sequence = typeof payload.sequence === 'number' ? payload.sequence : undefined;
return {
id: sequence === undefined ? syntheticLogRowId() : `live-${sequence}`,
line: payload.line,
level: normalizeLogLevel(payload.level),
sequence,
};
};
export const rowsFromHistory = (text: string): MainLogRow[] =>
text
.split(/\r?\n/)
.filter(line => line.length > 0)
.map((line, index) => ({
id: `history-${index}`,
line,
level: parseLogLevelFromLine(line),
}));
export const capLogRows = (rows: MainLogRow[]): MainLogRow[] => {
let charCount = 0;
const capped: MainLogRow[] = [];
for (let index = rows.length - 1; index >= 0; index -= 1) {
const row = rows[index];
const rowChars = row.line.length + 1;
const wouldExceedRows = capped.length >= MAX_IN_MEMORY_LOG_ROWS;
const wouldExceedChars = charCount + rowChars > MAX_IN_MEMORY_LOG_CHARS;
if (wouldExceedRows || (wouldExceedChars && capped.length > 0)) {
break;
}
capped.push(row);
charCount += rowChars;
}
return capped.reverse();
};
export const rowWasLoadedInHistory = (row: MainLogRow, lastHistorySequence: number): boolean =>
typeof row.sequence === 'number' && row.sequence <= lastHistorySequence;
export const lineCountsFromRows = (rows: MainLogRow[]): Map<string, number> => {
const lineCounts = new Map<string, number>();
rows.forEach(row => {
lineCounts.set(row.line, (lineCounts.get(row.line) ?? 0) + 1);
});
return lineCounts;
};
export const consumeLoadedHistoryRow = (
historyLineCounts: Map<string, number>,
row: MainLogRow,
lastHistorySequence: number,
): boolean => {
if (!rowWasLoadedInHistory(row, lastHistorySequence)) return false;
const count = historyLineCounts.get(row.line) ?? 0;
if (count <= 0) return false;
if (count === 1) {
historyLineCounts.delete(row.line);
} else {
historyLineCounts.set(row.line, count - 1);
}
return true;
};
export const dedupeBufferedRows = (
historyLineCounts: Map<string, number>,
bufferedRows: MainLogRow[],
lastHistorySequence: number,
): MainLogRow[] =>
bufferedRows.filter(row => !consumeLoadedHistoryRow(historyLineCounts, row, lastHistorySequence));
export const formatCount = (count: number, noun: string): string =>
`${count.toLocaleString()} ${noun}${count === 1 ? '' : 's'}`;
@@ -1,55 +0,0 @@
import {
dedupeBufferedRows,
lineCountsFromRows,
rowFromPayload,
rowsFromHistory,
rowWasLoadedInHistory,
} from '../src/lib/mainLogs.ts';
const assertEquals = <T>(actual: T, expected: T, message: string) => {
if (actual !== expected) {
throw new Error(`${message}: expected ${expected}, got ${actual}`);
}
};
Deno.test('history rows parse levels and stable ids', () => {
const rows = rowsFromHistory('[2026-06-07][12:00:00][app][WARN] careful\nplain line\n');
assertEquals(rows.length, 2, 'history should skip trailing empty line');
assertEquals(rows[0].id, 'history-0', 'history id should include row position');
assertEquals(rows[0].level, 'WARN', 'explicit level should be parsed');
assertEquals(rows[1].level, 'INFO', 'unknown level should default to info');
});
Deno.test('buffered main log rows covered by history sequence are removed', () => {
const historyRows = rowsFromHistory('[2026-06-07][12:00:01][app][INFO] included\n');
const included = rowFromPayload({
line: '[2026-06-07][12:00:01][app][INFO] included',
level: 'INFO',
sequence: 4,
});
const fresh = rowFromPayload({
line: '[2026-06-07][12:00:02][app][INFO] fresh',
level: 'INFO',
sequence: 5,
});
const deduped = dedupeBufferedRows(lineCountsFromRows(historyRows), [included, fresh], 4);
assertEquals(rowWasLoadedInHistory(included, 4), true, 'included row should match history');
assertEquals(deduped.length, 1, 'only fresh row should remain');
assertEquals(deduped[0].line, fresh.line, 'fresh row should not be dropped');
});
Deno.test('buffered rows missing from trimmed history are retained', () => {
const retained = rowFromPayload({
line: '[2026-06-07][12:00:00][app][INFO] trimmed out',
level: 'INFO',
sequence: 2,
});
const deduped = dedupeBufferedRows(new Map(), [retained], 4);
assertEquals(deduped.length, 1, 'trimmed row should remain visible');
assertEquals(deduped[0].line, retained.line, 'trimmed row should be preserved');
});