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
This commit is contained in:
Rodrigo Rodriguez (Pragmatismo) 2025-12-09 08:06:30 -03:00
parent 824b12365b
commit 715a60315e
3 changed files with 277 additions and 22 deletions

View file

@ -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();
}
}

View file

@ -1,9 +1,12 @@
use chrono::Local;
use log::{LevelFilter, Log, Metadata, Record, SetLoggerError};
use std::sync::{Arc, Mutex};
pub struct LogPanel {
logs: Vec<String>,
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<Mutex<LogPanel>>,
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<Mutex<LogPanel>>) -> Result<(), SetLoggerError> {
let logger = Box::new(UiLogger {
log_panel,

View file

@ -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;
}
_ => {}
},