From 5754e89b443f51c2d0ef8391f52768dc7ba347e9 Mon Sep 17 00:00:00 2001 From: Peer Sommerlund Date: Fri, 9 May 2025 05:30:28 +0200 Subject: [PATCH 1/9] refactor: Make DetailsPanel.scroll private Instead of relying on access to DetailsPanel.scroll, provide a setter function. --- src/ui/bookmarks_tab.rs | 2 +- src/ui/command_log_tab.rs | 2 +- src/ui/details_panel.rs | 8 ++++++-- src/ui/files_tab.rs | 2 +- src/ui/log_tab.rs | 2 +- 5 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/ui/bookmarks_tab.rs b/src/ui/bookmarks_tab.rs index 82240bc..50895c7 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) { diff --git a/src/ui/command_log_tab.rs b/src/ui/command_log_tab.rs index 2451443..be7dca6 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); } } diff --git a/src/ui/details_panel.rs b/src/ui/details_panel.rs index bc10a5c..672edd1 100644 --- a/src/ui/details_panel.rs +++ b/src/ui/details_panel.rs @@ -8,7 +8,7 @@ use ratatui::{ /// 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, @@ -54,8 +54,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..73be864 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(()) } diff --git a/src/ui/log_tab.rs b/src/ui/log_tab.rs index af379ae..e6be2d8 100644 --- a/src/ui/log_tab.rs +++ b/src/ui/log_tab.rs @@ -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) { From 29b49d6701b3443c037b7caeb4a4768a8f304a40 Mon Sep 17 00:00:00 2001 From: Peer Sommerlund Date: Fri, 9 May 2025 05:30:28 +0200 Subject: [PATCH 2/9] refactor: Improve api of DetailsPanel The scrollbar is used automatically, and less boilerplate code is needed for drawing in all the tabs. --- src/ui/bookmarks_tab.rs | 15 +++---- src/ui/command_log_tab.rs | 14 +++--- src/ui/details_panel.rs | 89 +++++++++++++++++++++++++++++++++++++-- src/ui/files_tab.rs | 14 +++--- src/ui/log_tab.rs | 13 ++---- 5 files changed, 105 insertions(+), 40 deletions(-) diff --git a/src/ui/bookmarks_tab.rs b/src/ui/bookmarks_tab.rs index 50895c7..bbce819 100644 --- a/src/ui/bookmarks_tab.rs +++ b/src/ui/bookmarks_tab.rs @@ -404,21 +404,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 be7dca6..e677f4a 100644 --- a/src/ui/command_log_tab.rs +++ b/src/ui/command_log_tab.rs @@ -220,16 +220,12 @@ impl Component for CommandLogTab { // 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 672edd1..05ade71 100644 --- a/src/ui/details_panel.rs +++ b/src/ui/details_panel.rs @@ -1,8 +1,11 @@ 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. @@ -14,6 +17,13 @@ pub struct DetailsPanel { 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 diff --git a/src/ui/files_tab.rs b/src/ui/files_tab.rs index 73be864..1ba5455 100644 --- a/src/ui/files_tab.rs +++ b/src/ui/files_tab.rs @@ -271,20 +271,16 @@ impl Component for FilesTab { // 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 e6be2d8..422a93c 100644 --- a/src/ui/log_tab.rs +++ b/src/ui/log_tab.rs @@ -656,16 +656,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 From 9b0d6a2779c0b8b9d684d089919155988dfb02f1 Mon Sep 17 00:00:00 2001 From: Peer Sommerlund Date: Fri, 9 May 2025 17:16:10 +0200 Subject: [PATCH 3/9] feat: Scroll bars in all tabs --- src/ui/bookmarks_tab.rs | 19 +++++++++++++++++++ src/ui/command_log_tab.rs | 18 ++++++++++++++++++ src/ui/files_tab.rs | 20 +++++++++++++++++++- src/ui/log_tab.rs | 19 +++++++++++++++++++ 4 files changed, 75 insertions(+), 1 deletion(-) diff --git a/src/ui/bookmarks_tab.rs b/src/ui/bookmarks_tab.rs index bbce819..9c157ae 100644 --- a/src/ui/bookmarks_tab.rs +++ b/src/ui/bookmarks_tab.rs @@ -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 diff --git a/src/ui/command_log_tab.rs b/src/ui/command_log_tab.rs index e677f4a..8ab9f22 100644 --- a/src/ui/command_log_tab.rs +++ b/src/ui/command_log_tab.rs @@ -214,8 +214,26 @@ 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 diff --git a/src/ui/files_tab.rs b/src/ui/files_tab.rs index 1ba5455..9e2f9f2 100644 --- a/src/ui/files_tab.rs +++ b/src/ui/files_tab.rs @@ -265,8 +265,26 @@ 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 diff --git a/src/ui/log_tab.rs b/src/ui/log_tab.rs index 422a93c..215911e 100644 --- a/src/ui/log_tab.rs +++ b/src/ui/log_tab.rs @@ -642,12 +642,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 From 2452c30c2cdb620a9bef4736339e463632709a13 Mon Sep 17 00:00:00 2001 From: Peer Sommerlund Date: Tue, 3 Jun 2025 23:50:35 +0200 Subject: [PATCH 4/9] chore: Update Args description --- src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index 76b44b8..f83f49e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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 { From 6cdcfcc35c50db1f2b533ff7dee3fd8eef5dcce6 Mon Sep 17 00:00:00 2001 From: Peer Sommerlund Date: Tue, 20 May 2025 09:28:57 +0200 Subject: [PATCH 5/9] refactor: Extract input_to_app Increase code clarity and prepare for multiple events per draw --- src/main.rs | 46 +++++++++++++++++++++++++++------------------- 1 file changed, 27 insertions(+), 19 deletions(-) diff --git a/src/main.rs b/src/main.rs index f83f49e..108b79b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -188,27 +188,9 @@ fn run_app( terminal_draw_res?; // 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_spawn.in_scope(|| -> Result { - if app.input(event, commander)? { - return Ok(true); - } - - Ok(false) - })?; + let should_stop = input_to_app(app, commander)?; if should_stop { return Ok(()); @@ -216,6 +198,32 @@ fn run_app( } } +/// 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_spawn = trace_span!("input"); + let event = loop { + match event::read()? { + event::Event::FocusLost => continue, + Event::Mouse(MouseEvent { + kind: MouseEventKind::Moved, + .. + }) => continue, + event => break event, + } + }; + + let should_stop = input_spawn.in_scope(|| -> Result { + if app.input(event, commander)? { + return Ok(true); + } + + Ok(false) + })?; + + Ok(should_stop) +} + fn setup_terminal() -> Result>> { enable_raw_mode()?; let mut stdout = io::stdout(); From 2de29d29ce44dd9a6e9abeba17b20ed66e53131a Mon Sep 17 00:00:00 2001 From: Peer Sommerlund Date: Tue, 20 May 2025 09:28:57 +0200 Subject: [PATCH 6/9] refactor: Multiple events per draw --- src/main.rs | 29 +++++++---------------------- 1 file changed, 7 insertions(+), 22 deletions(-) diff --git a/src/main.rs b/src/main.rs index 108b79b..4ea2ae4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,7 +5,7 @@ use std::{ fs::{canonicalize, OpenOptions}, io::{self, ErrorKind}, process::Command, - time::Instant, + time::{Duration, Instant}, }; use anyhow::{bail, Context, Result}; @@ -15,8 +15,7 @@ use ratatui::{ crossterm::{ event::{ self, DisableFocusChange, DisableMouseCapture, EnableFocusChange, EnableMouseCapture, - Event, KeyboardEnhancementFlags, MouseEvent, MouseEventKind, - PushKeyboardEnhancementFlags, + KeyboardEnhancementFlags, PushKeyboardEnhancementFlags, }, execute, terminal::{ @@ -202,25 +201,11 @@ fn run_app( /// Return true if application should stop fn input_to_app(app: &mut App, commander: &mut Commander) -> Result { 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, - } - }; - - let should_stop = input_spawn.in_scope(|| -> Result { - if app.input(event, commander)? { - return Ok(true); - } - - Ok(false) - })?; - + let mut should_stop: bool = false; + while event::poll(Duration::ZERO)? && !should_stop { + let event = event::read()?; + should_stop = input_spawn.in_scope(|| app.input(event, commander))?; + } Ok(should_stop) } From 6f11288a8cef83550eb560e735f4c24745a4930f Mon Sep 17 00:00:00 2001 From: Peer Sommerlund Date: Tue, 20 May 2025 09:28:57 +0200 Subject: [PATCH 7/9] refactor: Extract draw_app from run_app Improve code clarity. --- src/main.rs | 82 +++++++++++++++++++++++++++++------------------------ 1 file changed, 45 insertions(+), 37 deletions(-) diff --git a/src/main.rs b/src/main.rs index 4ea2ae4..53fb97c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -148,43 +148,7 @@ fn run_app( 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 start_time = Instant::now(); @@ -197,6 +161,50 @@ fn run_app( } } +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)?; + } + + 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 +} + /// 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 { From ec4029c96561ac253722f27260d7457052014ce3 Mon Sep 17 00:00:00 2001 From: Peer Sommerlund Date: Fri, 23 May 2025 03:46:23 +0200 Subject: [PATCH 8/9] feat: Refresh when .jj is touched -- UNSTABLE This makes it possible to keep lazyjj in one window, and working with jj in another. Every jj command will cause lazyjj to update. UNSTABLE Sometimes an external and an internal jj operation runs at the same time. This causes duplicate branches to appear. On the positive side, it is excellent for testing concurrency issues with jj. TRY: Add a --no-workspace-update flag so the refresh can run without updating .jj. TRY: Let focus lost/gained control when updates are allowed. --- Cargo.lock | 142 ++++++++++++++++++++++++++++++++++++-- Cargo.toml | 1 + src/app.rs | 57 +++++++++++++--- src/event.rs | 189 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 28 +++++--- 5 files changed, 393 insertions(+), 24 deletions(-) create mode 100644 src/event.rs 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..e8b0d12 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,28 @@ 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 { + // Trigger update of jj data - simulate focus gained + trace!("no-event trigger update of jj data"); + self.get_or_init_current_tab(commander)?.focus(commander)?; + 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 +267,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 +285,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 +297,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/event.rs b/src/event.rs new file mode 100644 index 0000000..8d17802 --- /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)] +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 53fb97c..71c1935 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,7 +5,7 @@ use std::{ fs::{canonicalize, OpenOptions}, io::{self, ErrorKind}, process::Command, - time::{Duration, Instant}, + time::Instant, }; use anyhow::{bail, Context, Result}; @@ -14,7 +14,7 @@ use ratatui::{ backend::{Backend, CrosstermBackend}, crossterm::{ event::{ - self, DisableFocusChange, DisableMouseCapture, EnableFocusChange, EnableMouseCapture, + DisableFocusChange, DisableMouseCapture, EnableFocusChange, EnableMouseCapture, KeyboardEnhancementFlags, PushKeyboardEnhancementFlags, }, execute, @@ -34,6 +34,7 @@ use tracing_subscriber::layer::SubscriberExt; mod app; mod commander; mod env; +mod event; mod keybinds; mod ui; @@ -134,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?; @@ -145,6 +148,8 @@ fn run_app( app: &mut App, commander: &mut Commander, ) -> Result<()> { + app.launch_input_channel(); + let mut start_time = Instant::now(); loop { // Draw @@ -156,9 +161,10 @@ fn run_app( let should_stop = input_to_app(app, commander)?; if should_stop { - return Ok(()); + break; } } + Ok(()) } fn draw_app( @@ -205,16 +211,20 @@ fn draw_app( terminal_draw_res } +/// 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_spawn = trace_span!("input"); - let mut should_stop: bool = false; - while event::poll(Duration::ZERO)? && !should_stop { - let event = event::read()?; - should_stop = input_spawn.in_scope(|| app.input(event, commander))?; + 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; + }; + + stop_app = input_span.in_scope(|| app.input(event, commander))?; } - Ok(should_stop) + Ok(stop_app) } fn setup_terminal() -> Result>> { From 26e41ccfe98a16122eac4d41325e28c89e6ba357 Mon Sep 17 00:00:00 2001 From: Peer Sommerlund Date: Wed, 4 Jun 2025 05:54:15 +0200 Subject: [PATCH 9/9] TRY add --ignore-working-copy when refreshing --- src/app.rs | 7 ++++++- src/commander/mod.rs | 11 +++++++++++ src/event.rs | 2 +- src/ui/log_tab.rs | 3 ++- 4 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/app.rs b/src/app.rs index e8b0d12..c4324b9 100644 --- a/src/app.rs +++ b/src/app.rs @@ -255,9 +255,14 @@ impl<'a> App<'a> { /// 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 update of jj data"); + 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() { 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 index 8d17802..610954e 100644 --- a/src/event.rs +++ b/src/event.rs @@ -21,7 +21,7 @@ const IDLE_TIMEOUT: Duration = Duration::from_secs(1); const NOTIFY_DELAY: Duration = Duration::from_millis(100); /// Input event to the app -#[derive(PartialEq)] +#[derive(PartialEq, Debug)] pub enum AppEvent { /// Keyboard or mouse input from user UserInput(crossterm::event::Event), diff --git a/src/ui/log_tab.rs b/src/ui/log_tab.rs index 215911e..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}; @@ -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); }