Files
galaxy/app/src/code_review/scroll_preservation.rs
T

213 lines
8.0 KiB
Rust

use galaxy_core::features::FeatureFlag;
use galaxyui::{elements::ScrollOffset, units::Pixels, ViewContext, ViewHandle};
#[cfg(not(target_family = "wasm"))]
use galaxyui::{AppContext, WeakViewHandle};
#[cfg(not(target_family = "wasm"))]
use super::FILE_HEADER_HEIGHT;
use super::{CodeReviewView, CodeReviewViewState};
use crate::code::editor::model::StableEditorLine;
use crate::code::local_code_editor::LocalCodeEditorView;
/// Context for preserving scroll position across file diff content changes.
/// The scroll position can be in different regions of the file diff item.
#[derive(Clone, Debug)]
#[cfg_attr(target_family = "wasm", allow(dead_code))]
pub(super) enum RelocatableScrollContext {
/// Scroll position is in the file header region.
/// Stores the pixel offset from the top of the header.
Header { offset: Pixels },
/// Scroll is on a line in the editor content (current or removed).
/// The [`StableEditorLine`] contains an internal anchor that
/// automatically tracks through buffer edits.
EditorLine {
stable_line: StableEditorLine,
intra_line_offset: Pixels,
},
/// Scroll position is in the footer region.
/// Stores the pixel offset from the top of the footer.
Footer { offset: Pixels },
}
impl CodeReviewView {
/// Computes the adjusted item-relative scroll offset for a file diff item
/// based on the captured scroll context. Called by the `ListState`
/// adjustment closure when an item's height changes during layout.
#[cfg(not(target_family = "wasm"))]
pub(super) fn adjust_scroll_offset(
view_handle: &WeakViewHandle<Self>,
index: usize,
captured_context: &RelocatableScrollContext,
app: &AppContext,
) -> Option<Pixels> {
if !FeatureFlag::CodeReviewScrollPreservation.is_enabled() {
return None;
}
// The adjustment function returns item-relative offsets (offset_from_start),
// NOT absolute positions. The ListState stores the result directly in
// scroll_top.offset_from_start, which is relative to the current scroll item.
match captured_context {
RelocatableScrollContext::Header { offset } => Some(*offset),
RelocatableScrollContext::EditorLine {
stable_line,
intra_line_offset,
} => {
let view_handle = view_handle.upgrade(app)?;
let view = view_handle.as_ref(app);
let CodeReviewViewState::Loaded(state) = view.state() else {
return None;
};
let editor_state = state
.file_states
.get_index(index)?
.1
.editor_state
.as_ref()?;
let editor_view = editor_state.editor.as_ref(app).editor().as_ref(app);
let line_offset = editor_view.line_top(stable_line, app)?;
Some(Pixels::new(FILE_HEADER_HEIGHT) + line_offset + *intra_line_offset)
}
RelocatableScrollContext::Footer { offset } => {
let view_handle = view_handle.upgrade(app)?;
let view = view_handle.as_ref(app);
let CodeReviewViewState::Loaded(state) = view.state() else {
return None;
};
let editor_state = state
.file_states
.get_index(index)?
.1
.editor_state
.as_ref()?;
let content_height = editor_state
.editor
.as_ref(app)
.editor()
.as_ref(app)
.content_height(app);
Some(Pixels::new(FILE_HEADER_HEIGHT) + content_height + *offset)
}
}
}
/// Computes the scroll preservation context for the given index and editor.
/// Returns `Some(context)` only if the index is the currently scrolled item.
/// Detects whether scroll is in header, editor content, or footer region.
#[cfg(not(target_family = "wasm"))]
pub(super) fn compute_scroll_context_for_index(
&self,
index: usize,
editor: &ViewHandle<LocalCodeEditorView>,
ctx: &mut ViewContext<Self>,
) -> Option<RelocatableScrollContext> {
// Only compute context if this is the currently scrolled item
let current_scroll_index = self.viewported_list_state.get_scroll_index();
if current_scroll_index != index {
return None;
}
// Get the scroll offset within this item
let scroll_offset_in_item = self.viewported_list_state.get_scroll_offset();
let file_header_height = Pixels::new(FILE_HEADER_HEIGHT);
// Check if scroll is in the header region
if scroll_offset_in_item < file_header_height {
return Some(RelocatableScrollContext::Header {
offset: scroll_offset_in_item,
});
}
// Compute offset relative to editor content
let scroll_offset_in_editor = scroll_offset_in_item - file_header_height;
// Check footer region using the view-level accessor.
let editor_view = editor.as_ref(ctx).editor();
let content_height = editor_view.as_ref(ctx).content_height(ctx);
if scroll_offset_in_editor >= content_height {
return Some(RelocatableScrollContext::Footer {
offset: scroll_offset_in_editor - content_height,
});
}
// Clone the model handle so immutable borrows on ctx can be released
// before the anchor creation which requires a mutable borrow.
let editor_model_handle = editor_view.as_ref(ctx).model.clone();
// Identify the line and create anchors (requires mutable context).
let (stable_line, intra_line_offset) = editor_model_handle.update(ctx, |model, ctx| {
model.line_at_vertical_offset(scroll_offset_in_editor, ctx)
})?;
Some(RelocatableScrollContext::EditorLine {
stable_line,
intra_line_offset,
})
}
/// Wasm stub - scroll preservation not supported
#[cfg(target_family = "wasm")]
pub(super) fn compute_scroll_context_for_index(
&self,
_index: usize,
_editor: &ViewHandle<LocalCodeEditorView>,
_ctx: &mut ViewContext<Self>,
) -> Option<RelocatableScrollContext> {
None
}
/// Called when scrolling settles (via debounced scroll events).
/// Computes and stores the current scroll context on the ListState
/// so the explicit invalidation path can adjust scroll position
/// when a file diff item's height changes.
fn on_scroll_settled(&mut self, _scroll_offset: ScrollOffset, ctx: &mut ViewContext<Self>) {
let scroll_index = self.viewported_list_state.get_scroll_index();
let Some(repo) = self.active_repo.as_ref() else {
return;
};
let CodeReviewViewState::Loaded(state) = &repo.state else {
return;
};
let Some((_, file_state)) = state.file_states.get_index(scroll_index) else {
return;
};
let Some(editor_state) = &file_state.editor_state else {
return;
};
let editor = editor_state.editor();
if let Some(context) = self.compute_scroll_context_for_index(scroll_index, editor, ctx) {
self.viewported_list_state.set_scroll_context(Some(context));
}
}
/// Sets up scroll tracking from a scroll event receiver.
/// Calls [`Self::on_scroll_settled`] on every scroll event so the
/// scroll context is always up-to-date.
/// No-op when [`FeatureFlag::CodeReviewScrollPreservation`] is disabled.
pub(super) fn setup_scroll_tracking(
scroll_rx: async_channel::Receiver<ScrollOffset>,
ctx: &mut ViewContext<Self>,
) {
if FeatureFlag::CodeReviewScrollPreservation.is_enabled() {
ctx.spawn_stream_local(scroll_rx, Self::on_scroll_settled, |_, _| {});
}
}
}