From 39b4656b8fde6069db80a7c318af2e8de228ddc4 Mon Sep 17 00:00:00 2001 From: "Rodrigo Rodriguez (Pragmatismo)" Date: Tue, 9 Dec 2025 08:06:30 -0300 Subject: [PATCH] feat(console): Add scrolling support for System Logs and Editor panels System Logs: - Add scroll_offset tracking with auto-scroll to bottom on new logs - Up/Down/j/k keys to scroll line by line - PageUp/PageDown for page scrolling - Home/End to jump to top/bottom - Show scroll indicators in title: [^v], [SCROLL] when not auto-scrolling - Display log count in title Editor: - Fix scroll_offset to follow cursor when moving up/down - Add PageUp/PageDown for faster navigation - Add Ctrl+Home/Ctrl+End to jump to start/end of file - ensure_cursor_visible() keeps cursor in view Tab Navigation: - FileTree -> Editor (if open) or Logs -> Chat -> back to start - Consistent cycling through all panels --- src/console/editor.rs | 77 ++++++++++++++++++++++++- src/console/log_panel.rs | 104 ++++++++++++++++++++++++++++++++-- src/console/mod.rs | 118 +++++++++++++++++++++++++++++++++------ 3 files changed, 277 insertions(+), 22 deletions(-) diff --git a/src/console/editor.rs b/src/console/editor.rs index 4b595b967..e9624a9c4 100644 --- a/src/console/editor.rs +++ b/src/console/editor.rs @@ -1,6 +1,7 @@ use crate::shared::state::AppState; use color_eyre::Result; use std::sync::Arc; + pub struct Editor { file_path: String, bucket: String, @@ -8,6 +9,7 @@ pub struct Editor { content: String, cursor_pos: usize, scroll_offset: usize, + visible_lines: usize, modified: bool, } @@ -44,6 +46,7 @@ impl Editor { content, cursor_pos: 0, scroll_offset: 0, + visible_lines: 20, modified: false, }) } @@ -63,11 +66,34 @@ impl Editor { pub fn file_path(&self) -> &str { &self.file_path } + pub fn set_visible_lines(&mut self, lines: usize) { + self.visible_lines = lines.max(5); + } + + fn get_cursor_line(&self) -> usize { + self.content[..self.cursor_pos].lines().count() + } + + fn ensure_cursor_visible(&mut self) { + let cursor_line = self.get_cursor_line(); + + // Scroll up if cursor is above visible area + if cursor_line < self.scroll_offset { + self.scroll_offset = cursor_line; + } + + // Scroll down if cursor is below visible area (leave some margin) + let visible = self.visible_lines.saturating_sub(3); + if cursor_line >= self.scroll_offset + visible { + self.scroll_offset = cursor_line.saturating_sub(visible) + 1; + } + } + pub fn render(&self, cursor_blink: bool) -> String { let lines: Vec<&str> = self.content.lines().collect(); let total_lines = lines.len().max(1); - let visible_lines = 25; - let cursor_line = self.content[..self.cursor_pos].lines().count(); + let visible_lines = self.visible_lines; + let cursor_line = self.get_cursor_line(); let cursor_col = self.content[..self.cursor_pos] .lines() .last() @@ -121,6 +147,7 @@ impl Editor { self.cursor_pos = (self.cursor_pos - prev_line_end - 1).min(prev_line_end); } } + self.ensure_cursor_visible(); } pub fn move_down(&mut self) { if let Some(next_line_start) = self.content[self.cursor_pos..].find('\n') { @@ -140,6 +167,37 @@ impl Editor { self.cursor_pos = target_pos; } } + self.ensure_cursor_visible(); + } + + pub fn page_up(&mut self) { + for _ in 0..self.visible_lines.saturating_sub(2) { + if self.content[..self.cursor_pos].rfind('\n').is_none() { + break; + } + self.move_up(); + } + } + + pub fn page_down(&mut self) { + for _ in 0..self.visible_lines.saturating_sub(2) { + if self.content[self.cursor_pos..].find('\n').is_none() { + break; + } + self.move_down(); + } + } + + pub fn scroll_up(&mut self) { + self.scroll_offset = self.scroll_offset.saturating_sub(1); + } + + pub fn scroll_down(&mut self) { + let total_lines = self.content.lines().count().max(1); + let max_scroll = total_lines.saturating_sub(self.visible_lines.saturating_sub(3)); + if self.scroll_offset < max_scroll { + self.scroll_offset += 1; + } } pub fn move_left(&mut self) { if self.cursor_pos > 0 { @@ -167,5 +225,20 @@ impl Editor { self.modified = true; self.content.insert(self.cursor_pos, '\n'); self.cursor_pos += 1; + self.ensure_cursor_visible(); + } + + pub fn goto_line(&mut self, line: usize) { + let lines: Vec<&str> = self.content.lines().collect(); + let target_line = line.saturating_sub(1).min(lines.len().saturating_sub(1)); + + self.cursor_pos = 0; + for (i, line_content) in lines.iter().enumerate() { + if i == target_line { + break; + } + self.cursor_pos += line_content.len() + 1; // +1 for newline + } + self.ensure_cursor_visible(); } } diff --git a/src/console/log_panel.rs b/src/console/log_panel.rs index 3bfd7c88b..bb9bab6f8 100644 --- a/src/console/log_panel.rs +++ b/src/console/log_panel.rs @@ -1,9 +1,12 @@ use chrono::Local; use log::{LevelFilter, Log, Metadata, Record, SetLoggerError}; use std::sync::{Arc, Mutex}; + pub struct LogPanel { logs: Vec, max_logs: usize, + scroll_offset: usize, + auto_scroll: bool, } impl std::fmt::Debug for LogPanel { @@ -11,39 +14,130 @@ impl std::fmt::Debug for LogPanel { f.debug_struct("LogPanel") .field("logs_count", &self.logs.len()) .field("max_logs", &self.max_logs) + .field("scroll_offset", &self.scroll_offset) + .field("auto_scroll", &self.auto_scroll) .finish() } } + impl LogPanel { pub fn new() -> Self { Self { logs: Vec::with_capacity(1000), max_logs: 1000, + scroll_offset: 0, + auto_scroll: true, } } + pub fn add_log(&mut self, entry: &str) { if self.logs.len() >= self.max_logs { self.logs.remove(0); + // Adjust scroll offset if we removed a log + if self.scroll_offset > 0 { + self.scroll_offset = self.scroll_offset.saturating_sub(1); + } } self.logs.push(entry.to_string()); + + // Auto-scroll to bottom if enabled + if self.auto_scroll { + self.scroll_to_bottom(); + } } - pub fn render(&self) -> String { - let visible_logs = if self.logs.len() > 10 { - &self.logs[self.logs.len() - 10..] + + pub fn scroll_up(&mut self, lines: usize) { + self.scroll_offset = self.scroll_offset.saturating_sub(lines); + self.auto_scroll = false; + } + + pub fn scroll_down(&mut self, lines: usize, visible_lines: usize) { + let max_scroll = self.logs.len().saturating_sub(visible_lines); + self.scroll_offset = (self.scroll_offset + lines).min(max_scroll); + + // Re-enable auto-scroll if we're at the bottom + if self.scroll_offset >= max_scroll { + self.auto_scroll = true; + } + } + + pub fn scroll_to_bottom(&mut self) { + // This will be adjusted when rendering based on visible lines + self.scroll_offset = usize::MAX; + self.auto_scroll = true; + } + + pub fn scroll_to_top(&mut self) { + self.scroll_offset = 0; + self.auto_scroll = false; + } + + pub fn page_up(&mut self, visible_lines: usize) { + self.scroll_up(visible_lines.saturating_sub(1)); + } + + pub fn page_down(&mut self, visible_lines: usize) { + self.scroll_down(visible_lines.saturating_sub(1), visible_lines); + } + + pub fn render(&self, visible_lines: usize) -> String { + if self.logs.is_empty() { + return " Waiting for logs...".to_string(); + } + + let total_logs = self.logs.len(); + + // Calculate actual scroll offset + let max_scroll = total_logs.saturating_sub(visible_lines); + let actual_offset = if self.scroll_offset == usize::MAX { + max_scroll } else { - &self.logs[..] + self.scroll_offset.min(max_scroll) }; + + // Get visible slice + let end = (actual_offset + visible_lines).min(total_logs); + let start = actual_offset; + + let visible_logs = &self.logs[start..end]; visible_logs.join("\n") } + + pub fn render_with_scroll_indicator(&self, visible_lines: usize) -> (String, bool, bool) { + let content = self.render(visible_lines); + let total_logs = self.logs.len(); + let max_scroll = total_logs.saturating_sub(visible_lines); + let actual_offset = if self.scroll_offset == usize::MAX { + max_scroll + } else { + self.scroll_offset.min(max_scroll) + }; + + let can_scroll_up = actual_offset > 0; + let can_scroll_down = actual_offset < max_scroll; + + (content, can_scroll_up, can_scroll_down) + } + + pub fn logs_count(&self) -> usize { + self.logs.len() + } + + pub fn is_auto_scroll(&self) -> bool { + self.auto_scroll + } } + pub struct UiLogger { log_panel: Arc>, filter: LevelFilter, } + impl Log for UiLogger { fn enabled(&self, metadata: &Metadata) -> bool { metadata.level() <= self.filter } + fn log(&self, record: &Record) { if self.enabled(record.metadata()) { let timestamp = Local::now().format("%H:%M:%S"); @@ -60,8 +154,10 @@ impl Log for UiLogger { } } } + fn flush(&self) {} } + pub fn init_logger(log_panel: Arc>) -> Result<(), SetLoggerError> { let logger = Box::new(UiLogger { log_panel, diff --git a/src/console/mod.rs b/src/console/mod.rs index 7db417dff..f4f944a8b 100644 --- a/src/console/mod.rs +++ b/src/console/mod.rs @@ -506,8 +506,10 @@ impl XtreeUI { .border_style(Style::default().fg(border)) .style(Style::default().bg(bg)); + // Calculate visible lines for log panel (area height minus borders) + let logs_visible_lines = main_chunks[2].height.saturating_sub(2) as usize; let logs_content = if let Ok(panel) = self.log_panel.lock() { - panel.render() + panel.render(logs_visible_lines) } else { String::from(" Waiting for logs...") }; @@ -720,12 +722,24 @@ impl XtreeUI { title_bg: Color, title_fg: Color, ) { + // Calculate visible lines (area height minus borders) + let visible_lines = area.height.saturating_sub(2) as usize; + let log_panel = self.log_panel.try_lock(); - let log_lines = if let Ok(panel) = log_panel { - panel.render() - } else { - "Loading logs...".to_string() - }; + let (log_lines, can_scroll_up, can_scroll_down, logs_count, auto_scroll) = + if let Ok(panel) = log_panel { + let (content, up, down) = panel.render_with_scroll_indicator(visible_lines); + ( + content, + up, + down, + panel.logs_count(), + panel.is_auto_scroll(), + ) + } else { + ("Loading logs...".to_string(), false, false, 0, true) + }; + let is_active = self.active_panel == ActivePanel::Logs; let border_color = if is_active { border_active @@ -740,8 +754,26 @@ impl XtreeUI { } else { Style::default().fg(title_fg).bg(title_bg) }; + + // Build title with scroll indicators + let scroll_indicator = if can_scroll_up && can_scroll_down { + " [^v] " + } else if can_scroll_up { + " [^] " + } else if can_scroll_down { + " [v] " + } else { + "" + }; + + let auto_indicator = if auto_scroll { "" } else { " [SCROLL] " }; + let title_text = format!( + " SYSTEM LOGS ({}) {}{}", + logs_count, scroll_indicator, auto_indicator + ); + let block = Block::default() - .title(Span::styled(" SYSTEM LOGS ", title_style)) + .title(Span::styled(title_text, title_style)) .borders(Borders::ALL) .border_style(Style::default().fg(border_color)) .style(Style::default().bg(bg)); @@ -816,7 +848,11 @@ impl XtreeUI { } } KeyCode::Tab => { - self.active_panel = ActivePanel::Chat; + if self.editor.is_some() { + self.active_panel = ActivePanel::Editor; + } else { + self.active_panel = ActivePanel::Logs; + } } KeyCode::Char('q') => { self.should_quit = true; @@ -841,6 +877,19 @@ impl XtreeUI { KeyCode::Down => editor.move_down(), KeyCode::Left => editor.move_left(), KeyCode::Right => editor.move_right(), + KeyCode::PageUp => editor.page_up(), + KeyCode::PageDown => editor.page_down(), + KeyCode::Home => { + if modifiers.contains(KeyModifiers::CONTROL) { + editor.goto_line(1); + } + } + KeyCode::End => { + if modifiers.contains(KeyModifiers::CONTROL) { + let line_count = editor.file_path().lines().count().max(1); + editor.goto_line(line_count); + } + } KeyCode::Char(c) => editor.insert_char(c), KeyCode::Backspace => editor.backspace(), KeyCode::Enter => editor.insert_newline(), @@ -857,9 +906,52 @@ impl XtreeUI { } } } + ActivePanel::Logs => match key { + KeyCode::Up | KeyCode::Char('k') => { + if let Ok(mut panel) = self.log_panel.lock() { + panel.scroll_up(1); + } + } + KeyCode::Down | KeyCode::Char('j') => { + if let Ok(mut panel) = self.log_panel.lock() { + panel.scroll_down(1, 10); // approximate visible lines + } + } + KeyCode::PageUp => { + if let Ok(mut panel) = self.log_panel.lock() { + panel.page_up(10); + } + } + KeyCode::PageDown => { + if let Ok(mut panel) = self.log_panel.lock() { + panel.page_down(10); + } + } + KeyCode::Home => { + if let Ok(mut panel) = self.log_panel.lock() { + panel.scroll_to_top(); + } + } + KeyCode::End => { + if let Ok(mut panel) = self.log_panel.lock() { + panel.scroll_to_bottom(); + } + } + KeyCode::Tab => { + self.active_panel = ActivePanel::Chat; + } + KeyCode::Char('q') => { + self.should_quit = true; + } + _ => {} + }, ActivePanel::Chat => match key { KeyCode::Tab => { - self.active_panel = ActivePanel::FileTree; + if self.editor.is_some() { + self.active_panel = ActivePanel::Editor; + } else { + self.active_panel = ActivePanel::FileTree; + } } KeyCode::Enter => { if let (Some(chat_panel), Some(file_tree), Some(app_state)) = @@ -887,13 +979,7 @@ impl XtreeUI { }, ActivePanel::Status => match key { KeyCode::Tab => { - self.active_panel = ActivePanel::Logs; - } - _ => {} - }, - ActivePanel::Logs => match key { - KeyCode::Tab => { - self.active_panel = ActivePanel::FileTree; + self.active_panel = ActivePanel::Chat; } _ => {} },