diff --git a/Cargo.lock b/Cargo.lock index c4df4e5..54695e1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -112,6 +112,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.9.0" @@ -257,7 +263,7 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags", + "bitflags 2.9.0", "crossterm_winapi", "mio", "parking_lot", @@ -349,6 +355,18 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "filetime" +version = "0.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35c0522e981e68cbfa8c3f978441a5f34b30b96e146b33cd3359176b50fe8586" +dependencies = [ + "cfg-if", + "libc", + "libredox", + "windows-sys 0.59.0", +] + [[package]] name = "fnv" version = "1.0.7" @@ -361,6 +379,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "fuchsia-cprng" version = "0.1.1" @@ -466,6 +493,26 @@ version = "2.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd" +[[package]] +name = "inotify" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f37dccff2791ab604f9babef0ba14fbe0be30bd368dc541e2b08d07c8aa908f3" +dependencies = [ + "bitflags 2.9.0", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + [[package]] name = "insta" version = "1.42.2" @@ -533,6 +580,26 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -549,6 +616,7 @@ dependencies = [ "clap", "insta", "itertools 0.14.0", + "notify", "ratatui", "regex", "serde", @@ -571,6 +639,17 @@ version = "0.2.172" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" +[[package]] +name = "libredox" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +dependencies = [ + "bitflags 2.9.0", + "libc", + "redox_syscall", +] + [[package]] name = "linked-hash-map" version = "0.5.6" @@ -642,6 +721,31 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "notify" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fee8403b3d66ac7b26aee6e40a897d85dc5ce26f44da36b8b73e987cc52e943" +dependencies = [ + "bitflags 2.9.0", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.59.0", +] + +[[package]] +name = "notify-types" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e0826a989adedc2a244799e823aece04662b66609d96af8dff7ac6df9a8925d" + [[package]] name = "nu-ansi-term" version = "0.46.0" @@ -845,7 +949,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" dependencies = [ - "bitflags", + "bitflags 2.9.0", "cassowary", "compact_str", "crossterm", @@ -877,7 +981,7 @@ version = "0.5.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2f103c6d277498fbceb16e84d317e2a400f160f46904d5f5410848c829511a3" dependencies = [ - "bitflags", + "bitflags 2.9.0", ] [[package]] @@ -924,7 +1028,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags", + "bitflags 2.9.0", "errno", "libc", "linux-raw-sys", @@ -943,6 +1047,15 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -1398,6 +1511,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" @@ -1487,6 +1610,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +dependencies = [ + "windows-sys 0.59.0", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -1649,7 +1781,7 @@ version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" dependencies = [ - "bitflags", + "bitflags 2.9.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index d1f7f6e..20d26a0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ chrono = "0.4.39" clap = { version = "4.5.31", features = ["derive", "env"] } insta = { version = "1.42.1", features = ["filters"] } itertools = "0.14.0" +notify = "8.0.0" ratatui = { version = "0.29.0", features = [ "serde", "unstable-rendered-line-info", diff --git a/src/app.rs b/src/app.rs index 2d7d201..c4324b9 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,16 +1,23 @@ +use std::path::PathBuf; + +use anyhow::{anyhow, Result}; +use core::fmt; +use ratatui::crossterm::{ + self, + event::{Event as TermEvent, KeyCode, KeyModifiers}, +}; +use tracing::{info, info_span, trace}; + use crate::{ commander::Commander, env::Env, + event::{AppEvent, EventSource}, ui::{ bookmarks_tab::BookmarksTab, command_log_tab::CommandLogTab, command_popup::CommandPopup, files_tab::FilesTab, log_tab::LogTab, Component, ComponentAction, }, ComponentInputResult, }; -use anyhow::{anyhow, Result}; -use core::fmt; -use ratatui::crossterm::event::{self, Event, KeyCode, KeyModifiers}; -use tracing::{info, info_span}; #[derive(PartialEq, Copy, Clone)] pub enum Tab { @@ -36,25 +43,34 @@ impl Tab { } pub struct App<'a> { + // application environment pub env: Env, + + // user interace pub current_tab: Tab, pub log: Option>, pub files: Option, pub bookmarks: Option>, pub command_log: Option, pub popup: Option>, + + // event handling + event_source: EventSource, } impl<'a> App<'a> { pub fn new(env: Env) -> Result> { Ok(App { env, + current_tab: Tab::Log, log: None, files: None, bookmarks: None, command_log: None, popup: None, + + event_source: EventSource::new(), }) } @@ -222,7 +238,33 @@ impl<'a> App<'a> { Ok(()) } - pub fn input(&mut self, event: Event, commander: &mut Commander) -> Result { + /// Set up threads that capture input and send AppEvents + pub fn launch_input_channel(&mut self) { + let jj_folder = PathBuf::from(&self.env.root.clone()) + .join(".jj") + .join("working_copy"); + self.event_source.launch_watcher(jj_folder); + self.event_source.launch_user_input(); + } + + /// Recieve an AppEvent if one is waiting. + pub fn try_recv_app_event(&mut self) -> Option { + self.event_source.try_recv() + } + + /// Process an AppEvent + pub fn input(&mut self, event: AppEvent, commander: &mut Commander) -> Result { + let AppEvent::UserInput(event) = event else { + if event != AppEvent::DirtyJj { + panic!("Unknown AppEvent {:?}", event); + } + // Trigger update of jj data - simulate focus gained + trace!("no-event trigger re-read of jj data"); + commander.jj_ignore_working_copy = true; + self.get_or_init_current_tab(commander)?.focus(commander)?; + commander.jj_ignore_working_copy = false; + return Ok(false); // do not terminate the app + }; if let Some(popup) = self.popup.as_mut() { match popup.input(commander, event.clone())? { ComponentInputResult::HandledAction(component_action) => { @@ -230,8 +272,8 @@ impl<'a> App<'a> { } ComponentInputResult::Handled => {} ComponentInputResult::NotHandled => { - if let Event::Key(key) = event { - if key.kind == event::KeyEventKind::Press { + if let TermEvent::Key(key) = event { + if key.kind == crossterm::event::KeyEventKind::Press { // Close if matches!( key.code, @@ -248,7 +290,7 @@ impl<'a> App<'a> { } } }; - } else if event == event::Event::FocusGained { + } else if event == TermEvent::FocusGained { self.get_or_init_current_tab(commander)?.focus(commander)?; } else { match self @@ -260,8 +302,8 @@ impl<'a> App<'a> { } ComponentInputResult::Handled => {} ComponentInputResult::NotHandled => { - if let Event::Key(key) = event { - if key.kind == event::KeyEventKind::Press { + if let TermEvent::Key(key) = event { + if key.kind == crossterm::event::KeyEventKind::Press { // Close if key.code == KeyCode::Char('q') || (key.modifiers.contains(KeyModifiers::CONTROL) diff --git a/src/commander/mod.rs b/src/commander/mod.rs index 76f2356..f9da413 100644 --- a/src/commander/mod.rs +++ b/src/commander/mod.rs @@ -78,6 +78,8 @@ pub struct Commander { pub env: Env, pub command_history: Arc>>, + /// If set, jj will not auto-update + pub jj_ignore_working_copy: bool, // Used for testing pub jj_config_toml: Option, pub force_no_color: bool, @@ -88,6 +90,7 @@ impl Commander { Self { env: env.clone(), command_history: Arc::new(Mutex::new(Vec::new())), + jj_ignore_working_copy: false, jj_config_toml: None, force_no_color: false, } @@ -152,6 +155,14 @@ impl Commander { command.args(args); command.args(get_output_args(!self.force_no_color && color, quiet)); + if self.jj_ignore_working_copy { + const IWC: &str = "--ignore-working-copy"; + let missing = command.get_args().find(|&a| a == IWC).is_none(); + if missing { + command.arg(IWC); + } + } + if let Some(jj_config_toml) = &self.jj_config_toml { command.args(vec!["--config-toml", jj_config_toml]); } diff --git a/src/event.rs b/src/event.rs new file mode 100644 index 0000000..610954e --- /dev/null +++ b/src/event.rs @@ -0,0 +1,189 @@ +//! The event module is where events are generated. This means +//! capture keyboard and mouse events from user, as well as listening +//! for file system notifications in case somebody used jj on this +//! repository. + +use std::{ + path::PathBuf, + sync::{mpsc, Arc, RwLock}, + thread, + time::{Duration, Instant}, +}; + +use notify::{RecursiveMode, Watcher}; +use ratatui::crossterm; +use tracing::{error, trace}; + +/// Minimum time between idle-events +const IDLE_TIMEOUT: Duration = Duration::from_secs(1); + +/// Minimum time between notify events and actually notifying the app +const NOTIFY_DELAY: Duration = Duration::from_millis(100); + +/// Input event to the app +#[derive(PartialEq, Debug)] +pub enum AppEvent { + /// Keyboard or mouse input from user + UserInput(crossterm::event::Event), + /// The .jj folder was touched, so the app must redraw + DirtyJj, +} + +/// Generator of events to the app +pub struct EventSource { + // Channel for app events + app_event_sender: mpsc::Sender, + app_event_receiver: mpsc::Receiver, + + // Producer data + repo_watcher: Option, + enable_watcher: Arc>, + + // Consumer data + last_event_recv: Instant, + last_event_none: bool, +} + +impl EventSource { + pub fn new() -> Self { + let (tx, rx) = mpsc::channel(); + Self { + app_event_sender: tx, + app_event_receiver: rx, + repo_watcher: None, + enable_watcher: Arc::new(RwLock::new(false)), + last_event_recv: Instant::now(), + last_event_none: false, + } + } + + /// Launch a user input event source + pub fn launch_user_input(&mut self) { + // Spawn user input thread + let app_event_tx = self.app_event_sender.clone(); + trace!("spawn crossterm reader"); + thread::Builder::new() + .name("crossterm reader".to_string()) + .spawn(move || { + trace!("crossterm reader - started"); + loop { + // Block until an event arrives + let Ok(event) = crossterm::event::read() else { + error!("crossterm reader - read abort"); + break; + }; + // Send event to main thread + let Ok(_) = app_event_tx.send(AppEvent::UserInput(event)) else { + error!("crossterm reader - send abort"); + break; + }; + trace!("crossterm reader - event forwarded"); + } + trace!("crossterm reader - stopped"); + }) + .unwrap(); + } + + /// Launch a file system watcher as event source + pub fn launch_watcher(&mut self, jj_folder: PathBuf) { + // The notify watcher uses a channel to send file system events + let (tx, rx) = mpsc::channel(); + + // Create a watcher attached to the channel + let watch_result = notify::recommended_watcher(tx); + let Ok(mut watcher) = watch_result else { + let Err(e) = watch_result else { + unreachable!(); + }; + error!("watcher abort on create: {:?}", e); + return; + }; + + // Start watching + if let Err(e) = watcher.watch(&jj_folder, RecursiveMode::Recursive) { + error!("watcher abort on start: {:?}", e); + return; + } + + // Spawn thread that forwards events to main event loop + trace!("spawn notify reader"); + let enable_watcher = self.enable_watcher.clone(); + let app_event_tx = self.app_event_sender.clone(); + thread::Builder::new() + .name("notify reader".to_string()) + .spawn(move || { + trace!("notify reader - started"); + loop { + // Block until an event arrives + let Ok(_event) = rx.recv() else { + error!("notify reader - abort on recv from watcher"); + break; + }; + // Ignore event that arrives when blocked + let enabled = *enable_watcher.read().unwrap(); + if !enabled { + trace!("notify reader - event blocked"); + continue; + } + // Send event to app + let Ok(_) = app_event_tx.send(AppEvent::DirtyJj) else { + error!("notify reader abort on send to app"); + break; + }; + trace!("notify reader - event forwarded"); + } + trace!("notify reader - stopped"); + }) + .unwrap(); + + // Store the watcher to keep it alive + self.repo_watcher = Some(watcher); + } + + /// Receive an AppEvent if one is waiting. + /// If no event is waiting, it will return None which represents + /// an idle event. There will be at least IDLE_TIMEOUT between two + /// consecutive idle events. Ordinary events are returned immediately. + pub fn try_recv(&mut self) -> Option { + // Introduce timeout if app is idle. + // This will reduce CPU load + let timeout = if self.last_event_none { + IDLE_TIMEOUT + } else { + Duration::ZERO + }; + + // Get event + let result = loop { + // Wait for event. While waiting the watcher thread is allowed to + // trigger a redraw. + *self.enable_watcher.write().unwrap() = true; + let result = self.app_event_receiver.recv_timeout(timeout); + *self.enable_watcher.write().unwrap() = false; + + // Ignore notify events too soon after a user event + // It was probably generated by the command that the user + // asked for. + if let Ok(ref event) = &result { + if *event == AppEvent::DirtyJj && self.last_event_recv.elapsed() < NOTIFY_DELAY { + trace!("try_recv - ignore DirtyJj"); + continue; + } + } + break result; + }; + + // Check for app event + if let Ok(event) = result { + trace!("try_recv - received app event"); + self.last_event_recv = Instant::now(); + self.last_event_none = false; + return Some(event); + } + + // No event found. This will trigger a redraw in the main loop + self.last_event_none = true; + trace!("try_recv - no event"); + None + } +} diff --git a/src/main.rs b/src/main.rs index 76b44b8..71c1935 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,9 +14,8 @@ use ratatui::{ backend::{Backend, CrosstermBackend}, crossterm::{ event::{ - self, DisableFocusChange, DisableMouseCapture, EnableFocusChange, EnableMouseCapture, - Event, KeyboardEnhancementFlags, MouseEvent, MouseEventKind, - PushKeyboardEnhancementFlags, + DisableFocusChange, DisableMouseCapture, EnableFocusChange, EnableMouseCapture, + KeyboardEnhancementFlags, PushKeyboardEnhancementFlags, }, execute, terminal::{ @@ -35,6 +34,7 @@ use tracing_subscriber::layer::SubscriberExt; mod app; mod commander; mod env; +mod event; mod keybinds; mod ui; @@ -45,7 +45,7 @@ use crate::{ ui::{ui, ComponentAction}, }; -/// Simple program to greet a person +/// Command line arguments #[derive(Parser, Debug)] #[command(version, about, long_about = None)] struct Args { @@ -135,6 +135,8 @@ fn main() -> Result<()> { // Run app let res = run_app(&mut terminal, &mut app, &mut commander); + + // restore terminal before possible panic from run_app restore_terminal()?; res?; @@ -146,74 +148,83 @@ fn run_app( app: &mut App, commander: &mut Commander, ) -> Result<()> { + app.launch_input_channel(); + let mut start_time = Instant::now(); loop { // Draw - let mut terminal_draw_res = Ok(()); - terminal.draw(|f| { - // Update current tab - let update_span = trace_span!("update"); - terminal_draw_res = update_span.in_scope(|| -> Result<()> { - if let Some(component_action) = - app.get_or_init_current_tab(commander)?.update(commander)? - { - app.handle_action(component_action, commander)?; - } - - Ok(()) - }); - if terminal_draw_res.is_err() { - return; - } - - let draw_span = trace_span!("draw"); - terminal_draw_res = draw_span.in_scope(|| -> Result<()> { - ui(f, app)?; - - { - let paragraph = - Paragraph::new(format!("{}ms", start_time.elapsed().as_millis())) - .alignment(Alignment::Right); - let position = Rect { - x: 0, - y: 1, - height: 1, - width: f.area().width - 1, - }; - f.render_widget(paragraph, position); - } - Ok(()) - }); - })?; - terminal_draw_res?; + draw_app(app, terminal, commander, &start_time)?; // Input - let input_spawn = trace_span!("input"); - let event = loop { - match event::read()? { - event::Event::FocusLost => continue, - Event::Mouse(MouseEvent { - kind: MouseEventKind::Moved, - .. - }) => continue, - event => break event, + start_time = Instant::now(); + + let should_stop = input_to_app(app, commander)?; + + if should_stop { + break; + } + } + Ok(()) +} + +fn draw_app( + app: &mut App, + terminal: &mut Terminal, + commander: &mut Commander, + start_time: &Instant, +) -> Result<()> { + let mut terminal_draw_res = Ok(()); + terminal.draw(|f| { + // Update current tab + let update_span = trace_span!("update"); + terminal_draw_res = update_span.in_scope(|| -> Result<()> { + if let Some(component_action) = + app.get_or_init_current_tab(commander)?.update(commander)? + { + app.handle_action(component_action, commander)?; } - }; - start_time = Instant::now(); + Ok(()) + }); + if terminal_draw_res.is_err() { + return; + } - let should_stop = input_spawn.in_scope(|| -> Result { - if app.input(event, commander)? { - return Ok(true); + let draw_span = trace_span!("draw"); + terminal_draw_res = draw_span.in_scope(|| -> Result<()> { + ui(f, app)?; + + { + let paragraph = Paragraph::new(format!("{}ms", start_time.elapsed().as_millis())) + .alignment(Alignment::Right); + let position = Rect { + x: 0, + y: 1, + height: 1, + width: f.area().width - 1, + }; + f.render_widget(paragraph, position); } + Ok(()) + }); + })?; + terminal_draw_res +} - Ok(false) - })?; +/// Wait for next event, then process it. +/// Let app process all input events in queue before returning +/// Return true if application should stop +fn input_to_app(app: &mut App, commander: &mut Commander) -> Result { + let input_span = trace_span!("input"); + let mut stop_app: bool = false; + while !stop_app { + let Some(event) = app.try_recv_app_event() else { + break; + }; - if should_stop { - return Ok(()); - } + stop_app = input_span.in_scope(|| app.input(event, commander))?; } + Ok(stop_app) } fn setup_terminal() -> Result>> { diff --git a/src/ui/bookmarks_tab.rs b/src/ui/bookmarks_tab.rs index 82240bc..9c157ae 100644 --- a/src/ui/bookmarks_tab.rs +++ b/src/ui/bookmarks_tab.rs @@ -190,7 +190,7 @@ impl BookmarksTab<'_> { _ => None, }); - self.bookmark_panel.scroll = 0; + self.bookmark_panel.scroll_to(0); } fn scroll_bookmarks(&mut self, commander: &mut Commander, scroll: isize) { @@ -391,9 +391,28 @@ impl Component for BookmarksTab<'_> { .title(" Bookmarks ") .border_type(BorderType::Rounded); self.bookmarks_height = bookmarks_block.inner(chunks[0]).height; + let bookmark_count = lines.len(); let bookmarks = List::new(lines).block(bookmarks_block).scroll_padding(3); *self.bookmarks_list_state.selected_mut() = current_bookmark_index; f.render_stateful_widget(bookmarks, chunks[0], &mut self.bookmarks_list_state); + + // Draw scrollbar on left panel + if bookmark_count > self.bookmarks_height.into() { + let index = current_bookmark_index.unwrap_or(0); + let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight); + let mut scrollbar_state = ScrollbarState::default() + .content_length(bookmark_count) + .position(index); + + f.render_stateful_widget( + scrollbar, + chunks[0].inner(Margin { + vertical: 1, + horizontal: 0, + }), + &mut scrollbar_state, + ); + } } // Draw bookmark @@ -404,21 +423,16 @@ impl Component for BookmarksTab<'_> { } else { " Bookmark ".to_owned() }; - - let bookmark_block = Block::bordered() - .title(title) - .border_type(BorderType::Rounded) - .padding(Padding::horizontal(1)); let bookmark_content: Vec = match self.bookmark_output.as_ref() { Some(Ok(bookmark_output)) => bookmark_output.into_text()?.lines, Some(Err(err)) => err.into_text("Error getting bookmark")?.lines, None => vec![], }; - let bookmark = self - .bookmark_panel - .render(bookmark_content, bookmark_block.inner(chunks[1])) - .block(bookmark_block); - f.render_widget(bookmark, chunks[1]); + self.bookmark_panel + .render_context() + .title(title) + .content(bookmark_content) + .draw(f, chunks[1]); } // Draw popup diff --git a/src/ui/command_log_tab.rs b/src/ui/command_log_tab.rs index 2451443..8ab9f22 100644 --- a/src/ui/command_log_tab.rs +++ b/src/ui/command_log_tab.rs @@ -144,7 +144,7 @@ impl CommandLogTab { .min(self.command_history.len() - 1) .max(0), ); - self.output_panel.scroll = 0; + self.output_panel.scroll_to(0); } } @@ -214,22 +214,36 @@ impl Component for CommandLogTab { ) .scroll_padding(3); + let commands_len = commands.len(); f.render_stateful_widget(commands, chunks[0], &mut self.commands_list_state); self.commands_height = chunks[0].height.saturating_sub(2); + + if commands_len > self.commands_height as usize { + let index = self.commands_list_state.selected().unwrap_or(0); + let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight); + let mut scrollbar_state = ScrollbarState::default() + .content_length(commands_len) + .position(index); + + f.render_stateful_widget( + scrollbar, + chunks[0].inner(Margin { + vertical: 1, + horizontal: 0, + }), + &mut scrollbar_state, + ); + } } // Draw output { - let output_block = Block::bordered() + let output_lines = self.get_output_lines()?; + self.output_panel + .render_context() .title(" Output ") - .border_type(BorderType::Rounded) - .padding(Padding::horizontal(1)); - let output = self - .output_panel - .render(self.get_output_lines()?, output_block.inner(chunks[1])) - .block(output_block); - - f.render_widget(output, chunks[1]); + .content(output_lines) + .draw(f, chunks[1]); } Ok(()) diff --git a/src/ui/details_panel.rs b/src/ui/details_panel.rs index bc10a5c..05ade71 100644 --- a/src/ui/details_panel.rs +++ b/src/ui/details_panel.rs @@ -1,19 +1,29 @@ use ratatui::{ crossterm::event::{KeyCode, KeyEvent, KeyModifiers}, - layout::Rect, - text::Text, - widgets::{Paragraph, Wrap}, + layout::{Margin, Rect}, + text::{Line, Text}, + widgets::{ + Block, BorderType, Padding, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, + Wrap, + }, }; /// Details panel used for the right side of each tab. /// This handles scrolling and wrapping. pub struct DetailsPanel { - pub scroll: u16, + scroll: u16, height: u16, lines: u16, wrap: bool, } +/// Transient object holding render data +pub struct DetailsPanelRenderContext<'a> { + panel: &'a mut DetailsPanel, + title: Option>, + content: Option>, +} + /// Commands that can be handled by the details panel pub enum DetailsPanelEvent { ScrollDown, @@ -25,6 +35,75 @@ pub enum DetailsPanelEvent { ToggleWrap, } +impl<'a> DetailsPanelRenderContext<'a> { + pub fn new(panel: &'a mut DetailsPanel) -> Self { + Self { + panel, + title: None, + content: None, + } + } + /// Set the title on the frame that surrounds the content + pub fn title(&mut self, title: T) -> &mut Self + where + T: Into>, + { + self.title = Some(title.into()); + self + } + /// Set the text inside the panel + pub fn content(&mut self, content: T) -> &mut Self + where + T: Into>, + { + self.content = Some(content.into()); + self + } + + pub fn draw(&mut self, f: &mut ratatui::prelude::Frame<'_>, area: ratatui::prelude::Rect) { + // Define border block + let mut border = Block::bordered() + .border_type(BorderType::Rounded) + .padding(Padding::horizontal(1)); + // Apply title if provided + if let Some(title) = &self.title { + border = border.title_top(title.clone()); + } + + // Find text inside border + let content_text = match &self.content { + Some(text) => text, + None => &Text::raw(""), + }; + // Create content widget that uses border + let paragraph_area = border.inner(area); + let paragraph = self + .panel + .render(content_text.clone(), paragraph_area) + .block(border); + + // render content and border + f.render_widget(paragraph, area); + + // render scrollbar on top of border + if self.panel.lines > paragraph_area.height { + let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight); + + let mut scrollbar_state = + ScrollbarState::new(self.panel.lines.into()).position(self.panel.scroll.into()); + + f.render_stateful_widget( + scrollbar, + area.inner(Margin { + vertical: 1, + horizontal: 0, + }), + &mut scrollbar_state, + ); + } + } +} + impl DetailsPanel { pub fn new() -> Self { Self { @@ -35,6 +114,10 @@ impl DetailsPanel { } } + pub fn render_context(&mut self) -> DetailsPanelRenderContext { + DetailsPanelRenderContext::new(self) + } + /// Render the parent into the area. pub fn render<'a, T>(&mut self, content: T, area: Rect) -> Paragraph<'a> where @@ -54,8 +137,12 @@ impl DetailsPanel { paragraph } + pub fn scroll_to(&mut self, line_no: u16) { + self.scroll = line_no.min(self.lines.saturating_sub(1)) + } + pub fn scroll(&mut self, scroll: isize) { - self.scroll = (self.scroll.saturating_add_signed(scroll as i16)).min(self.lines - 1) + self.scroll_to(self.scroll.saturating_add_signed(scroll as i16)) } pub fn handle_event(&mut self, details_panel_event: DetailsPanelEvent) { diff --git a/src/ui/files_tab.rs b/src/ui/files_tab.rs index 987fb0e..9e2f9f2 100644 --- a/src/ui/files_tab.rs +++ b/src/ui/files_tab.rs @@ -139,7 +139,7 @@ impl FilesTab { .map_or(Ok(None), |r| { r.map(|diff| diff.map(|diff| tabs_to_spaces(&diff))) }); - self.diff_panel.scroll = 0; + self.diff_panel.scroll_to(0); Ok(()) } @@ -265,26 +265,40 @@ impl Component for FilesTab { ) .scroll_padding(3); *self.files_list_state.selected_mut() = current_file_index; - f.render_stateful_widget(files, chunks[0], &mut self.files_list_state); + f.render_stateful_widget(&files, chunks[0], &mut self.files_list_state); self.files_height = chunks[0].height - 2; + + if let Some(index) = current_file_index { + if files.len() > self.files_height as usize { + let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight); + let mut scrollbar_state = ScrollbarState::default() + .content_length(files.len()) + .position(index); + + f.render_stateful_widget( + scrollbar, + chunks[0].inner(Margin { + vertical: 1, + horizontal: 0, + }), + &mut scrollbar_state, + ); + } + } } // Draw diff { - let diff_block = Block::bordered() - .title(" Diff ") - .border_type(BorderType::Rounded) - .padding(Padding::horizontal(1)); let diff_content = match self.diff_output.as_ref() { Ok(Some(diff_content)) => diff_content.into_text()?, Ok(None) => Text::default(), Err(err) => err.into_text("Error getting diff")?, }; - let diff = self - .diff_panel - .render(diff_content, diff_block.inner(chunks[1])) - .block(diff_block); - f.render_widget(diff, chunks[1]); + self.diff_panel + .render_context() + .title(" Diff ") + .content(diff_content) + .draw(f, chunks[1]); } Ok(()) diff --git a/src/ui/log_tab.rs b/src/ui/log_tab.rs index af379ae..08997c5 100644 --- a/src/ui/log_tab.rs +++ b/src/ui/log_tab.rs @@ -8,7 +8,7 @@ use ratatui::{ prelude::*, widgets::*, }; -use tracing::instrument; +use tracing::{instrument, trace}; use tui_confirm_dialog::{ButtonLabel, ConfirmDialog, ConfirmDialogState, Listener}; use tui_textarea::{CursorMove, TextArea}; @@ -178,7 +178,7 @@ impl LogTab<'_> { self.head_output = commander .get_commit_show(&self.head.commit_id, &self.diff_format, true) .map(|text| tabs_to_spaces(&text)); - self.head_panel.scroll = 0; + self.head_panel.scroll_to(0); } fn scroll_log(&mut self, commander: &mut Commander, scroll: isize) { @@ -204,6 +204,7 @@ impl LogTab<'_> { } pub fn set_head(&mut self, commander: &mut Commander, head: Head) { + trace!("INSIDE SIDS HEAD!! .. i mean sed_head.."); head.clone_into(&mut self.head); self.refresh_head_output(commander); } @@ -642,12 +643,31 @@ impl Component for LogTab<'_> { None => " Log ", }; + let log_length: usize = log_lines.len(); let log_block = Block::bordered() .title(title) .border_type(BorderType::Rounded); self.log_height = log_block.inner(chunks[0]).height; let log = List::new(log_lines).block(log_block).scroll_padding(7); f.render_stateful_widget(log, chunks[0], &mut self.log_list_state); + + // Show scrollbar if lines don't fit the screen height + if log_length > self.log_height.into() { + let index = self.log_list_state.selected().unwrap_or(0); + let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight); + let mut scrollbar_state = ScrollbarState::default() + .content_length(log_length) + .position(index); + + f.render_stateful_widget( + scrollbar, + chunks[0].inner(Margin { + vertical: 1, + horizontal: 0, + }), + &mut scrollbar_state, + ); + } } // Draw change details @@ -656,16 +676,11 @@ impl Component for LogTab<'_> { Ok(head_output) => head_output.into_text()?.lines, Err(err) => err.into_text("Error getting head details")?.lines, }; - let head_block = Block::bordered() + self.head_panel + .render_context() .title(format!(" Details for {} ", self.head.change_id)) - .border_type(BorderType::Rounded) - .padding(Padding::horizontal(1)); - let head = self - .head_panel - .render(head_content, head_block.inner(chunks[1])) - .block(head_block); - - f.render_widget(head, chunks[1]); + .content(head_content) + .draw(f, chunks[1]) } // Draw popup