use super::review_queue::FileStep;
use super::{
AnimationFrame, AnimationPhase, App, FileDiskStamp, FilePanelMode, ImagePreviewCache,
ImagePreviewSignature, PreviewLinkBox, StatusModeMenu, TopbarTab, TopbarTabContent,
ViewHistoryRecipe, ViewMode,
};
use crate::csv_preview::{CsvPreviewSignature, CsvPreviewState};
use crate::structured_preview::{StructuredPreviewSignature, StructuredPreviewState};
use crate::toasts::ToastEvent;
use oyo_core::multi::FileSide;
use ratatui::layout::Size;
use ratatui_image::{picker::Picker, protocol::Protocol, Resize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
const WATCH_CHANGE_FLASH_DURATION: Duration = Duration::from_secs(3);
fn is_openable_url(url: &str) -> bool {
["http://", "https://", "mailto:"].iter().any(|s| {
url.len() > s.len()
&& url
.get(..s.len())
.is_some_and(|prefix| prefix.eq_ignore_ascii_case(s))
})
}
fn is_image_name(name: &str) -> bool {
let lower = name.to_ascii_lowercase();
[".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"]
.iter()
.any(|ext| lower.ends_with(ext))
}
fn open_url(url: &str) {
if !is_openable_url(url) {
return;
}
#[cfg(target_os = "macos")]
let mut command = {
let mut c = std::process::Command::new("open");
c.arg(url);
c
};
#[cfg(target_os = "windows")]
let mut command = {
let mut c = std::process::Command::new("cmd");
c.args(["/C", "start", "", url]);
c
};
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
let mut command = {
let mut c = std::process::Command::new("xdg-open");
c.arg(url);
c
};
let _ = command.spawn();
}
impl App {
pub(crate) fn is_remote_session(&self) -> bool {
self.review_live_diff().remote_snapshot.is_some()
|| self
.review_target_metadata
.as_ref()
.is_some_and(|metadata| metadata.vcs == "remote")
}
pub(crate) fn remote_sync_generation(&self) -> u64 {
self.remote_sync_generation
}
fn start_remote_refresh(&mut self) -> bool {
let Some(url) = self
.review_target_metadata
.as_ref()
.filter(|m| m.vcs == "remote")
.map(|m| m.label.clone())
else {
return false;
};
if self.remote_refresh_rx.is_some() {
return true;
}
self.remote_refresh_rx = Some(crate::remote::loading::SnapshotWorker::start(url));
self.remote_sync_generation = self.remote_sync_generation.wrapping_add(1);
true
}
pub(crate) fn poll_remote_refresh(&mut self) -> bool {
let result = match self.remote_refresh_rx.as_ref().map(|rx| rx.try_recv()) {
Some(Ok(result)) => result,
Some(Err(std::sync::mpsc::TryRecvError::Disconnected)) => {
Err("Remote refresh worker stopped".to_owned())
}
_ => return false,
};
self.remote_refresh_rx = None;
match result {
Ok(built) => {
self.restore_live_diff_after_outdated_view();
self.replace_multi_diff(built.multi_diff);
self.refresh_remote_review_snapshot();
}
Err(error) => self.notify(ToastEvent::SelectionActionFailed(format!(
"Remote refresh failed: {error}"
))),
}
true
}
pub fn clear_preview_link_boxes(&mut self) {
self.preview_link_boxes.clear();
}
pub fn add_preview_link_box(&mut self, x: u16, y: u16, width: u16, url: String) {
self.preview_link_boxes
.push(PreviewLinkBox { x, y, width, url });
}
pub fn handle_preview_link_click(&mut self, column: u16, row: u16) -> bool {
if self.view_mode != ViewMode::Preview {
return false;
}
let url = self.preview_link_boxes.iter().rev().find_map(|b| {
(row == b.y && column >= b.x && column < b.x.saturating_add(b.width))
.then(|| b.url.clone())
});
match url {
Some(url) => {
open_url(&url);
true
}
None => false,
}
}
pub fn next_file(&mut self) {
self.step_file(FileStep::Next);
}
pub fn prev_file(&mut self) {
self.step_file(FileStep::Prev);
}
pub fn scroll_file_panel_up(&mut self) {
self.file_list_scroll = self.file_list_scroll.saturating_sub(1);
}
pub fn scroll_file_panel_down(&mut self) {
let total_rows = if self.file_panel_mode == FilePanelMode::Comments {
self.filtered_review_comment_indices().len()
} else {
let indices = self.filtered_file_indices();
self.file_list_total_rows(&indices)
};
let max_scroll = total_rows.saturating_sub(self.file_list_visible_rows());
self.file_list_scroll = self.file_list_scroll.saturating_add(1).min(max_scroll);
}
pub(crate) fn next_file_wrapped(&mut self) -> bool {
self.step_file(FileStep::NextWrapping)
}
pub(crate) fn prev_file_wrapped(&mut self) -> bool {
self.step_file(FileStep::PrevWrapping)
}
fn step_file(&mut self, step: FileStep) -> bool {
self.restore_live_diff_after_outdated_view();
let Some(target) = self.file_step_target(step) else {
return false;
};
self.select_file(target);
true
}
pub fn select_file(&mut self, index: usize) {
if self.request_settings_leave(super::settings::SettingsLeaveTarget::SelectFile(index)) {
return;
}
let history_origin = self.view_history_origin();
self.restore_live_diff_after_outdated_view();
if index >= self.multi_diff.file_count() {
return;
}
self.clear_review_action_prefixes();
let was_comment_view = matches!(
self.active_topbar_content(),
Some(TopbarTabContent::PrComments | TopbarTabContent::OutdatedComments)
);
self.save_active_topbar_tab_state();
if was_comment_view {
self.clear_virtual_comment_view_state();
}
self.replace_active_topbar_tab_file(index);
self.select_file_in_active_tab(index);
self.record_view_landing(
history_origin,
super::ViewHistoryRecipe::File {
tab_id: self.active_topbar_tab,
file_index: index,
scroll_offset: self.scroll_offset,
},
);
}
fn clear_review_action_prefixes(&mut self) {
self.pr_reply_prefix = false;
self.review_edit_prefix = false;
self.review_reply_prefix = false;
self.review_resolve_prefix = false;
self.review_delete_prefix = false;
self.review_overflow_prefix = false;
}
fn clear_virtual_comment_view_state(&mut self) {
self.pr_comment_focus = None;
self.outdated_comment_focus = None;
self.pr_comment_hits.clear();
self.pr_comment_add_hit = None;
self.pr_comment_action_hover = None;
self.pr_comment_action_hover_key = None;
self.pr_comment_add_hover = false;
self.clear_review_action_prefixes();
self.clear_review_preview_boxes();
if self.review_editor_active() {
self.review_cancel_editor();
}
}
fn file_is_preview_only(&self, index: usize) -> bool {
self.multi_diff
.files
.get(index)
.is_some_and(|file| file.binary || is_image_name(&file.display_name))
}
fn apply_content_view_mode(&mut self) {
let saved_mode = self
.active_topbar_tab
.and_then(|id| self.topbar_tabs.iter().find(|tab| tab.id == id))
.map(|tab| tab.view_mode)
.unwrap_or(self.view_mode);
let preview_only = match self.active_topbar_content() {
Some(TopbarTabContent::File(index)) => self.file_is_preview_only(index),
Some(TopbarTabContent::PrComments | TopbarTabContent::OutdatedComments) => true,
Some(TopbarTabContent::Help | TopbarTabContent::Settings) | None => false,
};
if preview_only && saved_mode != ViewMode::Preview {
self.view_mode = ViewMode::Preview;
self.preview_forced_by_content = true;
} else {
self.view_mode = saved_mode;
self.preview_forced_by_content = false;
}
}
fn select_file_in_active_tab(&mut self, index: usize) {
let old_index = self.multi_diff.selected_index;
if old_index != index && self.review_editor_active() {
self.review_cancel_editor();
}
self.clear_step_edge_hint();
self.clear_hunk_edge_hint();
self.clear_blame_step_hint();
self.clear_blame_hunk_hint();
if !self.stepping {
self.save_no_step_state_snapshot(old_index);
}
self.save_scroll_position_for(old_index);
self.multi_diff.select_file(index);
self.remember_selection_opened_on_purpose();
self.apply_content_view_mode();
self.restore_scroll_position_for(self.multi_diff.selected_index);
self.animation_phase = AnimationPhase::Idle;
self.animation_progress = 1.0;
self.view_build_defer = false;
self.view_build_pending = false;
self.reset_search_for_file_switch();
self.centered_once = false;
self.update_file_list_scroll();
self.handle_file_enter();
}
pub(crate) fn ensure_topbar_tabs(&mut self) {
let count = self.multi_diff.file_count();
if count == 0 {
self.topbar_tabs.retain(|tab| {
matches!(
tab.content,
TopbarTabContent::File(0)
| TopbarTabContent::Help
| TopbarTabContent::Settings
| TopbarTabContent::PrComments
| TopbarTabContent::OutdatedComments
)
});
if !self
.topbar_tabs
.iter()
.any(|tab| tab.content == TopbarTabContent::File(0))
{
let id = self.next_topbar_tab_id;
self.next_topbar_tab_id = self.next_topbar_tab_id.saturating_add(1);
self.topbar_tabs.insert(
0,
TopbarTab {
id,
content: TopbarTabContent::File(0),
view_mode: self.view_mode,
step_view_mode: self.step_view_mode,
stepping: self.stepping,
scroll_offset: 0,
horizontal_scroll: 0,
preview_rendered: true,
navigator_state: None,
},
);
}
self.active_topbar_tab = self
.active_topbar_tab
.filter(|id| self.topbar_tabs.iter().any(|tab| tab.id == *id))
.or_else(|| self.topbar_tabs.first().map(|tab| tab.id));
if matches!(
self.active_topbar_content(),
Some(TopbarTabContent::PrComments | TopbarTabContent::OutdatedComments)
) {
self.view_mode = ViewMode::Preview;
}
self.topbar_drag_target = None;
return;
}
self.topbar_tabs.retain(|tab| match tab.content {
TopbarTabContent::File(index) => index < count,
TopbarTabContent::Help
| TopbarTabContent::Settings
| TopbarTabContent::PrComments
| TopbarTabContent::OutdatedComments => true,
});
let live_ids: Vec<usize> = self.topbar_tabs.iter().map(|tab| tab.id).collect();
self.structured_previews
.retain(|id, _| live_ids.iter().any(|live| live == id));
self.csv_previews
.retain(|id, _| live_ids.iter().any(|live| live == id));
if self.topbar_tabs.is_empty() {
self.add_topbar_tab_for(self.multi_diff.selected_index.min(count.saturating_sub(1)));
}
if self
.active_topbar_tab
.is_none_or(|id| !self.topbar_tabs.iter().any(|tab| tab.id == id))
{
self.active_topbar_tab = self.topbar_tabs.first().map(|tab| tab.id);
}
if self
.topbar_drag_target
.is_some_and(|idx| idx > self.topbar_tabs.len())
{
self.topbar_drag_target = None;
}
}
pub(crate) fn open_file_in_new_topbar_tab(&mut self, file_index: usize) {
if self.request_settings_leave(super::settings::SettingsLeaveTarget::OpenFileNew(
file_index,
)) {
return;
}
self.restore_live_diff_after_outdated_view();
if file_index >= self.multi_diff.file_count() {
return;
}
let id = self.add_topbar_tab_for(file_index);
self.select_topbar_tab(id);
}
fn add_topbar_tab_for(&mut self, file_index: usize) -> usize {
let id = self.next_topbar_tab_id;
self.next_topbar_tab_id = self.next_topbar_tab_id.saturating_add(1);
self.topbar_tabs.push(TopbarTab {
id,
content: TopbarTabContent::File(file_index),
view_mode: self.view_mode,
step_view_mode: self.step_view_mode,
stepping: self.stepping,
scroll_offset: self.scroll_offset,
horizontal_scroll: self.horizontal_scroll,
preview_rendered: true,
navigator_state: None,
});
id
}
fn replace_active_topbar_tab_file(&mut self, index: usize) {
let Some(active) = self.active_topbar_tab else {
self.active_topbar_tab = Some(self.add_topbar_tab_for(index));
return;
};
let Some(tab) = self.topbar_tabs.iter_mut().find(|tab| tab.id == active) else {
self.active_topbar_tab = Some(self.add_topbar_tab_for(index));
return;
};
let saved_view_mode = if self.preview_forced_by_content {
tab.view_mode
} else {
self.view_mode
};
if tab.content != TopbarTabContent::File(index) {
self.structured_previews.remove(&active);
self.csv_previews.remove(&active);
tab.content = TopbarTabContent::File(index);
tab.navigator_state = None;
tab.scroll_offset = 0;
tab.horizontal_scroll = 0;
tab.preview_rendered = true;
}
tab.view_mode = saved_view_mode;
tab.step_view_mode = self.step_view_mode;
tab.stepping = self.stepping;
}
pub(crate) fn save_active_topbar_tab_state(&mut self) {
let Some(active) = self.active_topbar_tab else {
return;
};
let content = self
.topbar_tabs
.iter()
.find(|tab| tab.id == active)
.map(|tab| tab.content)
.unwrap_or(TopbarTabContent::File(self.multi_diff.selected_index));
let content = match content {
TopbarTabContent::File(_) => TopbarTabContent::File(self.multi_diff.selected_index),
TopbarTabContent::Help => TopbarTabContent::Help,
TopbarTabContent::Settings => TopbarTabContent::Settings,
TopbarTabContent::PrComments => TopbarTabContent::PrComments,
TopbarTabContent::OutdatedComments => TopbarTabContent::OutdatedComments,
};
let view_mode = if self.preview_forced_by_content {
self.topbar_tabs
.iter()
.find(|tab| tab.id == active)
.map(|tab| tab.view_mode)
.unwrap_or(self.view_mode)
} else {
self.view_mode
};
let step_view_mode = self.step_view_mode;
let stepping = self.stepping;
let scroll_offset = self.scroll_offset;
let horizontal_scroll = self.horizontal_scroll;
let navigator_state = match content {
TopbarTabContent::File(_) if self.multi_diff.file_count() > 0 => {
Some(self.multi_diff.current_navigator().state().clone())
}
TopbarTabContent::File(_) => None,
TopbarTabContent::Help
| TopbarTabContent::Settings
| TopbarTabContent::PrComments
| TopbarTabContent::OutdatedComments => None,
};
if let Some(tab) = self.topbar_tabs.iter_mut().find(|tab| tab.id == active) {
tab.content = content;
tab.view_mode = view_mode;
tab.step_view_mode = step_view_mode;
tab.stepping = stepping;
tab.scroll_offset = scroll_offset;
tab.horizontal_scroll = horizontal_scroll;
tab.navigator_state = navigator_state;
}
}
pub(crate) fn new_topbar_tab(&mut self) {
if self.request_settings_leave(super::settings::SettingsLeaveTarget::NewTab) {
return;
}
self.restore_live_diff_after_outdated_view();
if self.multi_diff.file_count() == 0 {
return;
}
self.save_active_topbar_tab_state();
let id = self.next_topbar_tab_id;
self.next_topbar_tab_id = self.next_topbar_tab_id.saturating_add(1);
let mut tab = self
.active_topbar_tab
.and_then(|active| {
self.topbar_tabs
.iter()
.find(|tab| tab.id == active)
.cloned()
})
.unwrap_or_else(|| TopbarTab {
id,
content: TopbarTabContent::File(self.multi_diff.selected_index),
view_mode: self.view_mode,
step_view_mode: self.step_view_mode,
stepping: self.stepping,
scroll_offset: self.scroll_offset,
horizontal_scroll: self.horizontal_scroll,
preview_rendered: true,
navigator_state: None,
});
tab.id = id;
self.topbar_tabs.push(tab);
self.active_topbar_tab = Some(id);
}
pub(crate) fn topbar_close_allowed(&self, tab_id: usize) -> bool {
if self.multi_diff.file_count() == 0
&& self
.topbar_tabs
.iter()
.any(|tab| tab.id == tab_id && tab.content == TopbarTabContent::File(0))
{
return false;
}
if self.topbar_tabs.len() > 1 {
return true;
}
self.multi_diff.file_count() == 0
&& self.topbar_tabs.iter().any(|tab| {
tab.id == tab_id
&& matches!(
tab.content,
TopbarTabContent::Help | TopbarTabContent::Settings
)
})
}
pub(crate) fn close_active_topbar_tab(&mut self) {
self.restore_live_diff_after_outdated_view();
if let Some(tab_id) = self.active_topbar_tab {
self.close_topbar_tab(tab_id);
}
}
pub(super) fn close_topbar_tab(&mut self, tab_id: usize) {
if !self.topbar_close_allowed(tab_id) {
return;
}
if self.active_topbar_tab == Some(tab_id)
&& self.request_settings_leave(super::settings::SettingsLeaveTarget::CloseTab(tab_id))
{
return;
}
let Some(pos) = self.topbar_tabs.iter().position(|tab| tab.id == tab_id) else {
return;
};
if self.topbar_tabs.len() == 1 {
self.structured_previews.remove(&tab_id);
self.csv_previews.remove(&tab_id);
self.topbar_tabs.clear();
self.active_topbar_tab = None;
self.topbar_hover_tab = None;
self.topbar_hover_close = None;
self.view_mode = ViewMode::UnifiedPane;
self.scroll_offset = 0;
self.horizontal_scroll = 0;
return;
}
self.structured_previews.remove(&tab_id);
self.csv_previews.remove(&tab_id);
self.topbar_tabs.remove(pos);
if self.active_topbar_tab == Some(tab_id) {
let next_pos = pos.min(self.topbar_tabs.len().saturating_sub(1));
if let Some(next) = self.topbar_tabs.get(next_pos).map(|tab| tab.id) {
self.select_topbar_tab(next);
}
}
}
pub(crate) fn select_topbar_tab(&mut self, tab_id: usize) {
if self.active_topbar_tab == Some(tab_id) {
return;
}
if self.request_settings_leave(super::settings::SettingsLeaveTarget::SelectTab(tab_id)) {
return;
}
let history_origin = self.view_history_origin();
self.restore_live_diff_after_outdated_view();
let file_count = self.multi_diff.file_count();
let Some(tab) = self
.topbar_tabs
.iter()
.find(|tab| tab.id == tab_id)
.cloned()
else {
return;
};
if let TopbarTabContent::File(index) = tab.content {
if index >= file_count && !(file_count == 0 && index == 0) {
return;
}
}
let old_index = self.multi_diff.selected_index;
let old_content = self.active_topbar_content();
let target_file = match tab.content {
TopbarTabContent::File(index) => Some(index),
TopbarTabContent::Help
| TopbarTabContent::Settings
| TopbarTabContent::PrComments
| TopbarTabContent::OutdatedComments => None,
};
if target_file != Some(old_index) && self.review_editor_active() {
self.review_cancel_editor();
}
self.save_active_topbar_tab_state();
if matches!(
old_content,
Some(TopbarTabContent::PrComments | TopbarTabContent::OutdatedComments)
) {
self.clear_virtual_comment_view_state();
}
if file_count > 0 && !self.stepping {
self.save_no_step_state_snapshot(old_index);
}
if file_count > 0 {
self.save_scroll_position_for(old_index);
}
self.clear_step_edge_hint();
self.clear_hunk_edge_hint();
self.clear_blame_step_hint();
self.clear_blame_hunk_hint();
self.active_topbar_tab = Some(tab_id);
self.view_mode = tab.view_mode;
self.preview_forced_by_content = false;
self.step_view_mode = tab.step_view_mode;
self.stepping = tab.stepping;
self.scroll_offset = tab.scroll_offset;
self.horizontal_scroll = tab.horizontal_scroll;
self.animation_phase = AnimationPhase::Idle;
self.animation_progress = 1.0;
self.view_build_defer = false;
self.view_build_pending = false;
self.reset_search_for_file_switch();
self.centered_once = false;
match tab.content {
TopbarTabContent::File(index) if file_count > 0 => {
self.multi_diff.select_file(index);
self.remember_selection_opened_on_purpose();
self.apply_content_view_mode();
self.update_file_list_scroll();
let restored = tab
.navigator_state
.map(|state| self.multi_diff.current_navigator().set_state(state))
.unwrap_or(false);
if restored {
self.queue_current_file_diff();
} else {
self.handle_file_enter();
}
}
TopbarTabContent::File(_) => {
self.preview_forced_by_content = false;
self.clear_diff_selection();
}
TopbarTabContent::Help => {
self.view_mode = ViewMode::Preview;
self.preview_forced_by_content = false;
self.clear_diff_selection();
}
TopbarTabContent::Settings => {
self.preview_forced_by_content = false;
self.clear_diff_selection();
if old_content != Some(TopbarTabContent::Settings) {
self.begin_settings_session();
}
}
TopbarTabContent::PrComments | TopbarTabContent::OutdatedComments => {
self.view_mode = ViewMode::Preview;
self.preview_forced_by_content = tab.view_mode != ViewMode::Preview;
self.clear_diff_selection();
}
}
let destination = match tab.content {
TopbarTabContent::Help => super::ViewHistoryRecipe::Help { tab_id },
TopbarTabContent::Settings => super::ViewHistoryRecipe::Settings { tab_id },
TopbarTabContent::PrComments => super::ViewHistoryRecipe::PrComments {
tab_id,
focus_comment_id: self.pr_comment_focus,
},
TopbarTabContent::OutdatedComments => super::ViewHistoryRecipe::OutdatedComments {
tab_id,
focus_comment_id: self.outdated_comment_focus,
},
TopbarTabContent::File(_) => super::ViewHistoryRecipe::Tab { tab_id },
};
self.record_view_landing(history_origin, destination);
}
fn set_virtual_comment_focus(&mut self, content: TopbarTabContent, focus: Option<u64>) {
match content {
TopbarTabContent::PrComments => self.pr_comment_focus = focus,
TopbarTabContent::OutdatedComments => self.outdated_comment_focus = focus,
TopbarTabContent::File(_) | TopbarTabContent::Help | TopbarTabContent::Settings => {}
}
}
fn open_virtual_comments_in_current_tab(
&mut self,
content: TopbarTabContent,
focus: Option<u64>,
) {
let target = match content {
TopbarTabContent::PrComments => {
super::settings::SettingsLeaveTarget::PrCommentsCurrent(focus)
}
TopbarTabContent::OutdatedComments => {
super::settings::SettingsLeaveTarget::OutdatedCommentsCurrent(focus)
}
_ => return,
};
if self.request_settings_leave(target) {
return;
}
if self.multi_diff.file_count() == 0
&& matches!(
self.active_topbar_content(),
Some(TopbarTabContent::File(0))
)
{
self.open_virtual_comments_tab(content, focus);
return;
}
self.restore_live_diff_after_outdated_view();
self.clear_virtual_comment_view_state();
self.set_virtual_comment_focus(content, focus);
self.save_active_topbar_tab_state();
let mut saved_mode = ViewMode::Preview;
if let Some(id) = self.active_topbar_tab {
if let Some(tab) = self.topbar_tabs.iter_mut().find(|tab| tab.id == id) {
saved_mode = tab.view_mode;
tab.content = content;
tab.scroll_offset = 0;
tab.horizontal_scroll = 0;
tab.preview_rendered = true;
tab.navigator_state = None;
}
} else {
self.open_virtual_comments_tab(content, focus);
return;
}
self.view_mode = ViewMode::Preview;
self.preview_forced_by_content = saved_mode != ViewMode::Preview;
self.scroll_offset = 0;
self.horizontal_scroll = 0;
self.clear_diff_selection();
}
fn open_virtual_comments_tab(&mut self, content: TopbarTabContent, focus: Option<u64>) {
self.restore_live_diff_after_outdated_view();
self.clear_virtual_comment_view_state();
if let Some(id) = self
.topbar_tabs
.iter()
.find(|tab| tab.content == content)
.map(|tab| tab.id)
{
self.select_topbar_tab(id);
self.set_virtual_comment_focus(content, focus);
return;
}
self.set_virtual_comment_focus(content, focus);
self.save_active_topbar_tab_state();
let saved_mode = if self.preview_forced_by_content {
self.active_topbar_tab
.and_then(|active| self.topbar_tabs.iter().find(|tab| tab.id == active))
.map(|tab| tab.view_mode)
.unwrap_or(self.view_mode)
} else {
self.view_mode
};
let id = self.next_topbar_tab_id;
self.next_topbar_tab_id = self.next_topbar_tab_id.saturating_add(1);
self.topbar_tabs.push(TopbarTab {
id,
content,
view_mode: saved_mode,
step_view_mode: self.step_view_mode,
stepping: self.stepping,
scroll_offset: 0,
horizontal_scroll: 0,
preview_rendered: true,
navigator_state: None,
});
self.active_topbar_tab = Some(id);
self.view_mode = ViewMode::Preview;
self.preview_forced_by_content = saved_mode != ViewMode::Preview;
self.scroll_offset = 0;
self.horizontal_scroll = 0;
self.clear_diff_selection();
}
fn open_virtual_comments_with_history(
&mut self,
content: TopbarTabContent,
focus: Option<u64>,
new_tab: bool,
) {
let history_origin = self.view_history_origin();
let was_replaying = self.view_history_replaying;
self.view_history_replaying = true;
if new_tab {
self.open_virtual_comments_tab(content, focus);
} else {
self.open_virtual_comments_in_current_tab(content, focus);
}
self.view_history_replaying = was_replaying;
let Some(tab_id) = self.active_topbar_tab else {
return;
};
let destination = match content {
TopbarTabContent::PrComments => super::ViewHistoryRecipe::PrComments {
tab_id,
focus_comment_id: focus,
},
TopbarTabContent::OutdatedComments => super::ViewHistoryRecipe::OutdatedComments {
tab_id,
focus_comment_id: focus,
},
TopbarTabContent::File(_) | TopbarTabContent::Help | TopbarTabContent::Settings => {
return
}
};
self.record_view_landing(history_origin, destination);
}
pub(crate) fn open_pr_comments_in_current_tab(&mut self, focus: Option<u64>) {
if self.request_settings_leave(super::settings::SettingsLeaveTarget::PrCommentsCurrent(
focus,
)) {
return;
}
self.open_virtual_comments_with_history(TopbarTabContent::PrComments, focus, false);
}
pub(crate) fn open_pr_comments_tab(&mut self, focus: Option<u64>) {
if self.request_settings_leave(super::settings::SettingsLeaveTarget::PrCommentsTab(focus)) {
return;
}
self.open_virtual_comments_with_history(TopbarTabContent::PrComments, focus, true);
}
pub(crate) fn open_outdated_comments_in_current_tab(&mut self, focus: Option<u64>) {
if self.request_settings_leave(
super::settings::SettingsLeaveTarget::OutdatedCommentsCurrent(focus),
) {
return;
}
self.open_virtual_comments_with_history(TopbarTabContent::OutdatedComments, focus, false);
}
pub(crate) fn open_outdated_comments_tab(&mut self, focus: Option<u64>) {
if self.request_settings_leave(super::settings::SettingsLeaveTarget::OutdatedCommentsTab(
focus,
)) {
return;
}
self.open_virtual_comments_with_history(TopbarTabContent::OutdatedComments, focus, true);
}
pub(crate) fn open_help_in_current_tab(&mut self) {
if self.request_settings_leave(super::settings::SettingsLeaveTarget::HelpCurrent) {
return;
}
self.restore_live_diff_after_outdated_view();
self.save_active_topbar_tab_state();
let Some(tab_id) = self.active_topbar_tab else {
return;
};
let Some(tab) = self.topbar_tabs.iter_mut().find(|tab| tab.id == tab_id) else {
return;
};
tab.content = TopbarTabContent::Help;
tab.view_mode = ViewMode::Preview;
tab.scroll_offset = 0;
tab.horizontal_scroll = 0;
tab.preview_rendered = true;
tab.navigator_state = None;
self.view_mode = ViewMode::Preview;
self.preview_forced_by_content = false;
self.scroll_offset = 0;
self.horizontal_scroll = 0;
self.clear_diff_selection();
self.show_help = false;
}
pub(crate) fn open_help_tab(&mut self) {
if self.request_settings_leave(super::settings::SettingsLeaveTarget::HelpTab) {
return;
}
let history_origin = self.view_history_origin();
let was_replaying = self.view_history_replaying;
self.view_history_replaying = true;
self.restore_live_diff_after_outdated_view();
if let Some(id) = self
.topbar_tabs
.iter()
.find(|tab| tab.content == TopbarTabContent::Help)
.map(|tab| tab.id)
{
self.select_topbar_tab(id);
} else {
self.save_active_topbar_tab_state();
let id = self.next_topbar_tab_id;
self.next_topbar_tab_id = self.next_topbar_tab_id.saturating_add(1);
self.topbar_tabs.push(TopbarTab {
id,
content: TopbarTabContent::Help,
view_mode: ViewMode::Preview,
step_view_mode: self.step_view_mode,
stepping: self.stepping,
scroll_offset: 0,
horizontal_scroll: 0,
preview_rendered: true,
navigator_state: None,
});
self.active_topbar_tab = Some(id);
self.view_mode = ViewMode::Preview;
self.preview_forced_by_content = false;
self.scroll_offset = 0;
self.horizontal_scroll = 0;
self.clear_diff_selection();
self.show_help = false;
}
self.view_history_replaying = was_replaying;
if let Some(tab_id) = self.active_topbar_tab {
self.record_view_landing(history_origin, super::ViewHistoryRecipe::Help { tab_id });
}
}
pub(crate) fn active_topbar_content(&self) -> Option<TopbarTabContent> {
self.active_topbar_tab.and_then(|id| {
self.topbar_tabs
.iter()
.find(|tab| tab.id == id)
.map(|tab| tab.content)
})
}
pub(crate) fn active_preview_rendered(&self) -> bool {
self.active_topbar_tab
.and_then(|id| self.topbar_tabs.iter().find(|tab| tab.id == id))
.map(|tab| tab.preview_rendered)
.unwrap_or(true)
}
pub(crate) fn toggle_preview_rendered(&mut self) {
let mut rendered = None;
if let Some(active) = self.active_topbar_tab {
if let Some(tab) = self.topbar_tabs.iter_mut().find(|tab| tab.id == active) {
tab.preview_rendered = !tab.preview_rendered;
rendered = Some(tab.preview_rendered);
}
}
if let Some(rendered) = rendered {
self.notify(ToastEvent::PreviewRendered(rendered));
}
}
pub(crate) fn set_image_picker(&mut self, picker: Picker) {
self.image_picker = Some(picker);
self.image_preview_cache = None;
}
pub(crate) fn ensure_terminal_image_preview(
&mut self,
path: &Path,
size: Size,
) -> Option<&Protocol> {
if size.width == 0 || size.height == 0 {
return None;
}
let picker = self.image_picker.as_ref()?.clone();
let metadata = std::fs::metadata(path).ok();
let signature = ImagePreviewSignature {
path: path.to_path_buf(),
size,
len: metadata.as_ref().map(std::fs::Metadata::len).unwrap_or(0),
modified: metadata.and_then(|metadata| metadata.modified().ok()),
};
if self
.image_preview_cache
.as_ref()
.is_some_and(|cache| cache.signature == signature)
{
return self
.image_preview_cache
.as_ref()
.map(|cache| &cache.protocol);
}
let image = image::ImageReader::open(path).ok()?.decode().ok()?;
let protocol = picker.new_protocol(image, size, Resize::Fit(None)).ok()?;
self.image_preview_cache = Some(ImagePreviewCache {
signature,
protocol,
});
self.image_preview_cache
.as_ref()
.map(|cache| &cache.protocol)
}
pub(crate) fn ensure_csv_preview(
&mut self,
signature: CsvPreviewSignature,
text: &str,
) -> Result<&mut CsvPreviewState, String> {
let Some(tab_id) = self.active_topbar_tab else {
return Err("No active tab".to_string());
};
let rebuild = self
.csv_previews
.get(&tab_id)
.is_none_or(|state| state.signature() != &signature);
if rebuild {
match CsvPreviewState::new(signature, text) {
Ok(state) => {
self.csv_previews.insert(tab_id, state);
}
Err(error) => {
self.csv_previews.remove(&tab_id);
return Err(error);
}
}
}
self.csv_previews
.get_mut(&tab_id)
.ok_or_else(|| "No CSV preview".to_string())
}
pub(crate) fn active_csv_preview_mut(&mut self) -> Option<&mut CsvPreviewState> {
if self.view_mode != ViewMode::Preview || !self.active_preview_rendered() {
return None;
}
let tab_id = self.active_topbar_tab?;
self.csv_previews.get_mut(&tab_id)
}
pub(crate) fn sync_scroll_from_csv_preview(&mut self) {
let viewport_height = self.last_viewport_height.saturating_sub(2).max(1);
if let Some(state) = self.active_csv_preview_mut() {
let line = state.selected_visual_line();
if line < self.scroll_offset {
self.scroll_offset = line;
} else if line >= self.scroll_offset.saturating_add(viewport_height) {
self.scroll_offset = line.saturating_sub(viewport_height.saturating_sub(1));
}
}
}
pub(crate) fn csv_preview_move_down(&mut self, count: usize) -> bool {
let Some(state) = self.active_csv_preview_mut().filter(|_| count > 0) else {
return false;
};
state.move_down(count);
self.sync_scroll_from_csv_preview();
true
}
pub(crate) fn csv_preview_move_up(&mut self, count: usize) -> bool {
let Some(state) = self.active_csv_preview_mut().filter(|_| count > 0) else {
return false;
};
state.move_up(count);
self.sync_scroll_from_csv_preview();
true
}
pub(crate) fn csv_preview_move_left(&mut self, count: usize) -> bool {
let Some(state) = self.active_csv_preview_mut().filter(|_| count > 0) else {
return false;
};
state.move_left(count);
true
}
pub(crate) fn csv_preview_move_right(&mut self, count: usize) -> bool {
let Some(state) = self.active_csv_preview_mut().filter(|_| count > 0) else {
return false;
};
state.move_right(count);
true
}
pub(crate) fn csv_preview_focus_top(&mut self) -> bool {
let Some(state) = self.active_csv_preview_mut() else {
return false;
};
state.focus_top();
self.sync_scroll_from_csv_preview();
true
}
pub(crate) fn csv_preview_focus_bottom(&mut self) -> bool {
let Some(state) = self.active_csv_preview_mut() else {
return false;
};
state.focus_bottom();
self.sync_scroll_from_csv_preview();
true
}
pub(crate) fn ensure_structured_preview(
&mut self,
signature: StructuredPreviewSignature,
text: &str,
) -> Result<&mut StructuredPreviewState, String> {
let Some(tab_id) = self.active_topbar_tab else {
return Err("No active tab".to_string());
};
let rebuild = self
.structured_previews
.get(&tab_id)
.is_none_or(|state| state.signature() != &signature);
if rebuild {
match StructuredPreviewState::new(signature, text) {
Ok(state) => {
self.structured_previews.insert(tab_id, state);
}
Err(error) => {
self.structured_previews.remove(&tab_id);
return Err(error);
}
}
}
self.structured_previews
.get_mut(&tab_id)
.ok_or_else(|| "No structured preview".to_string())
}
pub(crate) fn active_structured_preview_mut(&mut self) -> Option<&mut StructuredPreviewState> {
if self.view_mode != ViewMode::Preview || !self.active_preview_rendered() {
return None;
}
let tab_id = self.active_topbar_tab?;
self.structured_previews.get_mut(&tab_id)
}
pub(crate) fn sync_scroll_from_structured_preview(&mut self) {
if let Some(state) = self.active_structured_preview_mut() {
self.scroll_offset = state.top_visible_offset();
}
}
pub(crate) fn structured_preview_move_down(&mut self, count: usize) -> bool {
let Some(state) = self.active_structured_preview_mut().filter(|_| count > 0) else {
return false;
};
state.move_down(count);
self.sync_scroll_from_structured_preview();
true
}
pub(crate) fn structured_preview_move_up(&mut self, count: usize) -> bool {
let Some(state) = self.active_structured_preview_mut().filter(|_| count > 0) else {
return false;
};
state.move_up(count);
self.sync_scroll_from_structured_preview();
true
}
pub(crate) fn structured_preview_move_left(&mut self) -> bool {
let Some(state) = self.active_structured_preview_mut() else {
return false;
};
state.move_left();
self.sync_scroll_from_structured_preview();
true
}
pub(crate) fn structured_preview_move_right(&mut self) -> bool {
let Some(state) = self.active_structured_preview_mut() else {
return false;
};
state.move_right();
self.sync_scroll_from_structured_preview();
true
}
pub(crate) fn structured_preview_focus_top(&mut self) -> bool {
let Some(state) = self.active_structured_preview_mut() else {
return false;
};
state.focus_top();
self.sync_scroll_from_structured_preview();
true
}
pub(crate) fn structured_preview_focus_bottom(&mut self) -> bool {
let Some(state) = self.active_structured_preview_mut() else {
return false;
};
state.focus_bottom();
self.sync_scroll_from_structured_preview();
true
}
pub(crate) fn structured_preview_jump_up(&mut self, count: Option<usize>) -> bool {
let Some(state) = self.active_structured_preview_mut() else {
return false;
};
state.jump_up(count);
self.sync_scroll_from_structured_preview();
true
}
pub(crate) fn structured_preview_jump_down(&mut self, count: Option<usize>) -> bool {
let Some(state) = self.active_structured_preview_mut() else {
return false;
};
state.jump_down(count);
self.sync_scroll_from_structured_preview();
true
}
pub(crate) fn structured_preview_toggle_collapsed(&mut self) -> bool {
let Some(state) = self.active_structured_preview_mut() else {
return false;
};
state.toggle_collapsed();
self.sync_scroll_from_structured_preview();
true
}
pub(crate) fn structured_preview_collapse_node_and_siblings(&mut self, deep: bool) -> bool {
let Some(state) = self.active_structured_preview_mut() else {
return false;
};
if deep {
state.deep_collapse_node_and_siblings();
} else {
state.collapse_node_and_siblings();
}
self.sync_scroll_from_structured_preview();
true
}
pub(crate) fn structured_preview_expand_node_and_siblings(&mut self, deep: bool) -> bool {
let Some(state) = self.active_structured_preview_mut() else {
return false;
};
if deep {
state.deep_expand_node_and_siblings();
} else {
state.expand_node_and_siblings();
}
self.sync_scroll_from_structured_preview();
true
}
pub(crate) fn structured_preview_toggle_mode(&mut self) -> bool {
let Some(state) = self.active_structured_preview_mut() else {
return false;
};
state.toggle_mode();
self.sync_scroll_from_structured_preview();
true
}
pub(crate) fn handle_structured_preview_click(&mut self, column: u16, row: u16) -> bool {
if self.view_mode != ViewMode::Preview || !self.active_preview_rendered() {
return false;
}
let Some((x, y, width, height)) = self.diff_view_area else {
return false;
};
if column < x
|| column >= x.saturating_add(width)
|| row < y
|| row >= y.saturating_add(height)
{
return false;
}
let Some(state) = self.active_structured_preview_mut() else {
return false;
};
state.click(row.saturating_sub(y).saturating_add(1));
self.sync_scroll_from_structured_preview();
true
}
pub(crate) fn mouse_over_topbar(&self, column: u16, row: u16) -> bool {
self.topbar_area.is_some_and(|(x, y, width, height)| {
column >= x
&& column < x.saturating_add(width)
&& row >= y
&& row < y.saturating_add(height)
})
}
pub(crate) fn scroll_topbar_tabs(&mut self, delta: isize) -> bool {
let max_scroll = self.topbar_tabs.len().saturating_sub(1);
let old = self.topbar_tab_scroll.min(max_scroll);
let next = if delta.is_negative() {
old.saturating_sub(delta.unsigned_abs())
} else {
old.saturating_add(delta as usize).min(max_scroll)
};
self.topbar_tab_scroll = next;
old != next
}
pub(crate) fn handle_no_changes_dashboard_click(&mut self, column: u16, row: u16) -> bool {
let hit = self
.no_changes_dashboard_hit
.is_some_and(|(x, y, width, height)| {
column >= x
&& column < x.saturating_add(width)
&& row >= y
&& row < y.saturating_add(height)
});
if hit {
self.open_dashboard = true;
}
hit
}
pub(crate) fn handle_no_changes_quit_click(&mut self, column: u16, row: u16) -> bool {
let hit = self
.no_changes_quit_hit
.is_some_and(|(x, y, width, height)| {
column >= x
&& column < x.saturating_add(width)
&& row >= y
&& row < y.saturating_add(height)
});
if hit {
self.request_quit();
}
hit
}
pub(crate) fn handle_status_bar_mouse_down(
&mut self,
column: u16,
row: u16,
reverse: bool,
) -> bool {
let hit = self.status_mode_hit.is_some_and(|(x, y, width, height)| {
column >= x
&& column < x.saturating_add(width)
&& row >= y
&& row < y.saturating_add(height)
});
if !hit {
return false;
}
if reverse {
self.toggle_view_mode_reverse();
} else {
self.toggle_view_mode();
}
true
}
pub(crate) fn open_status_mode_menu(&mut self, column: u16, row: u16) -> bool {
let hit = self.status_mode_hit.is_some_and(|(x, y, width, height)| {
column >= x
&& column < x.saturating_add(width)
&& row >= y
&& row < y.saturating_add(height)
});
if !hit {
return false;
}
self.close_file_context_menu();
self.close_review_comment_context_menu();
self.status_mode_menu = Some(StatusModeMenu { x: column, y: row });
self.status_mode_menu_hover = None;
true
}
pub(crate) fn close_status_mode_menu(&mut self) -> bool {
let was_open = self.status_mode_menu.take().is_some();
self.status_mode_menu_hits.clear();
self.status_mode_menu_hover = None;
was_open
}
fn status_mode_menu_mode_at(&self, column: u16, row: u16) -> Option<ViewMode> {
self.status_mode_menu_hits
.iter()
.find(|hit| {
column >= hit.x
&& column < hit.x.saturating_add(hit.width)
&& row >= hit.y
&& row < hit.y.saturating_add(hit.height)
})
.map(|hit| hit.mode)
}
pub(crate) fn update_status_mode_menu_hover(&mut self, column: u16, row: u16) -> bool {
let hover = self.status_mode_menu_mode_at(column, row);
if self.status_mode_menu_hover == hover {
return false;
}
self.status_mode_menu_hover = hover;
true
}
pub(crate) fn handle_status_mode_menu_click(&mut self, column: u16, row: u16) -> bool {
let Some(mode) = self.status_mode_menu_mode_at(column, row) else {
return self.close_status_mode_menu();
};
self.set_view_mode(mode);
self.close_status_mode_menu();
true
}
pub(crate) fn status_mode_menu_open(&self) -> bool {
self.status_mode_menu.is_some()
}
pub(crate) fn handle_binary_preview_click(&mut self, column: u16, row: u16) -> bool {
let hit = self
.binary_preview_hit
.is_some_and(|(x, y, width, height)| {
column >= x
&& column < x.saturating_add(width)
&& row >= y
&& row < y.saturating_add(height)
});
if !hit {
return false;
}
self.set_view_mode(ViewMode::Preview);
self.clear_diff_selection();
true
}
pub(crate) fn handle_topbar_mouse_down(&mut self, column: u16, row: u16) -> bool {
self.update_topbar_hover(column, row);
if self
.preview_toggle_hit
.is_some_and(|(x, y, width, height)| {
column >= x
&& column < x.saturating_add(width)
&& row >= y
&& row < y.saturating_add(height)
})
{
self.toggle_preview_rendered();
return true;
}
if self
.topbar_sidebar_toggle_hit
.is_some_and(|(x, y, width, height)| {
column >= x
&& column < x.saturating_add(width)
&& row >= y
&& row < y.saturating_add(height)
})
{
self.toggle_file_panel();
return true;
}
if self
.topbar_scroll_left_hit
.is_some_and(|(x, y, width, height)| {
column >= x
&& column < x.saturating_add(width)
&& row >= y
&& row < y.saturating_add(height)
})
{
self.scroll_topbar_tabs(-1);
return true;
}
if self
.topbar_scroll_right_hit
.is_some_and(|(x, y, width, height)| {
column >= x
&& column < x.saturating_add(width)
&& row >= y
&& row < y.saturating_add(height)
})
{
self.scroll_topbar_tabs(1);
return true;
}
if self.topbar_plus_hit.is_some_and(|(x, y, width, height)| {
column >= x
&& column < x.saturating_add(width)
&& row >= y
&& row < y.saturating_add(height)
}) {
self.new_topbar_tab();
self.start_file_search();
return true;
}
let Some(hit) = self.topbar_hit(column, row) else {
return false;
};
if hit.close_col == Some(column) && self.topbar_close_allowed(hit.tab_id) {
self.close_topbar_tab(hit.tab_id);
return true;
}
self.select_topbar_tab(hit.tab_id);
self.topbar_drag_tab = Some(hit.tab_id);
self.topbar_drag_target = None;
true
}
pub(crate) fn drag_topbar_tab(&mut self, column: u16, row: u16) -> bool {
let Some(dragged) = self.topbar_drag_tab else {
return false;
};
self.topbar_drag_target = self.topbar_drop_target(dragged, column, row);
true
}
pub(crate) fn finish_topbar_drag(&mut self) -> bool {
let Some(dragged) = self.topbar_drag_tab.take() else {
return false;
};
if let Some(target) = self.topbar_drag_target.take() {
self.move_topbar_tab(dragged, target);
}
true
}
fn move_topbar_tab(&mut self, tab_id: usize, target: usize) {
let Some(from) = self.topbar_tabs.iter().position(|tab| tab.id == tab_id) else {
return;
};
let tab = self.topbar_tabs.remove(from);
let mut to = target.min(self.topbar_tabs.len() + 1);
if to > from {
to = to.saturating_sub(1);
}
self.topbar_tabs.insert(to.min(self.topbar_tabs.len()), tab);
}
pub(crate) fn update_topbar_hover(&mut self, column: u16, row: u16) -> bool {
let hit = self.topbar_hit(column, row);
let hover = hit.map(|hit| hit.tab_id);
let close_hover = hit
.filter(|hit| hit.close_col == Some(column))
.map(|hit| hit.tab_id);
let plus_hover = self.topbar_plus_hit.is_some_and(|(x, y, width, height)| {
column >= x
&& column < x.saturating_add(width)
&& row >= y
&& row < y.saturating_add(height)
});
let scroll_left_hover = self
.topbar_scroll_left_hit
.is_some_and(|(x, y, width, height)| {
column >= x
&& column < x.saturating_add(width)
&& row >= y
&& row < y.saturating_add(height)
});
let scroll_right_hover =
self.topbar_scroll_right_hit
.is_some_and(|(x, y, width, height)| {
column >= x
&& column < x.saturating_add(width)
&& row >= y
&& row < y.saturating_add(height)
});
let preview_hover = self
.preview_toggle_hit
.is_some_and(|(x, y, width, height)| {
column >= x
&& column < x.saturating_add(width)
&& row >= y
&& row < y.saturating_add(height)
});
let sidebar_hover = self
.topbar_sidebar_toggle_hit
.is_some_and(|(x, y, width, height)| {
column >= x
&& column < x.saturating_add(width)
&& row >= y
&& row < y.saturating_add(height)
});
let status_comments_hover =
self.status_comments_hit
.is_some_and(|(x, y, width, height)| {
column >= x
&& column < x.saturating_add(width)
&& row >= y
&& row < y.saturating_add(height)
});
let status_file_hover = self.status_file_hit.is_some_and(|(x, y, width, height)| {
column >= x
&& column < x.saturating_add(width)
&& row >= y
&& row < y.saturating_add(height)
});
let binary_preview_hover = self
.binary_preview_hit
.is_some_and(|(x, y, width, height)| {
column >= x
&& column < x.saturating_add(width)
&& row >= y
&& row < y.saturating_add(height)
});
let review_file_comment_hover =
self.review_file_comment_hit
.is_some_and(|(x, y, width, height)| {
column >= x
&& column < x.saturating_add(width)
&& row >= y
&& row < y.saturating_add(height)
});
let no_changes_dashboard_hover =
self.no_changes_dashboard_hit
.is_some_and(|(x, y, width, height)| {
column >= x
&& column < x.saturating_add(width)
&& row >= y
&& row < y.saturating_add(height)
});
let no_changes_quit_hover =
self.no_changes_quit_hit
.is_some_and(|(x, y, width, height)| {
column >= x
&& column < x.saturating_add(width)
&& row >= y
&& row < y.saturating_add(height)
});
let file_panel_hover = self.mouse_over_file_panel(column, row);
let file_panel_mode_toggle_hover =
self.file_panel_mode_toggle_hit
.is_some_and(|(x, y, width, height)| {
column >= x
&& column < x.saturating_add(width)
&& row >= y
&& row < y.saturating_add(height)
});
let file_panel_root_hover =
self.file_panel_root_hit
.is_some_and(|(x, y, width, height)| {
column >= x
&& column < x.saturating_add(width)
&& row >= y
&& row < y.saturating_add(height)
});
let file_filter_hover = self.file_filter_area.is_some_and(|(x, y, width, height)| {
column >= x
&& column < x.saturating_add(width)
&& row >= y
&& row < y.saturating_add(height)
});
let file_filter_clear_hover =
self.file_filter_clear_hit
.is_some_and(|(x, y, width, height)| {
column >= x
&& column < x.saturating_add(width)
&& row >= y
&& row < y.saturating_add(height)
});
let comments_sidebar_sync_hover =
self.comments_sidebar_sync_hit
.is_some_and(|(x, y, width, height)| {
column >= x
&& column < x.saturating_add(width)
&& row >= y
&& row < y.saturating_add(height)
});
let comments_sidebar_discard_hover =
self.comments_sidebar_discard_hit
.is_some_and(|(x, y, width, height)| {
column >= x
&& column < x.saturating_add(width)
&& row >= y
&& row < y.saturating_add(height)
});
let comments_sidebar_overflow_hover =
self.comments_sidebar_overflow_hit
.is_some_and(|(x, y, width, height)| {
column >= x
&& column < x.saturating_add(width)
&& row >= y
&& row < y.saturating_add(height)
});
let comments_sidebar_overflow_menu_hover = self
.comments_sidebar_overflow_hits
.iter()
.find(|hit| {
column >= hit.x
&& column < hit.x.saturating_add(hit.width)
&& row >= hit.y
&& row < hit.y.saturating_add(hit.height)
})
.map(|hit| hit.action);
let file_hover = self.file_list_item_at(column, row);
let file_context_menu_hover = self.file_context_menu_action_at(column, row);
let selection_hover = self
.selection_toolbar_hits
.iter()
.find(|hit| {
column >= hit.x
&& column < hit.x.saturating_add(hit.width)
&& row >= hit.y
&& row < hit.y.saturating_add(hit.height)
})
.map(|hit| hit.action);
let fold_context_hover = self
.fold_context_hits
.iter()
.find(|hit| {
column >= hit.x
&& column < hit.x.saturating_add(hit.width)
&& row >= hit.y
&& row < hit.y.saturating_add(hit.height)
})
.map(|hit| (hit.key, hit.direction));
let (review_line_add_row, review_line_add_hover) = if matches!(
self.active_topbar_content(),
Some(TopbarTabContent::File(_))
) {
self.review_line_add_hover_at(column, row)
} else {
(None, false)
};
let review_line_add_side =
review_line_add_row.and_then(|_| self.review_side_at_screen_column(column));
let review_preview_hit = self.review_preview_boxes.iter().rev().find_map(|hit| {
let end_x = hit.x.saturating_add(hit.width);
let end_y = hit.y.saturating_add(hit.height);
(column >= hit.x && column < end_x && row >= hit.y && row < end_y).then(|| {
(
hit.comment_id,
hit.anchor_key.clone(),
hit.edit,
hit.reply,
hit.resolve,
hit.delete,
hit.overflow,
)
})
});
let review_preview_hover_id = review_preview_hit.as_ref().and_then(|(id, ..)| *id);
let review_preview_hover = review_preview_hit
.as_ref()
.map(|(_, anchor_key, _, _, _, _, _)| anchor_key.clone());
let review_preview_edit_hover = review_preview_hit
.as_ref()
.and_then(|(id, _, edit, _, _, _, _)| edit.then_some(*id).flatten());
let review_preview_reply_hover = review_preview_hit
.as_ref()
.and_then(|(id, _, _, reply, _, _, _)| reply.then_some(*id).flatten());
let review_preview_resolve_hover = review_preview_hit
.as_ref()
.and_then(|(id, _, _, _, resolve, _, _)| resolve.then_some(*id).flatten());
let review_preview_delete_hover = review_preview_hit
.as_ref()
.and_then(|(id, _, _, _, _, delete, _)| delete.then_some(*id).flatten());
let review_preview_overflow_hover = review_preview_hit
.and_then(|(id, _, _, _, _, _, overflow)| overflow.then_some(id).flatten());
let pr_comment_action_hover = self
.pr_comment_hits
.iter()
.find(|hit| {
column >= hit.x
&& column < hit.x.saturating_add(hit.width)
&& row >= hit.y
&& row < hit.y.saturating_add(hit.height)
})
.map(|hit| hit.action);
let pr_comment_action_hover_key = pr_comment_action_hover.and_then(|action| match action {
super::review::PrCommentHitAction::Delete(_) => None,
super::review::PrCommentHitAction::Edit(id) => self.pull_request_edit_label(id),
super::review::PrCommentHitAction::Reply(id) => self.pull_request_reply_label(id),
super::review::PrCommentHitAction::Open(_) => None,
});
let pr_comment_add_hover = self
.pr_comment_add_hit
.is_some_and(|(x, y, width, height)| {
column >= x
&& column < x.saturating_add(width)
&& row >= y
&& row < y.saturating_add(height)
});
let review_editor_hover = self
.review_editor_toolbar_hits
.iter()
.find(|hit| {
column >= hit.x
&& column < hit.x.saturating_add(hit.width)
&& row >= hit.y
&& row < hit.y.saturating_add(hit.height)
})
.map(|hit| hit.action);
if self.topbar_hover_tab == hover
&& self.topbar_hover_close == close_hover
&& self.topbar_plus_hover == plus_hover
&& self.topbar_scroll_left_hover == scroll_left_hover
&& self.topbar_scroll_right_hover == scroll_right_hover
&& self.preview_toggle_hover == preview_hover
&& self.topbar_sidebar_toggle_hover == sidebar_hover
&& self.status_comments_hover == status_comments_hover
&& self.status_file_hover == status_file_hover
&& self.binary_preview_hover == binary_preview_hover
&& self.review_file_comment_hover == review_file_comment_hover
&& self.no_changes_dashboard_hover == no_changes_dashboard_hover
&& self.no_changes_quit_hover == no_changes_quit_hover
&& self.file_list_hover == file_hover
&& self.file_context_menu_hover == file_context_menu_hover
&& self.file_panel_hover == file_panel_hover
&& self.file_panel_mode_toggle_hover == file_panel_mode_toggle_hover
&& self.file_panel_root_hover == file_panel_root_hover
&& self.file_filter_hover == file_filter_hover
&& self.file_filter_clear_hover == file_filter_clear_hover
&& self.comments_sidebar_sync_hover == comments_sidebar_sync_hover
&& self.comments_sidebar_discard_hover == comments_sidebar_discard_hover
&& self.comments_sidebar_overflow_hover == comments_sidebar_overflow_hover
&& self.comments_sidebar_overflow_menu_hover == comments_sidebar_overflow_menu_hover
&& self.selection_toolbar_hover == selection_hover
&& self.fold_context_hover == fold_context_hover
&& self.review_line_add_row == review_line_add_row
&& self.review_line_add_side == review_line_add_side
&& self.review_line_add_hover == review_line_add_hover
&& self.review_preview_hover == review_preview_hover
&& self.review_preview_hover_id == review_preview_hover_id
&& self.review_preview_edit_hover == review_preview_edit_hover
&& self.review_preview_reply_hover == review_preview_reply_hover
&& self.review_preview_resolve_hover == review_preview_resolve_hover
&& self.review_preview_delete_hover == review_preview_delete_hover
&& self.review_preview_overflow_hover == review_preview_overflow_hover
&& self.pr_comment_action_hover == pr_comment_action_hover
&& self.pr_comment_action_hover_key == pr_comment_action_hover_key
&& self.pr_comment_add_hover == pr_comment_add_hover
&& self.review_editor_toolbar_hover == review_editor_hover
{
return false;
}
self.selection_toolbar_hover = selection_hover;
self.fold_context_hover = fold_context_hover;
self.review_line_add_row = review_line_add_row;
self.review_line_add_side = review_line_add_side;
self.review_line_add_hover = review_line_add_hover;
self.review_preview_hover = review_preview_hover;
self.review_preview_hover_id = review_preview_hover_id;
self.maybe_preload_hovered_outdated_reconstruction();
self.review_preview_edit_hover = review_preview_edit_hover;
self.review_preview_reply_hover = review_preview_reply_hover;
self.review_preview_resolve_hover = review_preview_resolve_hover;
self.review_preview_delete_hover = review_preview_delete_hover;
self.review_preview_overflow_hover = review_preview_overflow_hover;
self.pr_comment_action_hover = pr_comment_action_hover;
self.pr_comment_action_hover_key = pr_comment_action_hover_key;
self.pr_comment_add_hover = pr_comment_add_hover;
self.review_editor_toolbar_hover = review_editor_hover;
self.topbar_hover_tab = hover;
self.topbar_hover_close = close_hover;
self.topbar_plus_hover = plus_hover;
self.topbar_scroll_left_hover = scroll_left_hover;
self.topbar_scroll_right_hover = scroll_right_hover;
self.preview_toggle_hover = preview_hover;
self.topbar_sidebar_toggle_hover = sidebar_hover;
self.status_comments_hover = status_comments_hover;
self.status_file_hover = status_file_hover;
self.binary_preview_hover = binary_preview_hover;
self.review_file_comment_hover = review_file_comment_hover;
self.no_changes_dashboard_hover = no_changes_dashboard_hover;
self.no_changes_quit_hover = no_changes_quit_hover;
self.file_list_hover = file_hover;
self.file_context_menu_hover = file_context_menu_hover;
self.file_panel_hover = file_panel_hover;
self.file_panel_mode_toggle_hover = file_panel_mode_toggle_hover;
self.file_panel_root_hover = file_panel_root_hover;
self.file_filter_hover = file_filter_hover;
self.file_filter_clear_hover = file_filter_clear_hover;
self.comments_sidebar_sync_hover = comments_sidebar_sync_hover;
self.comments_sidebar_discard_hover = comments_sidebar_discard_hover;
self.comments_sidebar_overflow_hover = comments_sidebar_overflow_hover;
self.comments_sidebar_overflow_menu_hover = comments_sidebar_overflow_menu_hover;
true
}
fn topbar_drop_target(&self, tab_id: usize, column: u16, row: u16) -> Option<usize> {
let from = self.topbar_tabs.iter().position(|tab| tab.id == tab_id)?;
let target = self.topbar_tab_insert_index(column, row)?;
if target == from || target == from + 1 {
return None;
}
Some(target)
}
fn topbar_tab_insert_index(&self, column: u16, row: u16) -> Option<usize> {
if self.topbar_tab_hits.first()?.row != row {
return None;
}
for hit in &self.topbar_tab_hits {
let pos = self
.topbar_tabs
.iter()
.position(|tab| tab.id == hit.tab_id)?;
if column < hit.start_col {
return Some(pos);
}
if column < hit.end_col {
let midpoint = hit
.start_col
.saturating_add((hit.end_col - hit.start_col) / 2);
return Some(if column < midpoint { pos } else { pos + 1 });
}
}
let last = self.topbar_tab_hits.last()?;
let last_pos = self
.topbar_tabs
.iter()
.position(|tab| tab.id == last.tab_id)?;
let end_col = self
.topbar_plus_hit
.map(|(x, _, _, _)| x)
.unwrap_or(last.end_col);
(column <= end_col).then_some(last_pos + 1)
}
fn topbar_hit(&self, column: u16, row: u16) -> Option<super::TopbarTabHit> {
self.topbar_tab_hits
.iter()
.copied()
.find(|hit| row == hit.row && column >= hit.start_col && column < hit.end_col)
}
pub fn start_file_filter(&mut self) {
self.file_filter.clear();
self.focus_file_filter();
}
pub(crate) fn focus_file_filter(&mut self) {
self.file_filter_active = true;
self.file_filter_cursor_visible = true;
self.file_filter_cursor_last_blink = std::time::Instant::now();
self.file_list_scroll = 0;
self.ensure_selection_matches_filter();
self.update_file_list_scroll();
}
pub fn stop_file_filter(&mut self) {
self.file_filter_active = false;
self.file_filter_cursor_visible = true;
}
pub fn push_file_filter_char(&mut self, ch: char) {
self.file_filter.push(ch);
self.reset_file_filter_cursor();
self.on_filter_changed();
}
pub fn pop_file_filter_char(&mut self) {
self.file_filter.pop();
self.reset_file_filter_cursor();
self.on_filter_changed();
}
pub fn clear_file_filter(&mut self) {
self.file_filter.clear();
self.reset_file_filter_cursor();
self.on_filter_changed();
}
fn reset_file_filter_cursor(&mut self) {
self.file_filter_cursor_visible = true;
self.file_filter_cursor_last_blink = std::time::Instant::now();
}
fn is_blank_at_step0(&self) -> bool {
self.multi_diff.current_old_is_empty() && !self.multi_diff.current_new_is_empty()
}
pub fn handle_file_enter(&mut self) {
if self.multi_diff.file_count() == 0 {
return;
}
self.queue_current_file_diff();
if !self.current_file_diff_ready() {
return;
}
self.finish_file_enter();
}
pub(crate) fn finish_file_enter(&mut self) {
if self.multi_diff.file_count() == 0 {
return;
}
let idx = self.multi_diff.selected_index;
if !self.stepping {
if !self.files_visited[idx] {
self.files_visited[idx] = true;
}
self.ensure_step_state_snapshot(idx);
self.multi_diff.current_navigator().goto_end();
self.multi_diff.current_navigator().clear_active_change();
self.animation_phase = AnimationPhase::Idle;
self.animation_progress = 1.0;
if !self.restore_no_step_state_snapshot(idx) {
if self.no_step_auto_jump_on_enter && !self.no_step_visited[idx] {
self.goto_hunk_index_scroll(0);
} else {
self.set_cursor_for_current_scroll();
self.multi_diff.current_navigator().set_hunk_scope(false);
}
}
self.no_step_visited[idx] = true;
return;
}
if self.files_visited[idx] {
return;
}
let is_large = self.multi_diff.file_is_large(idx);
if is_large {
self.files_visited[idx] = true;
return;
}
self.files_visited[idx] = true;
let state = self.multi_diff.current_navigator().state();
let at_step_0 = state.current_step == 0;
let has_steps = state.total_steps > 1;
if !at_step_0 || !has_steps {
return;
}
if self.auto_step_blank_files && self.is_blank_at_step0() {
self.next_step();
return;
}
if self.auto_step_on_enter && self.view_mode != ViewMode::Evolution {
self.next_step();
}
}
pub fn can_show_file_panel(&self) -> bool {
true
}
pub(super) fn update_file_list_scroll(&mut self) {
let indices = self.filtered_file_indices();
if indices.is_empty() {
self.file_list_scroll = 0;
return;
}
let selected = self.multi_diff.selected_index;
let selected_row = self.file_list_row_for_file(&indices, selected).unwrap_or(0);
if selected_row < self.file_list_scroll {
self.file_list_scroll = selected_row;
}
let visible_rows = self.file_list_visible_rows();
if selected_row >= self.file_list_scroll.saturating_add(visible_rows) {
self.file_list_scroll = selected_row.saturating_sub(visible_rows - 1);
}
let max_scroll = self
.file_list_total_rows(&indices)
.saturating_sub(visible_rows);
self.file_list_scroll = self.file_list_scroll.min(max_scroll);
}
pub(crate) fn file_list_total_rows(&self, indices: &[usize]) -> usize {
if !self.file_filter.is_empty() {
return indices.len();
}
let mut rows = 0usize;
let mut current_group: Option<String> = None;
for &index in indices {
let group = self.file_list_group(index);
if current_group.as_deref() != Some(group.as_str()) {
if current_group.is_some() {
rows += 1;
}
rows += 1;
current_group = Some(group);
}
rows += 1;
}
rows
}
fn file_list_row_for_file(&self, indices: &[usize], target: usize) -> Option<usize> {
if !self.file_filter.is_empty() {
return indices.iter().position(|index| *index == target);
}
let mut row = 0usize;
let mut current_group: Option<String> = None;
for &index in indices {
let group = self.file_list_group(index);
if current_group.as_deref() != Some(group.as_str()) {
if current_group.is_some() {
row += 1;
}
row += 1;
current_group = Some(group);
}
if index == target {
return Some(row);
}
row += 1;
}
None
}
pub(crate) fn file_list_group(&self, index: usize) -> String {
self.multi_diff
.files
.get(index)
.and_then(|file| {
file.display_name
.rsplit_once('/')
.map(|(dir, _)| dir.to_string())
})
.unwrap_or_else(|| "Root Path".to_string())
}
fn file_list_visible_rows(&self) -> usize {
self.file_list_area
.map(|(_, _, _, height)| height.saturating_sub(2) as usize)
.unwrap_or(20)
.max(1)
}
fn on_filter_changed(&mut self) {
self.file_list_scroll = 0;
self.ensure_selection_matches_filter();
if self.file_panel_mode == FilePanelMode::Files {
self.move_selection_off_hidden_reviewed_file();
}
self.update_file_list_scroll();
}
fn ensure_selection_matches_filter(&mut self) {
if self.file_panel_mode != FilePanelMode::Files || self.file_filter.is_empty() {
return;
}
let indices = self.filtered_file_indices();
if indices.is_empty() {
return;
}
if !indices.contains(&self.multi_diff.selected_index) {
self.select_file(indices[0]);
}
}
pub fn filtered_file_indices(&self) -> Vec<usize> {
self.file_indices_for_query(&self.file_filter)
}
pub(super) fn file_indices_for_query(&self, query: &str) -> Vec<usize> {
self.fuzzy_file_matches_for_query(query)
.into_iter()
.map(|matched| matched.file_index)
.collect()
}
pub fn current_file_path(&self) -> String {
if self.active_topbar_content() == Some(TopbarTabContent::Help) {
return "Help".to_string();
}
if self.active_topbar_content() == Some(TopbarTabContent::Settings) {
return "Settings".to_string();
}
if self.active_topbar_content() == Some(TopbarTabContent::PrComments) {
return format!(
"{} comments",
self.review_provider_kind().long_review_noun()
);
}
if self.active_topbar_content() == Some(TopbarTabContent::OutdatedComments) {
return "Outdated comments".to_string();
}
self.multi_diff
.current_file()
.map(|f| f.display_name.clone())
.unwrap_or_default()
}
pub(crate) fn git_index_stamp(&self) -> FileDiskStamp {
if !self.multi_diff.uses_git_index() {
return FileDiskStamp::default();
}
let Some(repo_root) = self.multi_diff.repo_root() else {
return FileDiskStamp::default();
};
let Ok(path) = oyo_core::git::get_index_path(repo_root) else {
return FileDiskStamp::default();
};
match std::fs::metadata(path) {
Ok(meta) => FileDiskStamp {
modified: meta.modified().ok(),
len: meta.len(),
exists: true,
},
Err(_) => FileDiskStamp::default(),
}
}
fn disk_stamp_for_index(&self, idx: usize) -> FileDiskStamp {
if self.multi_diff.remote_snapshot.is_some() {
return FileDiskStamp::default();
}
let Some(file) = self.multi_diff.files.get(idx) else {
return FileDiskStamp::default();
};
let full_path = self
.multi_diff
.source_path(idx, FileSide::New)
.unwrap_or_else(|| {
if let Some(repo_root) = self.multi_diff.repo_root() {
repo_root.join(&file.path)
} else {
file.path.clone()
}
});
match std::fs::metadata(&full_path) {
Ok(meta) => FileDiskStamp {
modified: meta.modified().ok(),
len: meta.len(),
exists: true,
},
Err(_) => FileDiskStamp::default(),
}
}
pub(crate) fn rebuild_file_disk_baseline(&mut self) {
let file_count = self.multi_diff.file_count();
self.file_disk_baseline = (0..file_count)
.map(|idx| self.disk_stamp_for_index(idx))
.collect();
self.file_disk_changed = vec![false; file_count];
self.file_recently_changed_until = vec![None; file_count];
}
fn refresh_file_disk_baseline_for(&mut self, idx: usize) {
if self.file_disk_baseline.len() != self.multi_diff.file_count() {
self.rebuild_file_disk_baseline();
return;
}
let stamp = self.disk_stamp_for_index(idx);
if let Some(slot) = self.file_disk_baseline.get_mut(idx) {
*slot = stamp;
}
}
fn recompute_file_change_state(&mut self) -> bool {
let old_any = self.files_changed_on_disk;
let old_changed = self.file_disk_changed.clone();
let file_count = self.multi_diff.file_count();
if self.file_disk_baseline.len() != file_count {
self.rebuild_file_disk_baseline();
}
if self.file_disk_changed.len() != file_count {
self.file_disk_changed = vec![false; file_count];
}
let mut any_changed = false;
for idx in 0..file_count {
let changed = self.disk_stamp_for_index(idx) != self.file_disk_baseline[idx];
if let Some(slot) = self.file_disk_changed.get_mut(idx) {
*slot = changed;
}
any_changed |= changed;
}
self.files_changed_on_disk = any_changed;
old_any != self.files_changed_on_disk || old_changed != self.file_disk_changed
}
pub(crate) fn file_changed_on_disk(&self, idx: usize) -> bool {
self.file_disk_changed.get(idx).copied().unwrap_or(false)
|| self
.file_recently_changed_until
.get(idx)
.and_then(|until| *until)
.is_some_and(|until| Instant::now() < until)
}
pub(crate) fn files_changed_indicator_active(&self) -> bool {
self.files_changed_on_disk
|| self
.file_recently_changed_until
.iter()
.flatten()
.any(|until| Instant::now() < *until)
}
pub(crate) fn expire_recent_file_changes(&mut self, now: Instant) -> bool {
let mut expired = false;
for until in &mut self.file_recently_changed_until {
if until.is_some_and(|until| now >= until) {
*until = None;
expired = true;
}
}
expired
}
pub fn maybe_check_file_changes(&mut self) -> bool {
let now = Instant::now();
if now.duration_since(self.last_fs_check) < Duration::from_secs(1) {
return false;
}
self.last_fs_check = now;
self.recompute_file_change_state()
}
pub(crate) fn maybe_watch_refresh_git_files(&mut self) -> bool {
if !self.watch || self.jj_watch_target.is_some() || !self.multi_diff.is_git_mode() {
return false;
}
let now = Instant::now();
if now.duration_since(self.last_change_list_watch_check) < Duration::from_secs(1) {
return false;
}
self.last_change_list_watch_check = now;
let index_stamp = self.git_index_stamp();
let changed =
self.multi_diff.git_change_list_changed() || index_stamp != self.git_index_baseline;
if !changed {
return false;
}
let previous_signature = self
.multi_diff
.files
.iter()
.map(|file| (file.status, file.old_path.clone(), file.path.clone()))
.collect::<Vec<_>>();
self.refresh_all_files();
if !self.same_file_signature(&previous_signature)
&& self.file_panel_mode != super::FilePanelMode::Files
{
self.files_tab_unseen = true;
}
true
}
pub fn set_jj_watch_target(&mut self, repo_root: PathBuf, revision: String) {
let working_copy_stamp = crate::jj_working_copy_stamp(&repo_root);
self.jj_watch_target = Some(super::JjWatchTarget {
repo_root,
revision,
working_copy_stamp,
});
}
pub(crate) fn maybe_watch_refresh_jj_files(&mut self) -> bool {
if !self.watch || self.jj_watch_target.is_none() {
return false;
}
let now = Instant::now();
if now.duration_since(self.last_change_list_watch_check) < Duration::from_secs(1) {
return false;
}
self.last_change_list_watch_check = now;
let target = self.jj_watch_target.clone().unwrap();
let working_copy_stamp = crate::jj_working_copy_stamp(&target.repo_root);
if working_copy_stamp == target.working_copy_stamp {
return false;
}
if let Some((change_id, commit_id)) =
crate::jj_revision_ids(&target.repo_root, &target.revision)
{
self.update_jj_review_target_ids(change_id, commit_id);
}
let Ok(signature) = crate::jj_diff_file_signature(&target.repo_root, &target.revision)
else {
return false;
};
if self.same_file_signature(&signature) {
if let Some(target) = self.jj_watch_target.as_mut() {
target.working_copy_stamp = working_copy_stamp;
}
return false;
}
let changed_indices: Vec<usize> = signature
.iter()
.enumerate()
.filter_map(|(index, (status, old_path, path))| {
(!self.multi_diff.files.iter().any(|file| {
file.path == *path && file.old_path == *old_path && file.status == *status
}))
.then_some(index)
})
.collect();
let Ok(refreshed) = crate::build_jj_diff(&target.repo_root, &target.revision, None) else {
return false;
};
if let Some(target) = self.jj_watch_target.as_mut() {
target.working_copy_stamp = working_copy_stamp;
}
self.replace_multi_diff(refreshed);
if self.file_panel_mode != super::FilePanelMode::Files {
self.files_tab_unseen = true;
}
let flash_until = Instant::now() + WATCH_CHANGE_FLASH_DURATION;
for index in changed_indices {
if let Some(until) = self.file_recently_changed_until.get_mut(index) {
*until = Some(flash_until);
}
}
true
}
fn same_file_signature(
&self,
other: &[(oyo_core::git::FileStatus, Option<PathBuf>, PathBuf)],
) -> bool {
self.multi_diff.files.len() == other.len()
&& self
.multi_diff
.files
.iter()
.zip(other)
.all(|(left, (status, old_path, path))| {
left.path == *path && left.old_path == *old_path && left.status == *status
})
}
pub(crate) fn maybe_watch_refresh_changed_files(&mut self) -> bool {
if !self.watch || !self.files_changed_on_disk {
return false;
}
let changed: Vec<usize> = self
.file_disk_changed
.iter()
.enumerate()
.filter_map(|(idx, changed)| changed.then_some(idx))
.collect();
if changed.is_empty() {
return false;
}
let current = self.multi_diff.selected_index;
let flash_until = Instant::now() + WATCH_CHANGE_FLASH_DURATION;
for idx in changed {
if idx == current {
self.refresh_current_file();
} else {
self.multi_diff.refresh_file(idx);
self.mark_diff_changed();
if idx < self.syntax_caches.len() {
self.syntax_caches[idx] = None;
}
if idx < self.fold_scope_caches.len() {
self.fold_scope_caches[idx] = None;
}
self.refresh_file_disk_baseline_for(idx);
}
if let Some(until) = self.file_recently_changed_until.get_mut(idx) {
*until = Some(flash_until);
}
}
self.recompute_file_change_state();
self.repair_review_comments_after_diff_refresh();
true
}
pub fn refresh_current_file(&mut self) {
if self.start_remote_refresh() {
return;
}
if self.multi_diff.file_count() == 0 {
return;
}
let keep_full_context_tail = !self.stepping
&& self
.multi_diff
.current_file()
.is_some_and(|file| self.fold_context_fully_expanded_files.contains(&file.path))
&& {
let total = self.current_view_with_frame(AnimationFrame::Idle).len();
self.scroll_offset
>= super::utils::max_scroll(
total,
self.last_viewport_height.max(1),
self.allow_overscroll(),
)
};
let preserve_no_step_hunk = if !self.stepping {
let nav = self.multi_diff.current_navigator();
let state = nav.state();
if state.last_nav_was_hunk {
let cursor_rank = nav
.diff()
.hunks
.get(state.current_hunk)
.and_then(|hunk| {
state
.cursor_change
.and_then(|cursor| hunk.change_ids.iter().position(|id| *id == cursor))
})
.unwrap_or(0);
Some((state.current_hunk, cursor_rank))
} else {
None
}
} else {
None
};
self.multi_diff.refresh_current_file();
self.invalidate_blame_cache();
self.mark_diff_changed();
let idx = self.multi_diff.selected_index;
if self.queue_refreshed_current_file_diff() {
self.diff_refresh_restore_end = Some(idx);
} else if self.diff_refresh_restore_end == Some(idx) {
self.diff_refresh_restore_end = None;
}
{
let nav = self.multi_diff.current_navigator();
nav.goto_end();
if !self.stepping {
nav.clear_active_change();
}
}
if !self.stepping {
let restored_hunk_scope = if let Some((prev_hunk, prev_cursor_rank)) =
preserve_no_step_hunk
{
let nav = self.multi_diff.current_navigator();
let total_hunks = nav.state().total_hunks;
if total_hunks > 0 {
let hunk_idx = prev_hunk.min(total_hunks.saturating_sub(1));
let cursor_change = nav.diff().hunks.get(hunk_idx).and_then(|hunk| {
if hunk.change_ids.is_empty() {
None
} else {
let idx = prev_cursor_rank.min(hunk.change_ids.len().saturating_sub(1));
hunk.change_ids.get(idx).copied()
}
});
nav.set_cursor_hunk(hunk_idx, cursor_change);
nav.set_hunk_scope(true);
true
} else {
false
}
} else {
false
};
if !restored_hunk_scope {
self.set_cursor_for_current_scroll();
self.multi_diff.current_navigator().set_hunk_scope(false);
}
}
self.invalidate_fold_context_view();
if keep_full_context_tail {
self.scroll_offset = usize::MAX;
self.needs_scroll_to_active = false;
self.centered_once = false;
}
self.rebuild_current_syntax_cache_after_reload();
self.refresh_file_disk_baseline_for(idx);
self.recompute_file_change_state();
self.repair_review_comments_after_diff_refresh();
}
pub fn refresh_all_files(&mut self) {
if self.start_remote_refresh() {
return;
}
if let Some(target) = self.jj_watch_target.clone() {
let working_copy_stamp = crate::jj_working_copy_stamp(&target.repo_root);
if let Ok(refreshed) = crate::build_jj_diff(&target.repo_root, &target.revision, None) {
if let Some((change_id, commit_id)) =
crate::jj_revision_ids(&target.repo_root, &target.revision)
{
self.update_jj_review_target_ids(change_id, commit_id);
}
if let Some(target) = self.jj_watch_target.as_mut() {
target.working_copy_stamp = working_copy_stamp;
}
self.replace_multi_diff(refreshed);
}
} else if self.outdated_diff_view.is_some() {
self.clear_outdated_reconstruction_cache();
let view = self.outdated_diff_view.as_mut().unwrap();
view.cache_on_restore = false;
let repo_root = view.live_backup.repo_root().map(Path::to_path_buf);
let refreshed = view.live_backup.refresh_all_from_git();
if refreshed {
view.worker_state.valid = false;
if let Some(repo_root) = repo_root {
self.refresh_git_review_target_commits(&repo_root);
}
}
} else {
let repo_root = self.multi_diff.repo_root().map(Path::to_path_buf);
let old_paths: Vec<PathBuf> = self
.multi_diff
.files
.iter()
.map(|file| file.path.clone())
.collect();
if self.multi_diff.refresh_all_from_git() {
self.remap_file_indices_after_refresh(&old_paths);
if let Some(repo_root) = repo_root {
self.refresh_git_review_target_commits(&repo_root);
}
self.reset_after_file_list_refresh(true);
}
}
self.move_selection_off_hidden_reviewed_file();
}
pub(crate) fn replace_multi_diff(&mut self, mut refreshed: oyo_core::MultiFileDiff) {
self.clear_outdated_reconstruction_cache();
if let Some(view) = self.outdated_diff_view.as_mut() {
view.cache_on_restore = false;
let selected_path = view
.live_backup
.current_file()
.map(|file| file.path.clone());
if let Some(index) = selected_path
.and_then(|path| refreshed.files.iter().position(|file| file.path == path))
{
refreshed.select_file(index);
}
if let Some(request) = refreshed.take_pending_content_for(refreshed.selected_index) {
let content = super::load_content_request_sync(request);
refreshed.apply_prepared_content(refreshed.selected_index, content);
refreshed.ensure_full_navigator(refreshed.selected_index);
}
view.worker_state.valid = false;
view.live_backup = refreshed;
return;
}
let recent_changes: HashMap<PathBuf, Option<Instant>> = self
.multi_diff
.files
.iter()
.zip(&self.file_recently_changed_until)
.map(|(file, until)| (file.path.clone(), *until))
.collect();
let old_paths: Vec<PathBuf> = self
.multi_diff
.files
.iter()
.map(|file| file.path.clone())
.collect();
let selected_path = self.multi_diff.current_file().map(|file| file.path.clone());
let selected_index = selected_path
.and_then(|path| refreshed.files.iter().position(|file| file.path == path));
if let Some(index) = selected_index {
refreshed.select_file(index);
}
let scroll_offset = self.scroll_offset;
let horizontal_scroll = self.horizontal_scroll;
self.multi_diff = refreshed;
self.remap_file_indices_after_refresh(&old_paths);
if let Some(request) = self
.multi_diff
.take_pending_content_for_sync(self.multi_diff.selected_index)
{
let content = super::load_content_request_sync(request);
self.multi_diff
.apply_prepared_content(self.multi_diff.selected_index, content);
self.multi_diff
.ensure_full_navigator(self.multi_diff.selected_index);
}
self.reset_after_file_list_refresh(true);
self.start_content_loading();
for (index, file) in self.multi_diff.files.iter().enumerate() {
if let Some(until) = recent_changes.get(&file.path) {
self.file_recently_changed_until[index] = *until;
}
}
if let Some(index) = selected_index {
self.scroll_offset = scroll_offset;
self.horizontal_scroll = horizontal_scroll;
self.needs_scroll_to_active = false;
if self.stepping {
self.scroll_offsets_step[index] = scroll_offset;
self.horizontal_scrolls_step[index] = horizontal_scroll;
} else {
self.scroll_offsets_no_step[index] = scroll_offset;
self.horizontal_scrolls_no_step[index] = horizontal_scroll;
}
}
}
fn remap_file_indices_after_refresh(&mut self, old_paths: &[PathBuf]) {
let new_indices: HashMap<PathBuf, usize> = self
.multi_diff
.files
.iter()
.enumerate()
.map(|(index, file)| (file.path.clone(), index))
.collect();
let remap_index = |old_index: usize| {
old_paths
.get(old_index)
.and_then(|path| new_indices.get(path).copied())
};
self.topbar_tabs.retain_mut(|tab| match tab.content {
TopbarTabContent::File(old_index) => {
if let Some(index) = remap_index(old_index) {
tab.content = TopbarTabContent::File(index);
true
} else {
false
}
}
_ => true,
});
let old_cursor = self.view_history_cursor;
let mut cursor_map = vec![None; self.view_history.len()];
let mut history = Vec::with_capacity(self.view_history.len());
for (old_index, recipe) in self.view_history.iter().copied().enumerate() {
let recipe = match recipe {
ViewHistoryRecipe::File {
tab_id,
file_index,
scroll_offset,
} => {
let Some(file_index) = remap_index(file_index) else {
continue;
};
ViewHistoryRecipe::File {
tab_id,
file_index,
scroll_offset,
}
}
other => other,
};
cursor_map[old_index] = Some(history.len());
history.push(recipe);
}
self.view_history_cursor = cursor_map
.get(old_cursor)
.copied()
.flatten()
.or_else(|| {
cursor_map
.iter()
.take(old_cursor)
.rev()
.find_map(|index| *index)
})
.or_else(|| cursor_map.iter().find_map(|index| *index))
.unwrap_or(0);
self.view_history = history;
self.ensure_topbar_tabs();
}
pub(crate) fn reset_after_file_list_refresh(&mut self, repair_comments: bool) {
if repair_comments {
self.clear_outdated_reconstruction_cache();
}
self.invalidate_blame_cache();
self.mark_diff_changed();
self.diff_worker_tx = None;
self.diff_worker_rx = None;
self.content_generation = self.content_generation.wrapping_add(1);
self.content_worker_rx = None;
self.content_loading.clear();
self.diff_inflight = None;
self.diff_queue.clear();
self.diff_refresh_restore_end = None;
let file_count = self.multi_diff.file_count();
self.scroll_offsets_step = vec![0; file_count];
self.scroll_offsets_no_step = vec![0; file_count];
self.horizontal_scrolls_step = vec![0; file_count];
self.horizontal_scrolls_no_step = vec![0; file_count];
self.max_line_widths_step = vec![0; file_count];
self.max_line_widths_no_step = vec![0; file_count];
self.no_step_visited = vec![false; file_count];
self.files_visited = vec![false; file_count];
self.syntax_caches = vec![None; file_count];
self.fold_scope_caches = vec![None; file_count];
self.step_state_snapshots = vec![None; file_count];
self.no_step_state_snapshots = vec![None; file_count];
self.scroll_offset = 0;
self.horizontal_scroll = 0;
self.needs_scroll_to_active = true;
self.centered_once = false;
self.handle_file_enter();
self.invalidate_fold_context_view();
self.rebuild_current_syntax_cache_after_reload();
self.rebuild_file_disk_baseline();
self.git_index_baseline = self.git_index_stamp();
self.files_changed_on_disk = false;
self.invalidate_review_repo_file_cache();
if repair_comments {
self.repair_review_comments_after_diff_refresh();
}
}
#[allow(dead_code)]
pub fn total_lines(&mut self) -> usize {
let frame = self.animation_frame();
self.current_view_with_frame(frame).len()
}
pub fn stats(&mut self) -> (usize, usize) {
if self.multi_diff.file_count() == 0 || self.current_file_is_binary() {
return (0, 0);
}
if self.multi_diff.current_file_diff_status() != oyo_core::multi::DiffStatus::Ready {
return self
.multi_diff
.current_file()
.map(|file| (file.insertions, file.deletions))
.unwrap_or((0, 0));
}
let diff = self.multi_diff.current_navigator().diff();
(diff.insertions, diff.deletions)
}
pub fn current_file_is_binary(&self) -> bool {
self.multi_diff.current_file_is_binary()
}
pub(crate) fn current_file_is_image(&self) -> bool {
self.multi_diff
.current_file()
.is_some_and(|file| is_image_name(&file.display_name))
}
}
#[cfg(test)]
mod link_tests {
use super::is_openable_url;
use crate::app::{App, TopbarTabContent, ViewHistoryRecipe, ViewMode};
use oyo_core::MultiFileDiff;
use std::path::PathBuf;
#[test]
fn only_web_and_mailto_urls_open() {
assert!(is_openable_url("https://example.com"));
assert!(is_openable_url("http://example.com/path?q=1"));
assert!(is_openable_url("HTTPS://EXAMPLE.COM"));
assert!(is_openable_url("mailto:me@victorare.mu"));
assert!(!is_openable_url("file:///etc/passwd"));
assert!(!is_openable_url("javascript:alert(1)"));
assert!(!is_openable_url("./assets/logo.png"));
assert!(!is_openable_url("ftp://example.com"));
assert!(!is_openable_url("résumé.md"));
assert!(!is_openable_url(""));
assert!(!is_openable_url("https://"));
}
#[test]
fn refreshed_file_order_remaps_tabs_and_view_history_by_path() {
let mut app = App::new(
MultiFileDiff::from_file_pairs(vec![
(PathBuf::from("b.txt"), "old\n".into(), "b\n".into()),
(PathBuf::from("c.txt"), "old\n".into(), "c\n".into()),
]),
ViewMode::UnifiedPane,
0,
false,
None,
);
app.topbar_tabs[0].content = TopbarTabContent::File(1);
let second_tab_id = app.next_topbar_tab_id;
app.next_topbar_tab_id += 1;
let mut second_tab = app.topbar_tabs[0].clone();
second_tab.id = second_tab_id;
second_tab.content = TopbarTabContent::File(0);
app.topbar_tabs.push(second_tab);
app.active_topbar_tab = Some(second_tab_id);
app.view_history = vec![
ViewHistoryRecipe::File {
tab_id: Some(1),
file_index: 1,
scroll_offset: 0,
},
ViewHistoryRecipe::File {
tab_id: Some(second_tab_id),
file_index: 0,
scroll_offset: 0,
},
];
app.view_history_cursor = 1;
app.replace_multi_diff(MultiFileDiff::from_file_pairs(vec![
(PathBuf::from("a.txt"), "old\n".into(), "a\n".into()),
(PathBuf::from("b.txt"), "old\n".into(), "b\n".into()),
(PathBuf::from("c.txt"), "old\n".into(), "c\n".into()),
]));
assert_eq!(app.current_file_path(), "b.txt");
assert_eq!(app.multi_diff.selected_index, 1);
assert_eq!(app.topbar_tabs[0].content, TopbarTabContent::File(2));
assert_eq!(app.topbar_tabs[1].content, TopbarTabContent::File(1));
assert_eq!(app.active_topbar_tab, Some(second_tab_id));
assert_eq!(
app.view_history,
vec![
ViewHistoryRecipe::File {
tab_id: Some(1),
file_index: 2,
scroll_offset: 0,
},
ViewHistoryRecipe::File {
tab_id: Some(second_tab_id),
file_index: 1,
scroll_offset: 0,
},
]
);
assert_eq!(app.view_history_cursor, 1);
}
}