Skip to content

Commit 14d87e2

Browse files
committed
Preserve scroll position when toggling markdown Rendered/Raw views
Toggling a markdown file between Rendered and Raw replaces the entire pane (FileNotebookView <-> CodeView), so the new pane always started at the top of the document. Capture the outgoing pane's vertical scroll as a fraction of its scrollable range, carry it on the pane-replacement events, and re-apply it after the new pane's content is laid out. A fraction is used instead of a char-offset snapshot because the two modes render different documents. Application is deferred through the render model's element-update cycle (version- and viewport-gated), since a freshly created pane has no laid-out content yet and applying immediately would clamp the scroll to the top. Fixes the scroll-preservation half of #7897; search/replace is out of scope.
1 parent 69ce372 commit 14d87e2

14 files changed

Lines changed: 267 additions & 12 deletions

File tree

app/src/code/editor/scroll.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ impl ScrollWheelBehavior {
2727
pub enum ScrollPosition {
2828
LineAndColumn(LineAndColumnArg),
2929
FocusedDiffHunk,
30+
/// Scroll to a fraction of the scrollable range, in `0..=1`. Used to restore scroll position
31+
/// across a markdown raw<->rendered toggle, where the documents differ and a line/column can't
32+
/// map between them.
33+
Fraction(f32),
3034
}
3135

3236
/// We don't want to scroll to the provided line number until the content has

app/src/code/editor/view.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1319,6 +1319,9 @@ impl CodeEditorView {
13191319
ScrollPosition::FocusedDiffHunk => {
13201320
self.navigate_current_diff_hunk(ctx);
13211321
}
1322+
ScrollPosition::Fraction(fraction) => {
1323+
self.scroll_to_fraction(fraction, ctx);
1324+
}
13221325
}
13231326
}
13241327
ctx.emit(CodeEditorEvent::ViewportUpdated);
@@ -1600,6 +1603,23 @@ impl CodeEditorView {
16001603
self.model.as_ref(ctx).render_state().as_ref(ctx).height()
16011604
}
16021605

1606+
/// The current vertical scroll position as a fraction of the scrollable range, in `0..=1`.
1607+
pub fn scroll_fraction(&self, ctx: &AppContext) -> f32 {
1608+
self.model
1609+
.as_ref(ctx)
1610+
.render_state()
1611+
.as_ref(ctx)
1612+
.scroll_fraction()
1613+
}
1614+
1615+
fn scroll_to_fraction(&self, fraction: f32, ctx: &mut ViewContext<Self>) {
1616+
self.model.update(ctx, |model, ctx| {
1617+
model.render_state().update(ctx, |render_state, _ctx| {
1618+
render_state.scroll_to_fraction(fraction);
1619+
});
1620+
});
1621+
}
1622+
16031623
pub fn interaction_state(&self, ctx: &AppContext) -> InteractionState {
16041624
self.model.as_ref(ctx).interaction_state()
16051625
}

app/src/code/local_code_editor.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1918,6 +1918,11 @@ impl LocalCodeEditorView {
19181918
&self.editor
19191919
}
19201920

1921+
/// The current vertical scroll position as a fraction of the scrollable range, in `0..=1`.
1922+
pub fn scroll_fraction(&self, ctx: &AppContext) -> f32 {
1923+
self.editor.as_ref(ctx).scroll_fraction(ctx)
1924+
}
1925+
19211926
/// Accept the diff that is currently in the editor. For local files, this can only be called after the file contents
19221927
/// have been loaded into the editor.
19231928
/// If it is a local file, the diff content will be retrieved and the pending diff will be marked as completed.

