debugger: Open correct pane for breakpoints (#49390)

Anthony Eid created

Closes #40602

### Summary

This PR ensures that active debug lines only open in a single pane and
new active debug lines are added to the most recent pane that contained
an active debug line. This fixes a bug where Zed could go to the active
debug line file and location in every pane a user had open, even if that
pane was focused on a different file.

I fixed this by storing the `entity_id` of the pane containing the most
recently active debug line on `BreakpointStore`, this is consistent with
where the selected stack frame is stored. I used an `entity_id` instead
of a strong type to avoid circular dependencies. Whenever an active
debug line is being set in the editor or by the debugger it now checks
if there's a specific pane it should be set in, and after setting the
line it updates `BreakpointStore` state.

I also added a new method on the `workspace::Item` trait called `fn
pane_changed(&mut self, new_pane_id: EntityId, cx: &mut Context<Self>)`
To enable `Editor` to update `BreakpointStore`'s active debug line pane
id whenever an `Editor` is moved to a new pane.


### PR review TODO list

Before you mark this PR as ready for review, make sure that you have:
- [x] Added a solid test coverage and/or screenshots from doing manual
testing
- [x] Done a self-review taking into account security and performance
aspects
- [x] Aligned any UI changes with the [UI
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)

Release Notes:

- debugger: Fix bug where active debug lines could be set in the wrong
pane

Change summary

crates/debugger_ui/src/session/running/stack_frame_list.rs |  93 
crates/debugger_ui/src/tests/debugger_panel.rs             | 533 +++++++
crates/editor/src/editor.rs                                |  28 
crates/editor/src/items.rs                                 |  19 
crates/project/src/debugger/breakpoint_store.rs            |  25 
crates/workspace/src/item.rs                               |  20 
crates/workspace/src/workspace.rs                          |  13 
7 files changed, 689 insertions(+), 42 deletions(-)

Detailed changes

crates/debugger_ui/src/session/running/stack_frame_list.rs 🔗

@@ -429,34 +429,58 @@ impl StackFrameList {
             let position = buffer.read_with(cx, |this, _| {
                 this.snapshot().anchor_after(PointUtf16::new(row, 0))
             });
-            this.update_in(cx, |this, window, cx| {
-                this.workspace.update(cx, |workspace, cx| {
-                    let project_path = buffer
-                        .read(cx)
-                        .project_path(cx)
-                        .context("Could not select a stack frame for unnamed buffer")?;
-
-                    let open_preview = !workspace
-                        .item_of_type::<StackTraceView>(cx)
-                        .map(|viewer| {
-                            workspace
-                                .active_item(cx)
-                                .is_some_and(|item| item.item_id() == viewer.item_id())
-                        })
-                        .unwrap_or_default();
-
-                    anyhow::Ok(workspace.open_path_preview(
-                        project_path,
-                        None,
-                        true,
-                        true,
-                        open_preview,
-                        window,
-                        cx,
-                    ))
-                })
-            })???
-            .await?;
+            let opened_item = this
+                .update_in(cx, |this, window, cx| {
+                    this.workspace.update(cx, |workspace, cx| {
+                        let project_path = buffer
+                            .read(cx)
+                            .project_path(cx)
+                            .context("Could not select a stack frame for unnamed buffer")?;
+
+                        let open_preview = !workspace
+                            .item_of_type::<StackTraceView>(cx)
+                            .map(|viewer| {
+                                workspace
+                                    .active_item(cx)
+                                    .is_some_and(|item| item.item_id() == viewer.item_id())
+                            })
+                            .unwrap_or_default();
+
+                        let active_debug_line_pane = workspace
+                            .project()
+                            .read(cx)
+                            .breakpoint_store()
+                            .read(cx)
+                            .active_debug_line_pane_id()
+                            .and_then(|id| workspace.pane_for_entity_id(id));
+
+                        let debug_pane = if let Some(pane) = active_debug_line_pane {
+                            Some(pane.downgrade())
+                        } else {
+                            // No debug pane set yet. Find a pane where the target file
+                            // is already the active tab so we don't disrupt other panes.
+                            let pane_with_active_file = workspace.panes().iter().find(|pane| {
+                                pane.read(cx)
+                                    .active_item()
+                                    .and_then(|item| item.project_path(cx))
+                                    .is_some_and(|path| path == project_path)
+                            });
+
+                            pane_with_active_file.map(|pane| pane.downgrade())
+                        };
+
+                        anyhow::Ok(workspace.open_path_preview(
+                            project_path,
+                            debug_pane,
+                            true,
+                            true,
+                            open_preview,
+                            window,
+                            cx,
+                        ))
+                    })
+                })???
+                .await?;
 
             this.update(cx, |this, cx| {
                 let thread_id = this.state.read_with(cx, |state, _| {
@@ -464,6 +488,19 @@ impl StackFrameList {
                 })??;
 
                 this.workspace.update(cx, |workspace, cx| {
+                    if let Some(pane_id) = workspace
+                        .pane_for(&*opened_item)
+                        .map(|pane| pane.entity_id())
+                    {
+                        workspace
+                            .project()
+                            .read(cx)
+                            .breakpoint_store()
+                            .update(cx, |store, _cx| {
+                                store.set_active_debug_pane_id(pane_id);
+                            });
+                    }
+
                     let breakpoint_store = workspace.project().read(cx).breakpoint_store();
 
                     breakpoint_store.update(cx, |store, cx| {

crates/debugger_ui/src/tests/debugger_panel.rs 🔗

@@ -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,

crates/editor/src/editor.rs 🔗

@@ -22801,12 +22801,36 @@ impl Editor {
         maybe!({
             let breakpoint_store = self.breakpoint_store.as_ref()?;
 
-            let Some(active_stack_frame) = breakpoint_store.read(cx).active_position().cloned()
-            else {
+            let (active_stack_frame, debug_line_pane_id) = {
+                let store = breakpoint_store.read(cx);
+                let active_stack_frame = store.active_position().cloned();
+                let debug_line_pane_id = store.active_debug_line_pane_id();
+                (active_stack_frame, debug_line_pane_id)
+            };
+
+            let Some(active_stack_frame) = active_stack_frame else {
                 self.clear_row_highlights::<ActiveDebugLine>();
                 return None;
             };
 
+            if let Some(debug_line_pane_id) = debug_line_pane_id {
+                if let Some(workspace) = self
+                    .workspace
+                    .as_ref()
+                    .and_then(|(workspace, _)| workspace.upgrade())
+                {
+                    let editor_pane_id = workspace
+                        .read(cx)
+                        .pane_for_item_id(cx.entity_id())
+                        .map(|pane| pane.entity_id());
+
+                    if editor_pane_id.is_some_and(|id| id != debug_line_pane_id) {
+                        self.clear_row_highlights::<ActiveDebugLine>();
+                        return None;
+                    }
+                }
+            }
+
             let position = active_stack_frame.position;
             let buffer_id = position.buffer_id?;
             let snapshot = self

crates/editor/src/items.rs 🔗

@@ -1,7 +1,7 @@
 use crate::{
-    Anchor, Autoscroll, BufferSerialization, Capability, Editor, EditorEvent, EditorSettings,
-    ExcerptId, ExcerptRange, FormatTarget, MultiBuffer, MultiBufferSnapshot, NavigationData,
-    ReportEditorEvent, SelectionEffects, ToPoint as _,
+    ActiveDebugLine, Anchor, Autoscroll, BufferSerialization, Capability, Editor, EditorEvent,
+    EditorSettings, ExcerptId, ExcerptRange, FormatTarget, MultiBuffer, MultiBufferSnapshot,
+    NavigationData, ReportEditorEvent, SelectionEffects, ToPoint as _,
     display_map::HighlightKey,
     editor_settings::SeedQuerySetting,
     persistence::{DB, SerializedEditor},
@@ -1027,6 +1027,19 @@ impl Item for Editor {
         }
     }
 
+    fn pane_changed(&mut self, new_pane_id: EntityId, cx: &mut Context<Self>) {
+        if self
+            .highlighted_rows
+            .get(&TypeId::of::<ActiveDebugLine>())
+            .is_some_and(|lines| !lines.is_empty())
+            && let Some(breakpoint_store) = self.breakpoint_store.as_ref()
+        {
+            breakpoint_store.update(cx, |store, _cx| {
+                store.set_active_debug_pane_id(new_pane_id);
+            });
+        }
+    }
+
     fn to_item_events(event: &EditorEvent, f: &mut dyn FnMut(ItemEvent)) {
         match event {
             EditorEvent::Saved | EditorEvent::TitleChanged => {

crates/project/src/debugger/breakpoint_store.rs 🔗

@@ -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);

crates/workspace/src/item.rs 🔗

@@ -219,6 +219,7 @@ pub trait Item: Focusable + EventEmitter<Self::Event> + Render + Sized {
     fn discarded(&self, _project: Entity<Project>, _window: &mut Window, _cx: &mut Context<Self>) {}
     fn on_removed(&self, _cx: &mut Context<Self>) {}
     fn workspace_deactivated(&mut self, _window: &mut Window, _: &mut Context<Self>) {}
+    fn pane_changed(&mut self, _new_pane_id: EntityId, _cx: &mut Context<Self>) {}
     fn navigate(
         &mut self,
         _: Arc<dyn Any + Send>,
@@ -737,11 +738,22 @@ impl<T: Item> ItemHandle for Entity<T> {
                 .log_err();
         }
 
-        if workspace
+        let new_pane_id = pane.entity_id();
+        let old_item_pane = workspace
             .panes_by_item
-            .insert(self.item_id(), pane.downgrade())
-            .is_none()
-        {
+            .insert(self.item_id(), pane.downgrade());
+
+        if old_item_pane.as_ref().is_none_or(|old_pane| {
+            old_pane
+                .upgrade()
+                .is_some_and(|old_pane| old_pane.entity_id() != new_pane_id)
+        }) {
+            self.update(cx, |this, cx| {
+                this.pane_changed(new_pane_id, cx);
+            });
+        }
+
+        if old_item_pane.is_none() {
             let mut pending_autosave = DelayedDebouncedEditAction::new();
             let (pending_update_tx, mut pending_update_rx) = mpsc::unbounded();
             let pending_update = Rc::new(RefCell::new(None));

crates/workspace/src/workspace.rs 🔗

@@ -4906,10 +4906,21 @@ impl Workspace {
     }
 
     pub fn pane_for(&self, handle: &dyn ItemHandle) -> Option<Entity<Pane>> {
-        let weak_pane = self.panes_by_item.get(&handle.item_id())?;
+        self.pane_for_item_id(handle.item_id())
+    }
+
+    pub fn pane_for_item_id(&self, item_id: EntityId) -> Option<Entity<Pane>> {
+        let weak_pane = self.panes_by_item.get(&item_id)?;
         weak_pane.upgrade()
     }
 
+    pub fn pane_for_entity_id(&self, entity_id: EntityId) -> Option<Entity<Pane>> {
+        self.panes
+            .iter()
+            .find(|pane| pane.entity_id() == entity_id)
+            .cloned()
+    }
+
     fn collaborator_left(&mut self, peer_id: PeerId, window: &mut Window, cx: &mut Context<Self>) {
         self.follower_states.retain(|leader_id, state| {
             if *leader_id == CollaboratorId::PeerId(peer_id) {