use std::path::Path; use log_viewer_core::io::progressive_reader::{ IndexerMessage, ProgressiveFileReader, spawn_indexer, }; use log_viewer_core::watcher::file_watcher::FileWatcher; use super::{App, AppLoadingState}; impl App { pub fn load_file(&mut self, path: &str) -> anyhow::Result<()> { // ── Phase 1: Pure computation, no mutation of self ────────── // If any step fails and returns ?, self remains completely untouched, // preserving the old file's watcher, loading_state, and file_path. let mut pfr = ProgressiveFileReader::open(Path::new(path)).map_err(|e| anyhow::anyhow!("{e}"))?; let new_loading_state = if pfr.is_sampling() { // Cache miss: spawn background indexer let (cancel_tx, cancel_rx) = crossbeam_channel::bounded(1); let generation = pfr.generation(); let indexer_rx = spawn_indexer(pfr.path().to_path_buf(), generation, 80, false, cancel_rx); pfr = ProgressiveFileReader::with_channels( Path::new(path), cancel_tx, indexer_rx, generation, ) .map_err(|e| anyhow::anyhow!("{e}"))?; let estimated = pfr.line_count() as u64; AppLoadingState::Loading { reader: pfr, estimated_lines: estimated, progress_percent: 0.0, } } else { // Cache hit: Ready state AppLoadingState::Ready { reader: pfr } }; let new_watcher = FileWatcher::watch(Path::new(path)).ok(); // ── Phase 2: Commit — swap self to new state ─────────────── // SAFETY: Do NOT add any fallible operations (with ?) below this point. // The old file_watcher and loading_state are dropped here, cancelling // any background indexer for the previous file. self.file_watcher = new_watcher; self.loading_state = new_loading_state; self.file_path = Some(path.to_string()); // Reset UI state for the new file self.cursor_line = 0; self.v_offset = 0; self.v_sub_offset = 0; self.cursor_sub_offset = 0; self.viewport_cache.invalidate(); self.last_g_press = None; self.json_format = false; self.mode = super::AppMode::Normal; self.reload_after_loading = false; Ok(()) } /// Poll the background indexer for progress/completion. /// Transitions Loading → Ready when indexing completes. /// Must be called every frame in the event loop. pub fn poll_background_indexer(&mut self) { // Poll visual height rebuild (Ready state only). // Recalibrate v_offset only when VHI actually changed. if let AppLoadingState::Ready { reader } = &mut self.loading_state && let Some(index) = reader.poll_visual_height_rebuild() { if let log_viewer_core::io::progressive_reader::ReaderState::Ready { visual_height_index, .. } = &mut reader.state { *visual_height_index = Some(index); } let logical_top = self.v_offset.min(self.total_lines().saturating_sub(1)); let sub = self.v_sub_offset; let first_visual = self.cursor_to_first_visual_row(logical_top); let line_height = self .get_visual_height_index() .map_or(1, |idx| idx.visual_height_of_line(logical_top)); self.v_offset = first_visual.saturating_add(sub.min(line_height.saturating_sub(1))); self.v_sub_offset = 0; self.clamp_cursor_sub_offset_no_vhi(); self.clamp_v_offset(); self.viewport_cache.invalidate(); } // Poll main indexer (Loading state) let old_state = std::mem::replace(&mut self.loading_state, AppLoadingState::Empty); if let AppLoadingState::Loading { mut reader, estimated_lines, mut progress_percent, } = old_state { if let Some(msg) = reader.poll_indexer() { match msg { IndexerMessage::Progress { percent, .. } => { progress_percent = percent; self.loading_state = AppLoadingState::Loading { reader, estimated_lines, progress_percent, }; } IndexerMessage::Complete { reader: fr, visual_height_index, .. } => { let saved_cursor = self.cursor_line; reader.set_ready(fr, visual_height_index); self.loading_state = AppLoadingState::Ready { reader }; self.viewport_cache.invalidate(); // Clamp cursor if exact count < estimated self.cursor_line = saved_cursor.min(self.total_lines().saturating_sub(1)); // Loading uses 1:1 logical-line offsets; Ready uses visual-row // offsets derived from the prefix-sum index. Recompute v_offset // so the same logical line stays visible (falls back to 1:1 when // the index is absent, which is the case right after invalidate). self.v_offset = self.cursor_to_first_visual_row(self.cursor_line); self.clamp_v_offset(); self.v_sub_offset = 0; self.cursor_sub_offset = 0; // Gutter width changes (~N → N) shift content_width, so any // VisualHeightIndex built with the old width is stale. let (new_offset, new_sub) = self.rebase_offset_for_invalidate(); if let AppLoadingState::Ready { reader } = &mut self.loading_state { reader.invalidate_visual_height_index(); } self.v_offset = new_offset; self.v_sub_offset = new_sub; self.cursor_sub_offset = 0; if self.reload_after_loading { self.reload_after_loading = false; self.reload_ready_reader(); } } IndexerMessage::Error { message, .. } => { self.loading_state = AppLoadingState::Error(message); self.reload_after_loading = false; } } } else { self.loading_state = AppLoadingState::Loading { reader, estimated_lines, progress_percent, }; } } else { self.loading_state = old_state; } } }