app/src/code/view.rs

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -265,11 +265,18 @@ impl CodeView {
265265
pub fn new(
266266
source: CodeSource,
267267
line_col: Option<LineAndColumnArg>,
268+
scroll_fraction: Option<f32>,
268269
ctx: &mut ViewContext<Self>,
269270
) -> Self {
270271
let location = source.location();
271272
let mut view = Self::new_internal(source, ctx);
272273
view.open_or_focus_existing(location, line_col, ctx);
274+
// Restore scroll captured before a markdown rendered->raw toggle, overriding the default
275+
// top-of-file scroll. The rendered and raw documents differ, so a fraction is used rather
276+
// than a line/column.
277+
if let Some(fraction) = scroll_fraction {
278+
view.set_pending_scroll_fraction(fraction, ctx);
279+
}
273280
#[cfg(feature = "local_fs")]
274281
{
275282
view.update_markdown_mode_segmented_control(ctx);
@@ -781,6 +788,22 @@ impl CodeView {
781788
});
782789
}
783790

791+
fn set_pending_scroll_fraction(&self, fraction: f32, ctx: &mut ViewContext<Self>) {
792+
let Some(tab) = self.tab_group.get(self.active_tab_index) else {
793+
return;
794+
};
795+
tab.editor_view.update(ctx, |editor, ctx| {
796+
editor.set_pending_scroll(ScrollPosition::Fraction(fraction), ctx);
797+
});
798+
}
799+
800+
/// The current vertical scroll fraction of the active tab's editor, in `0..=1`.
801+
#[cfg(feature = "local_fs")]
802+
fn scroll_fraction(&self, ctx: &AppContext) -> Option<f32> {
803+
let tab = self.tab_group.get(self.active_tab_index)?;
804+
Some(tab.editor_view.as_ref(ctx).scroll_fraction(ctx))
805+
}
806+
784807
fn open_new_tab(
785808
&mut self,
786809
location: Option<LocalOrRemotePath>,
@@ -1296,7 +1319,7 @@ impl CodeView {
12961319
range_end: None,
12971320
};
12981321
self.remove_tab_data_index(index, ctx);
1299-
CodePane::new(source, None, ctx)
1322+
CodePane::new(source, None, None, ctx)
13001323
})
13011324
}
13021325

@@ -2345,6 +2368,8 @@ impl TypedActionView for CodeView {
23452368

23462369
if let Some(lor_path) = lor_path {
23472370
let source = self.source.clone();
2371+
let scroll_fraction =
2372+
self.scroll_fraction(ctx).map(ordered_float::OrderedFloat);
23482373
if self.active_tab_has_unsaved_changes(ctx) {
23492374
self.save_local(
23502375
self.active_tab_index,
@@ -2353,6 +2378,7 @@ impl TypedActionView for CodeView {
23532378
ctx.emit(CodeViewEvent::Pane(PaneEvent::ReplaceWithFilePane {
23542379
path: lor_path.clone(),
23552380
source: Some(source.clone()),
2381+
scroll_fraction,
23562382
}));
23572383
}
23582384
})),
@@ -2362,6 +2388,7 @@ impl TypedActionView for CodeView {
23622388
ctx.emit(CodeViewEvent::Pane(PaneEvent::ReplaceWithFilePane {
23632389
path: lor_path,
23642390
source: Some(source),
2391+
scroll_fraction,
23652392
}));
23662393
}
23672394
}

app/src/notebooks/file/mod.rs

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,9 @@ pub struct FileNotebookView {
103103
code_source: Option<CodeSource>,
104104
/// Persistent hover state for the header title tooltip.
105105
header_title_mouse_state: MouseStateHandle,
106+
/// Vertical scroll fraction (`0..=1`) to restore once the file content is first loaded,
107+
/// captured before a markdown raw->rendered toggle. Consumed on the first `set_content`.
108+
pending_scroll_fraction: Option<f32>,
106109
}
107110

108111
#[derive(Debug, Clone)]
@@ -304,6 +307,7 @@ impl FileNotebookView {
304307
#[cfg(feature = "local_fs")]
305308
code_source: None,
306309
header_title_mouse_state: Default::default(),
310+
pending_scroll_fraction: None,
307311
}
308312
}
309313

@@ -314,6 +318,26 @@ impl FileNotebookView {
314318
self.code_source = source;
315319
}
316320

