@@ -34,7 +34,8 @@ use terminal_view::terminal_panel::TerminalPanel;
use tests::{active_debug_session_panel, init_test, init_test_workspace};
use util::{path, rel_path::rel_path};
use workspace::item::SaveOptions;
-use workspace::{Item, dock::Panel};
+use workspace::pane_group::SplitDirection;
+use workspace::{Item, dock::Panel, move_active_item};
#[gpui::test]
async fn test_basic_show_debug_panel(executor: BackgroundExecutor, cx: &mut TestAppContext) {
@@ -1813,6 +1814,536 @@ async fn test_debug_adapters_shutdown_on_app_quit(
);
}
+#[gpui::test]
+async fn test_breakpoint_jumps_only_in_proper_split_view(
+ executor: BackgroundExecutor,
+ cx: &mut TestAppContext,
+) {
+ init_test(cx);
+
+ let fs = FakeFs::new(executor.clone());
+
+ fs.insert_tree(
+ path!("/project"),
+ json!({
+ "main.rs": "First line\nSecond line\nThird line\nFourth line",
+ "second.rs": "First line\nSecond line\nThird line\nFourth line",
+ }),
+ )
+ .await;
+
+ let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
+ let workspace = init_test_workspace(&project, cx).await;
+ let cx = &mut VisualTestContext::from_window(*workspace, cx);
+
+ let project_path = Path::new(path!("/project"));
+ let worktree = project
+ .update(cx, |project, cx| project.find_worktree(project_path, cx))
+ .expect("This worktree should exist in project")
+ .0;
+
+ let worktree_id = workspace
+ .update(cx, |_, _, cx| worktree.read(cx).id())
+ .unwrap();
+
+ // Open main.rs in pane A (the initial pane)
+ let pane_a = workspace
+ .update(cx, |multi, _window, cx| {
+ multi.workspace().read(cx).active_pane().clone()
+ })
+ .unwrap();
+
+ let open_main = workspace
+ .update(cx, |multi, window, cx| {
+ multi.workspace().update(cx, |workspace, cx| {
+ workspace.open_path((worktree_id, rel_path("main.rs")), None, true, window, cx)
+ })
+ })
+ .unwrap();
+ open_main.await.unwrap();
+
+ cx.run_until_parked();
+
+ // Split pane A to the right, creating pane B
+ let pane_b = workspace
+ .update(cx, |multi, window, cx| {
+ multi.workspace().update(cx, |workspace, cx| {
+ workspace.split_pane(pane_a.clone(), SplitDirection::Right, window, cx)
+ })
+ })
+ .unwrap();
+
+ cx.run_until_parked();
+
+ // Open main.rs in pane B
+ let weak_pane_b = pane_b.downgrade();
+ let open_main_in_b = workspace
+ .update(cx, |multi, window, cx| {
+ multi.workspace().update(cx, |workspace, cx| {
+ workspace.open_path(
+ (worktree_id, rel_path("main.rs")),
+ Some(weak_pane_b),
+ true,
+ window,
+ cx,
+ )
+ })
+ })
+ .unwrap();
+ open_main_in_b.await.unwrap();
+
+ cx.run_until_parked();
+
+ // Also open second.rs in pane B as an inactive tab
+ let weak_pane_b = pane_b.downgrade();
+ let open_second_in_b = workspace
+ .update(cx, |multi, window, cx| {
+ multi.workspace().update(cx, |workspace, cx| {
+ workspace.open_path(
+ (worktree_id, rel_path("second.rs")),
+ Some(weak_pane_b),
+ true,
+ window,
+ cx,
+ )
+ })
+ })
+ .unwrap();
+ open_second_in_b.await.unwrap();
+
+ cx.run_until_parked();
+
+ // Switch pane B back to main.rs so second.rs is inactive there
+ let weak_pane_b = pane_b.downgrade();
+ let reactivate_main_in_b = workspace
+ .update(cx, |multi, window, cx| {
+ multi.workspace().update(cx, |workspace, cx| {
+ workspace.open_path(
+ (worktree_id, rel_path("main.rs")),
+ Some(weak_pane_b),
+ true,
+ window,
+ cx,
+ )
+ })
+ })
+ .unwrap();
+ reactivate_main_in_b.await.unwrap();
+
+ cx.run_until_parked();
+
+ // Now open second.rs in pane A, making main.rs an inactive tab there
+ let weak_pane_a = pane_a.downgrade();
+ let open_second = workspace
+ .update(cx, |multi, window, cx| {
+ multi.workspace().update(cx, |workspace, cx| {
+ workspace.open_path(
+ (worktree_id, rel_path("second.rs")),
+ Some(weak_pane_a),
+ true,
+ window,
+ cx,
+ )
+ })
+ })
+ .unwrap();
+ open_second.await.unwrap();
+
+ cx.run_until_parked();
+
+ // Layout:
+ // Pane A: second.rs (active), main.rs (inactive tab)
+ // Pane B: main.rs (active), second.rs (inactive tab)
+
+ // Verify pane A's active item is second.rs (main.rs is an inactive tab)
+ workspace
+ .read_with(cx, |_multi, cx| {
+ let active = pane_a.read(cx).active_item().unwrap();
+ let editor = active.to_any_view().downcast::<Editor>().unwrap();
+ let path = editor.read(cx).project_path(cx).unwrap();
+ assert_eq!(
+ path.path.file_name().unwrap(),
+ "second.rs",
+ "Pane A should have second.rs active",
+ );
+ })
+ .unwrap();
+
+ // Verify pane B's active item is main.rs
+ workspace
+ .read_with(cx, |_multi, cx| {
+ let active = pane_b.read(cx).active_item().unwrap();
+ let editor = active.to_any_view().downcast::<Editor>().unwrap();
+ let path = editor.read(cx).project_path(cx).unwrap();
+ assert_eq!(
+ path.path.file_name().unwrap(),
+ "main.rs",
+ "Pane B should have main.rs active",
+ );
+ })
+ .unwrap();
+
+ // Start a debug session and trigger a breakpoint stop on main.rs line 2
+ let session = start_debug_session(&workspace, cx, |_| {}).unwrap();
+ let client = session.update(cx, |session, _| session.adapter_client().unwrap());
+
+ client.on_request::<Threads, _>(move |_, _| {
+ Ok(dap::ThreadsResponse {
+ threads: vec![dap::Thread {
+ id: 1,
+ name: "Thread 1".into(),
+ }],
+ })
+ });
+
+ client.on_request::<dap::requests::Scopes, _>(move |_, _| {
+ Ok(dap::ScopesResponse {
+ scopes: Vec::default(),
+ })
+ });
+
+ client.on_request::<StackTrace, _>(move |_, args| {
+ assert_eq!(args.thread_id, 1);
+
+ Ok(dap::StackTraceResponse {
+ stack_frames: vec![dap::StackFrame {
+ id: 1,
+ name: "frame 1".into(),
+ source: Some(dap::Source {
+ name: Some("main.rs".into()),
+ path: Some(path!("/project/main.rs").into()),
+ source_reference: None,
+ presentation_hint: None,
+ origin: None,
+ sources: None,
+ adapter_data: None,
+ checksums: None,
+ }),
+ line: 2,
+ column: 0,
+ end_line: None,
+ end_column: None,
+ can_restart: None,
+ instruction_pointer_reference: None,
+ module_id: None,
+ presentation_hint: None,
+ }],
+ total_frames: None,
+ })
+ });
+
+ client
+ .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent {
+ reason: dap::StoppedEventReason::Breakpoint,
+ description: None,
+ thread_id: Some(1),
+ preserve_focus_hint: None,
+ text: None,
+ all_threads_stopped: None,
+ hit_breakpoint_ids: None,
+ }))
+ .await;
+
+ cx.run_until_parked();
+
+ // After first breakpoint stop on main.rs:
+ // Pane A should still have second.rs as its active item because
+ // main.rs was only an inactive tab there. The debugger should have jumped
+ // to main.rs only in pane B where it was already the active tab.
+ workspace
+ .read_with(cx, |_multi, cx| {
+ let pane_a_active = pane_a.read(cx).active_item().unwrap();
+ let pane_a_editor = pane_a_active.to_any_view().downcast::<Editor>().unwrap();
+ let pane_a_path = pane_a_editor.read(cx).project_path(cx).unwrap();
+ assert_eq!(
+ pane_a_path.path.file_name().unwrap(),
+ "second.rs",
+ "Pane A should still have second.rs as active item. \
+ The debugger should not switch active tabs in panes where the \
+ breakpoint file is not the active tab (issue #40602)",
+ );
+ })
+ .unwrap();
+
+ // There should be exactly one active debug line across all editors in all panes
+ workspace
+ .read_with(cx, |_multi, cx| {
+ let mut total_active_debug_lines = 0;
+ for pane in [&pane_a, &pane_b] {
+ for item in pane.read(cx).items() {
+ if let Some(editor) = item.to_any_view().downcast::<Editor>().ok() {
+ total_active_debug_lines += editor
+ .read(cx)
+ .highlighted_rows::<ActiveDebugLine>()
+ .count();
+ }
+ }
+ }
+ assert_eq!(
+ total_active_debug_lines, 1,
+ "There should be exactly one active debug line across all editors in all panes"
+ );
+ })
+ .unwrap();
+
+ // Pane B should show the debug highlight on main.rs
+ workspace
+ .read_with(cx, |_multi, cx| {
+ let pane_b_active = pane_b.read(cx).active_item().unwrap();
+ let pane_b_editor = pane_b_active.to_any_view().downcast::<Editor>().unwrap();
+
+ let active_debug_lines: Vec<_> = pane_b_editor
+ .read(cx)
+ .highlighted_rows::<ActiveDebugLine>()
+ .collect();
+
+ assert_eq!(
+ active_debug_lines.len(),
+ 1,
+ "Pane B's main.rs editor should have the active debug line"
+ );
+ })
+ .unwrap();
+
+ // Second breakpoint stop: now on second.rs line 3.
+ // Even though pane A has second.rs as its active tab, the debug line
+ // should open in pane B (the persistent debug pane) because pane B
+ // had the last active debug line.
+ client.on_request::<StackTrace, _>(move |_, args| {
+ assert_eq!(args.thread_id, 1);
+
+ Ok(dap::StackTraceResponse {
+ stack_frames: vec![dap::StackFrame {
+ id: 2,
+ name: "frame 2".into(),
+ source: Some(dap::Source {
+ name: Some("second.rs".into()),
+ path: Some(path!("/project/second.rs").into()),
+ source_reference: None,
+ presentation_hint: None,
+ origin: None,
+ sources: None,
+ adapter_data: None,
+ checksums: None,
+ }),
+ line: 3,
+ column: 0,
+ end_line: None,
+ end_column: None,
+ can_restart: None,
+ instruction_pointer_reference: None,
+ module_id: None,
+ presentation_hint: None,
+ }],
+ total_frames: None,
+ })
+ });
+
+ client
+ .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent {
+ reason: dap::StoppedEventReason::Breakpoint,
+ description: None,
+ thread_id: Some(1),
+ preserve_focus_hint: None,
+ text: None,
+ all_threads_stopped: None,
+ hit_breakpoint_ids: None,
+ }))
+ .await;
+
+ cx.run_until_parked();
+
+ // Pane B should now have second.rs as the active tab with the debug line,
+ // because pane B was the last pane that had the debug line (persistent debug pane).
+ workspace
+ .read_with(cx, |_multi, cx| {
+ let pane_b_active = pane_b.read(cx).active_item().unwrap();
+ let pane_b_editor = pane_b_active.to_any_view().downcast::<Editor>().unwrap();
+ let pane_b_path = pane_b_editor.read(cx).project_path(cx).unwrap();
+ assert_eq!(
+ pane_b_path.path.file_name().unwrap(),
+ "second.rs",
+ "Pane B should have switched to second.rs because it is the persistent debug pane",
+ );
+
+ let active_debug_lines: Vec<_> = pane_b_editor
+ .read(cx)
+ .highlighted_rows::<ActiveDebugLine>()
+ .collect();
+
+ assert_eq!(
+ active_debug_lines.len(),
+ 1,
+ "Pane B's second.rs editor should have the active debug line"
+ );
+ })
+ .unwrap();
+
+ // There should still be exactly one active debug line across all editors
+ workspace
+ .read_with(cx, |_multi, cx| {
+ let mut total_active_debug_lines = 0;
+ for pane in [&pane_a, &pane_b] {
+ for item in pane.read(cx).items() {
+ if let Some(editor) = item.to_any_view().downcast::<Editor>().ok() {
+ total_active_debug_lines += editor
+ .read(cx)
+ .highlighted_rows::<ActiveDebugLine>()
+ .count();
+ }
+ }
+ }
+ assert_eq!(
+ total_active_debug_lines, 1,
+ "There should be exactly one active debug line across all editors after second stop"
+ );
+ })
+ .unwrap();
+
+ // === New case: Move the debug pane (pane B) active item to a new pane C ===
+ // This simulates a user dragging the tab with the active debug line to a new split.
+ // The debugger should track that the debug line moved to pane C and use pane C
+ // for subsequent debug stops.
+
+ // Split pane B to create pane C
+ let pane_c = workspace
+ .update(cx, |multi, window, cx| {
+ multi.workspace().update(cx, |workspace, cx| {
+ workspace.split_pane(pane_b.clone(), SplitDirection::Right, window, cx)
+ })
+ })
+ .unwrap();
+
+ cx.run_until_parked();
+
+ // Move the active item (second.rs with debug line) from pane B to pane C
+ workspace
+ .update(cx, |_multi, window, cx| {
+ move_active_item(&pane_b, &pane_c, true, false, window, cx);
+ })
+ .unwrap();
+
+ cx.run_until_parked();
+
+ // Verify pane C now has second.rs as active item
+ workspace
+ .read_with(cx, |_multi, cx| {
+ let pane_c_active = pane_c.read(cx).active_item().unwrap();
+ let pane_c_editor = pane_c_active.to_any_view().downcast::<Editor>().unwrap();
+ let pane_c_path = pane_c_editor.read(cx).project_path(cx).unwrap();
+ assert_eq!(
+ pane_c_path.path.file_name().unwrap(),
+ "second.rs",
+ "Pane C should have second.rs after moving it from pane B",
+ );
+ })
+ .unwrap();
+
+ // Third breakpoint stop: back on main.rs line 2.
+ // The debug line should appear in pane C because that's where the debug line
+ // was moved to. The debugger should track pane moves.
+ client.on_request::<StackTrace, _>(move |_, args| {
+ assert_eq!(args.thread_id, 1);
+
+ Ok(dap::StackTraceResponse {
+ stack_frames: vec![dap::StackFrame {
+ id: 3,
+ name: "frame 3".into(),
+ source: Some(dap::Source {
+ name: Some("main.rs".into()),
+ path: Some(path!("/project/main.rs").into()),
+ source_reference: None,
+ presentation_hint: None,
+ origin: None,
+ sources: None,
+ adapter_data: None,
+ checksums: None,
+ }),
+ line: 2,
+ column: 0,
+ end_line: None,
+ end_column: None,
+ can_restart: None,
+ instruction_pointer_reference: None,
+ module_id: None,
+ presentation_hint: None,
+ }],
+ total_frames: None,
+ })
+ });
+
+ client
+ .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent {
+ reason: dap::StoppedEventReason::Breakpoint,
+ description: None,
+ thread_id: Some(1),
+ preserve_focus_hint: None,
+ text: None,
+ all_threads_stopped: None,
+ hit_breakpoint_ids: None,
+ }))
+ .await;
+
+ cx.run_until_parked();
+
+ // Pane C should now have main.rs as the active tab with the debug line,
+ // because pane C is where the debug line was moved to from pane B.
+ workspace
+ .read_with(cx, |_multi, cx| {
+ let pane_c_active = pane_c.read(cx).active_item().unwrap();
+ let pane_c_editor = pane_c_active.to_any_view().downcast::<Editor>().unwrap();
+ let pane_c_path = pane_c_editor.read(cx).project_path(cx).unwrap();
+ assert_eq!(
+ pane_c_path.path.file_name().unwrap(),
+ "main.rs",
+ "Pane C should have switched to main.rs because it is now the persistent debug pane \
+ (the debug line was moved here from pane B)",
+ );
+
+ let active_debug_lines: Vec<_> = pane_c_editor
+ .read(cx)
+ .highlighted_rows::<ActiveDebugLine>()
+ .collect();
+
+ assert_eq!(
+ active_debug_lines.len(),
+ 1,
+ "Pane C's main.rs editor should have the active debug line"
+ );
+ })
+ .unwrap();
+
+ // There should still be exactly one active debug line across all editors
+ workspace
+ .read_with(cx, |_multi, cx| {
+ let mut total_active_debug_lines = 0;
+ for pane in [&pane_a, &pane_b, &pane_c] {
+ for item in pane.read(cx).items() {
+ if let Some(editor) = item.to_any_view().downcast::<Editor>().ok() {
+ total_active_debug_lines += editor
+ .read(cx)
+ .highlighted_rows::<ActiveDebugLine>()
+ .count();
+ }
+ }
+ }
+ assert_eq!(
+ total_active_debug_lines, 1,
+ "There should be exactly one active debug line across all editors after third stop"
+ );
+ })
+ .unwrap();
+
+ // Clean up
+ let shutdown_session = project.update(cx, |project, cx| {
+ project.dap_store().update(cx, |dap_store, cx| {
+ dap_store.shutdown_session(session.read(cx).session_id(), cx)
+ })
+ });
+
+ shutdown_session.await.unwrap();
+}
+
#[gpui::test]
async fn test_adapter_shutdown_with_child_sessions_on_app_quit(
executor: BackgroundExecutor,
@@ -6,7 +6,9 @@ pub use breakpoints_in_file::{BreakpointSessionState, BreakpointWithPosition};
use breakpoints_in_file::{BreakpointsInFile, StatefulBreakpoint};
use collections::{BTreeMap, HashMap};
use dap::{StackFrameId, client::SessionId};
-use gpui::{App, AppContext, AsyncApp, Context, Entity, EventEmitter, Subscription, Task};
+use gpui::{
+ App, AppContext, AsyncApp, Context, Entity, EntityId, EventEmitter, Subscription, Task,
+};
use itertools::Itertools;
use language::{Buffer, BufferSnapshot, proto::serialize_anchor as serialize_text_anchor};
use rpc::{
@@ -154,6 +156,7 @@ pub struct BreakpointStore {
breakpoints: BTreeMap<Arc<Path>, BreakpointsInFile>,
downstream_client: Option<(AnyProtoClient, u64)>,
active_stack_frame: Option<ActiveStackFrame>,
+ active_debug_line_pane_id: Option<EntityId>,
// E.g ssh
mode: BreakpointStoreMode,
}
@@ -171,6 +174,7 @@ impl BreakpointStore {
worktree_store,
downstream_client: None,
active_stack_frame: Default::default(),
+ active_debug_line_pane_id: None,
}
}
@@ -190,6 +194,7 @@ impl BreakpointStore {
worktree_store,
downstream_client: None,
active_stack_frame: Default::default(),
+ active_debug_line_pane_id: None,
}
}
@@ -651,16 +656,30 @@ impl BreakpointStore {
self.active_stack_frame.as_ref()
}
+ pub fn active_debug_line_pane_id(&self) -> Option<EntityId> {
+ self.active_debug_line_pane_id
+ }
+
+ pub fn set_active_debug_pane_id(&mut self, pane_id: EntityId) {
+ self.active_debug_line_pane_id = Some(pane_id);
+ }
+
pub fn remove_active_position(
&mut self,
session_id: Option<SessionId>,
cx: &mut Context<Self>,
) {
if let Some(session_id) = session_id {
- self.active_stack_frame
- .take_if(|active_stack_frame| active_stack_frame.session_id == session_id);
+ if self
+ .active_stack_frame
+ .take_if(|active_stack_frame| active_stack_frame.session_id == session_id)
+ .is_some()
+ {
+ self.active_debug_line_pane_id = None;
+ }
} else {
self.active_stack_frame.take();
+ self.active_debug_line_pane_id = None;
}
cx.emit(BreakpointStoreEvent::ClearDebugLines);