321+
/// Set the scroll fraction to restore once the file content is first loaded. Used to preserve
322+
/// scroll position when toggling markdown from raw to rendered.
323+
pub fn set_pending_scroll_fraction(&mut self, scroll_fraction: Option<f32>) {
324+
self.pending_scroll_fraction = scroll_fraction;
325+
}
326+
327+
/// The current vertical scroll fraction of the rendered editor, in `0..=1`.
328+
#[cfg(feature = "local_fs")]
329+
fn scroll_fraction(&self, ctx: &AppContext) -> Option<f32> {
330+
Some(
331+
self.editor
332+
.as_ref(ctx)
333+
.model()
334+
.as_ref(ctx)
335+
.render_state()
336+
.as_ref(ctx)
337+
.scroll_fraction(),
338+
)
339+
}
340+
317341
pub fn title(&self) -> String {
318342
// Prefer the location name that's been resolved against a Session, but if that's not
319343
// available yet, fall back to the raw file path.
@@ -339,15 +363,23 @@ impl FileNotebookView {
339363
let doc_path = self.file_state.local_path().map(|p| p.to_path_buf());
340364
let render_as_ipynb =
341365
FeatureFlag::JupyterNotebookRendering.is_enabled() && self.is_jupyter_notebook_file();
366+
let scroll_fraction = self.pending_scroll_fraction.take();
342367
self.editor.update(ctx, |editor, ctx| {
343368
if render_as_ipynb {
344369
editor.reset_with_ipynb(content, ctx);
345370
} else {
346371
editor.reset_with_markdown(content, ctx);
347372
}
348-
// Set the document path for resolving relative image paths
349373
editor.model().update(ctx, |model, ctx| {
374+
// Set the document path for resolving relative image paths
350375
model.set_document_path(doc_path, ctx);
376+
// Restore scroll captured before a raw->rendered toggle. Deferred through the
377+
// layout pipeline so it applies after the new content is laid out.
378+
if let Some(fraction) = scroll_fraction {
379+
model.render_state().update(ctx, |render_state, _ctx| {
380+
render_state.scroll_to_fraction(fraction);
381+
});
382+
}
351383
});
352384
});
353385
}
@@ -687,10 +719,12 @@ impl FileNotebookView {
687719
#[cfg(feature = "local_fs")]
688720
fn open_as_code(&mut self, ctx: &mut ViewContext<Self>) {
689721
if let Some(path) = self.file_state.path().cloned() {
722+
let scroll_fraction = self.scroll_fraction(ctx).map(ordered_float::OrderedFloat);
690723
// Emit an event to the pane group to handle the replacement
691724
ctx.emit(FileNotebookEvent::Pane(PaneEvent::ReplaceWithCodePane {
692725
path,
693726
source: self.code_source.clone(),
727+
scroll_fraction,
694728
}));
695729
}
696730
}
@@ -1067,9 +1101,12 @@ impl TypedActionView for FileNotebookView {
10671101
});
10681102
} else if let Some(path) = self.file_state.path().cloned() {
10691103
// For remote files, open as a code editor pane.
1104+
let scroll_fraction =
1105+
self.scroll_fraction(ctx).map(ordered_float::OrderedFloat);
10701106
ctx.emit(FileNotebookEvent::Pane(PaneEvent::ReplaceWithCodePane {
10711107
path,
10721108
source: None,
1109+
scroll_fraction,
10731110
}));
10741111
}
10751112
}
@@ -1099,9 +1136,12 @@ impl TypedActionView for FileNotebookView {
10991136
#[cfg(feature = "local_fs")]
11001137
{
11011138
if let Some(path) = self.file_state.path().cloned() {
1139+
let scroll_fraction =
1140+
self.scroll_fraction(ctx).map(ordered_float::OrderedFloat);
11021141
ctx.emit(FileNotebookEvent::Pane(PaneEvent::ReplaceWithCodePane {
11031142
path,
11041143
source: self.code_source.clone(),
1144+
scroll_fraction,
11051145
}));
11061146
}
11071147
}

app/src/pane_group/mod.rs

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1755,6 +1755,7 @@ impl PaneGroup {
17551755
None,
17561756
#[cfg(feature = "local_fs")]
17571757
None,
1758+
None,
17581759
ctx,
17591760
)),
17601761
};
@@ -5004,6 +5005,7 @@ impl PaneGroup {
50045005
file_pane_id: PaneId,
50055006
path: LocalOrRemotePath,
50065007
source: Option<crate::code::editor_management::CodeSource>,
5008+
scroll_fraction: Option<f32>,
50075009
ctx: &mut ViewContext<Self>,
50085010
) {
50095011
use crate::code::editor_management::CodeSource;
@@ -5012,7 +5014,7 @@ impl PaneGroup {
50125014
// Use the provided source if available, or construct from the path.
50135015
let source = source.unwrap_or(CodeSource::FileTree { location: path });
50145016

5015-
let code_pane = CodePane::new(source, None, ctx);
5017+
let code_pane = CodePane::new(source, None, scroll_fraction, ctx);
50165018
let success = self.replace_pane(file_pane_id, code_pane, false, ctx);
50175019

50185020
if !success {
@@ -5029,6 +5031,7 @@ impl PaneGroup {
50295031
code_pane_id: PaneId,
50305032
path: LocalOrRemotePath,
50315033
source: Option<crate::code::editor_management::CodeSource>,
5034+
scroll_fraction: Option<f32>,
50325035
ctx: &mut ViewContext<Self>,
50335036
) {
50345037
// Get the active session to pass to the FilePane, if any
@@ -5044,7 +5047,7 @@ impl PaneGroup {
50445047
}
50455048
});
50465049

5047-
let file_pane = FilePane::new(Some(path), session, source, ctx);
5050+
let file_pane = FilePane::new(Some(path), session, source, scroll_fraction, ctx);
50485051
let success = self.replace_pane(code_pane_id, file_pane, false, ctx);
50495052

50505053
if !success {
@@ -5099,12 +5102,32 @@ impl PaneGroup {
50995102
}
51005103
PaneEvent::ClearHoveredTabIndex => ctx.emit(Event::ClearHoveredTabIndex),
51015104
#[cfg(feature = "local_fs")]
5102-
PaneEvent::ReplaceWithCodePane { path, source } => {
5103-
self.replace_file_pane_with_code_pane(pane_id, path.clone(), source.clone(), ctx);
5105+
PaneEvent::ReplaceWithCodePane {
5106+
path,
5107+
source,
5108+
scroll_fraction,
5109+
} => {
5110+
self.replace_file_pane_with_code_pane(
5111+
pane_id,
5112+
path.clone(),
5113+
source.clone(),
5114+
(*scroll_fraction).map(|f| f.into_inner()),
5115+
ctx,
5116+
);
51045117
}
51055118
#[cfg(feature = "local_fs")]
5106-
PaneEvent::ReplaceWithFilePane { path, source } => {
5107-
self.replace_code_pane_with_file_pane(pane_id, path.clone(), source.clone(), ctx);
5119+
PaneEvent::ReplaceWithFilePane {
5120+
path,
5121+
source,
5122+
scroll_fraction,
5123+
} => {
5124+
self.replace_code_pane_with_file_pane(
5125+
pane_id,
5126+
path.clone(),
5127+
source.clone(),
5128+
(*scroll_fraction).map(|f| f.into_inner()),
5129+
ctx,
5130+
);
51085131
}
51095132
PaneEvent::RepoChanged => {
51105133
ctx.emit(Event::RepoChanged);

app/src/pane_group/pane/code_pane.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,12 @@ impl CodePane {
3333
pub fn new<V: View>(
3434
source: CodeSource,
3535
line_col: Option<LineAndColumnArg>,
36+
scroll_fraction: Option<f32>,
3637
ctx: &mut ViewContext<V>,
3738
) -> Self {
38-
let view = ctx.add_typed_action_view(move |ctx| CodeView::new(source, line_col, ctx));
39+
let view = ctx.add_typed_action_view(move |ctx| {
40+
CodeView::new(source, line_col, scroll_fraction, ctx)
41+
});
3942
Self::from_view(view, ctx)
4043
}
4144

app/src/pane_group/pane/file_pane.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,14 @@ impl FilePane {
4444
path: Option<LocalOrRemotePath>,
4545
target_session: Option<Arc<Session>>,
4646
#[cfg(feature = "local_fs")] code_source: Option<CodeSource>,
47+
scroll_fraction: Option<f32>,
4748
ctx: &mut ViewContext<V>,
4849
) -> Self {
4950
let view = ctx.add_typed_action_view(move |ctx| {
5051
let mut view = FileNotebookView::new(ctx);
5152
#[cfg(feature = "local_fs")]
5253
view.set_code_source(code_source);
54+
view.set_pending_scroll_fraction(scroll_fraction);
5355

5456
if let Some(path) = path {
5557
view.open(path, target_session, ctx);

app/src/pane_group/pane/mod.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1128,10 +1128,18 @@ pub enum PaneEvent {
11281128
ReplaceWithCodePane {
11291129
path: LocalOrRemotePath,
11301130
source: Option<crate::code::editor_management::CodeSource>,
1131+
/// Vertical scroll fraction (`0..=1`) captured from the outgoing pane, to restore on the
1132+
/// new pane. `None` scrolls to the top. Wrapped in `OrderedFloat` so `PaneEvent` can
1133+
/// still derive `Eq`.
1134+
scroll_fraction: Option<ordered_float::OrderedFloat<f32>>,
11311135
},
11321136
#[cfg(feature = "local_fs")]
11331137
ReplaceWithFilePane {
11341138
path: LocalOrRemotePath,
11351139
source: Option<crate::code::editor_management::CodeSource>,
1140+
/// Vertical scroll fraction (`0..=1`) captured from the outgoing pane, to restore on the
1141+
/// new pane. `None` scrolls to the top. Wrapped in `OrderedFloat` so `PaneEvent` can
1142+
/// still derive `Eq`.
1143+
scroll_fraction: Option<ordered_float::OrderedFloat<f32>>,
11361144
},
11371145
}

app/src/workspace/home.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ pub fn create_home_pane(ctx: &mut ViewContext<Workspace>) -> Box<dyn AnyPaneCont
2525
None,
2626
#[cfg(feature = "local_fs")]
2727
None,
28+
None,
2829
ctx,
2930
);
3031
pane.file_view(ctx).update(ctx, |pane, ctx| {

0 commit comments

Comments
 (0)