From 41a60ffecfbf87c99044a899cccfbe650fa76ef7 Mon Sep 17 00:00:00 2001 From: Remco Smits Date: Tue, 18 Mar 2025 17:55:25 +0100 Subject: [PATCH] Debugger implementation (#13433) ### DISCLAIMER > As of 6th March 2025, debugger is still in development. We plan to merge it behind a staff-only feature flag for staff use only, followed by non-public release and then finally a public one (akin to how Git panel release was handled). This is done to ensure the best experience when it gets released. ### END OF DISCLAIMER **The current state of the debugger implementation:** https://github.com/user-attachments/assets/c4deff07-80dd-4dc6-ad2e-0c252a478fe9 https://github.com/user-attachments/assets/e1ed2345-b750-4bb6-9c97-50961b76904f ---- All the todo's are in the following channel, so it's easier to work on this together: https://zed.dev/channel/zed-debugger-11370 If you are on Linux, you can use the following command to join the channel: ```cli zed https://zed.dev/channel/zed-debugger-11370 ``` ## Current Features - Collab - Breakpoints - Sync when you (re)join a project - Sync when you add/remove a breakpoint - Sync active debug line - Stack frames - Click on stack frame - View variables that belong to the stack frame - Visit the source file - Restart stack frame (if adapter supports this) - Variables - Loaded sources - Modules - Controls - Continue - Step back - Stepping granularity (configurable) - Step into - Stepping granularity (configurable) - Step over - Stepping granularity (configurable) - Step out - Stepping granularity (configurable) - Debug console - Breakpoints - Log breakpoints - line breakpoints - Persistent between zed sessions (configurable) - Multi buffer support - Toggle disable/enable all breakpoints - Stack frames - Click on stack frame - View variables that belong to the stack frame - Visit the source file - Show collapsed stack frames - Restart stack frame (if adapter supports this) - Loaded sources - View all used loaded sources if supported by adapter. - Modules - View all used modules (if adapter supports this) - Variables - Copy value - Copy name - Copy memory reference - Set value (if adapter supports this) - keyboard navigation - Debug Console - See logs - View output that was sent from debug adapter - Output grouping - Evaluate code - Updates the variable list - Auto completion - If not supported by adapter, we will show auto-completion for existing variables - Debug Terminal - Run custom commands and change env values right inside your Zed terminal - Attach to process (if adapter supports this) - Process picker - Controls - Continue - Step back - Stepping granularity (configurable) - Step into - Stepping granularity (configurable) - Step over - Stepping granularity (configurable) - Step out - Stepping granularity (configurable) - Disconnect - Restart - Stop - Warning when a debug session exited without hitting any breakpoint - Debug view to see Adapter/RPC log messages - Testing - Fake debug adapter - Fake requests & events --- Release Notes: - N/A --------- Co-authored-by: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Co-authored-by: Anthony Eid Co-authored-by: Anthony Co-authored-by: Piotr Osiewicz Co-authored-by: Piotr --- .zed/debug.json | 19 + Cargo.lock | 135 +- Cargo.toml | 10 + assets/icons/debug.svg | 1 + assets/icons/debug_breakpoint.svg | 1 + assets/icons/debug_continue.svg | 1 + assets/icons/debug_disconnect.svg | 1 + assets/icons/debug_ignore_breakpoints.svg | 1 + assets/icons/debug_log_breakpoint.svg | 1 + assets/icons/debug_pause.svg | 1 + assets/icons/debug_restart.svg | 1 + assets/icons/debug_step_back.svg | 1 + assets/icons/debug_step_into.svg | 5 + assets/icons/debug_step_out.svg | 5 + assets/icons/debug_step_over.svg | 5 + assets/icons/debug_stop.svg | 1 + assets/keymaps/default-linux.json | 11 +- assets/keymaps/default-macos.json | 17 + assets/keymaps/linux/jetbrains.json | 13 +- assets/keymaps/macos/jetbrains.json | 13 +- assets/settings/default.json | 10 +- assets/settings/initial_debug_tasks.json | 32 + crates/activity_indicator/Cargo.toml | 1 - .../src/activity_indicator.rs | 54 +- .../src/context_editor.rs | 1 + crates/collab/Cargo.toml | 3 + .../20221109000000_test_schema.sql | 11 + .../20241121185750_add_breakpoints.sql | 11 + crates/collab/src/db/queries/projects.rs | 75 +- crates/collab/src/rpc.rs | 4 +- crates/collab/src/tests.rs | 1 + crates/collab/src/tests/debug_panel_tests.rs | 2454 +++++++++++++++++ crates/collab/src/tests/editor_tests.rs | 205 +- crates/dap/Cargo.toml | 53 + crates/dap/LICENSE-GPL | 1 + crates/dap/docs/breakpoints.md | 9 + crates/dap/src/adapters.rs | 370 +++ crates/dap/src/client.rs | 490 ++++ crates/dap/src/debugger_settings.rs | 59 + crates/dap/src/lib.rs | 38 + crates/dap/src/proto_conversions.rs | 591 ++++ crates/dap/src/transport.rs | 891 ++++++ crates/dap_adapters/Cargo.toml | 41 + crates/dap_adapters/LICENSE-GPL | 1 + crates/dap_adapters/src/custom.rs | 84 + crates/dap_adapters/src/dap_adapters.rs | 67 + crates/dap_adapters/src/gdb.rs | 83 + crates/dap_adapters/src/go.rs | 100 + crates/dap_adapters/src/javascript.rs | 148 + crates/dap_adapters/src/lldb.rs | 104 + crates/dap_adapters/src/php.rs | 123 + crates/dap_adapters/src/python.rs | 142 + crates/debugger_tools/Cargo.toml | 26 + crates/debugger_tools/LICENSE-GPL | 1 + crates/debugger_tools/src/dap_log.rs | 845 ++++++ crates/debugger_tools/src/debugger_tools.rs | 8 + crates/debugger_ui/Cargo.toml | 58 + crates/debugger_ui/LICENSE-GPL | 1 + crates/debugger_ui/src/attach_modal.rs | 293 ++ crates/debugger_ui/src/debugger_panel.rs | 536 ++++ crates/debugger_ui/src/lib.rs | 122 + crates/debugger_ui/src/session.rs | 313 +++ crates/debugger_ui/src/session/failed.rs | 30 + crates/debugger_ui/src/session/inert.rs | 219 ++ crates/debugger_ui/src/session/running.rs | 686 +++++ .../src/session/running/console.rs | 419 +++ .../src/session/running/loaded_source_list.rs | 103 + .../src/session/running/module_list.rs | 183 ++ .../src/session/running/stack_frame_list.rs | 519 ++++ .../src/session/running/variable_list.rs | 946 +++++++ crates/debugger_ui/src/session/starting.rs | 80 + crates/debugger_ui/src/tests.rs | 75 + crates/debugger_ui/src/tests/attach_modal.rs | 136 + crates/debugger_ui/src/tests/console.rs | 916 ++++++ .../debugger_ui/src/tests/debugger_panel.rs | 1084 ++++++++ crates/debugger_ui/src/tests/module_list.rs | 262 ++ .../debugger_ui/src/tests/stack_frame_list.rs | 845 ++++++ crates/debugger_ui/src/tests/variable_list.rs | 1759 ++++++++++++ crates/editor/Cargo.toml | 2 + crates/editor/src/actions.rs | 2 + crates/editor/src/editor.rs | 768 +++++- crates/editor/src/editor_settings.rs | 5 + crates/editor/src/editor_tests.rs | 339 ++- crates/editor/src/element.rs | 240 +- crates/extension/src/extension_host_proxy.rs | 6 +- .../src/extension_store_test.rs | 19 +- .../src/wasm_host/wit/since_v0_0_1.rs | 16 +- .../src/wasm_host/wit/since_v0_1_0.rs | 16 +- .../src/wasm_host/wit/since_v0_3_0.rs | 16 +- crates/feature_flags/src/feature_flags.rs | 7 +- crates/language/src/buffer_tests.rs | 2 +- crates/language/src/language.rs | 16 +- crates/language/src/language_registry.rs | 38 +- .../src/extension_lsp_adapter.rs | 6 +- crates/languages/src/json.rs | 11 +- crates/paths/src/paths.rs | 24 + crates/project/Cargo.toml | 12 +- crates/project/src/buffer_store.rs | 35 +- crates/project/src/debugger.rs | 19 + crates/project/src/debugger/README.md | 350 +++ .../project/src/debugger/breakpoint_store.rs | 612 ++++ crates/project/src/debugger/dap_command.rs | 1738 ++++++++++++ crates/project/src/debugger/dap_store.rs | 882 ++++++ crates/project/src/debugger/session.rs | 1762 ++++++++++++ crates/project/src/lsp_store.rs | 18 +- crates/project/src/project.rs | 194 +- crates/project/src/project_settings.rs | 40 +- crates/project/src/project_tests.rs | 1 + crates/project/src/task_inventory.rs | 40 +- crates/project/src/task_store.rs | 57 +- crates/project/src/terminals.rs | 33 + crates/proto/build.rs | 3 + crates/proto/proto/zed.proto | 541 +++- crates/proto/src/proto.rs | 148 +- crates/recent_projects/Cargo.toml | 3 +- crates/recent_projects/src/recent_projects.rs | 2 + crates/remote_server/src/headless_project.rs | 43 +- crates/settings/src/settings.rs | 6 +- crates/settings/src/settings_store.rs | 10 +- crates/sqlez/src/bindable.rs | 2 + crates/sqlez/src/statement.rs | 6 + crates/task/Cargo.toml | 8 + crates/task/src/debug_format.rs | 227 ++ crates/task/src/lib.rs | 33 +- crates/task/src/task_template.rs | 94 +- crates/tasks_ui/Cargo.toml | 2 + crates/tasks_ui/src/modal.rs | 85 +- crates/tasks_ui/src/tasks_ui.rs | 35 +- crates/terminal/src/pty_info.rs | 4 + crates/terminal/src/terminal.rs | 7 + crates/terminal_view/src/terminal_panel.rs | 2 +- crates/terminal_view/src/terminal_view.rs | 5 +- crates/text/src/text.rs | 2 +- crates/theme/src/default_colors.rs | 6 +- crates/theme/src/fallback_themes.rs | 7 + crates/theme/src/schema.rs | 21 +- crates/theme/src/styles/colors.rs | 9 +- .../ui/src/components/button/button_like.rs | 43 +- .../ui/src/components/button/icon_button.rs | 9 + crates/ui/src/components/dropdown_menu.rs | 6 +- crates/ui/src/components/icon.rs | 13 + crates/ui/src/components/list/list_item.rs | 11 +- crates/ui/src/styles/color.rs | 3 + crates/ui/src/utils/search_input.rs | 2 +- crates/util/src/fs.rs | 69 +- crates/vim/src/command.rs | 1 + crates/workspace/src/pane.rs | 28 +- crates/workspace/src/persistence.rs | 311 ++- crates/workspace/src/persistence/model.rs | 4 +- crates/workspace/src/workspace.rs | 40 +- crates/zed/Cargo.toml | 2 + crates/zed/src/main.rs | 2 + crates/zed/src/zed.rs | 140 +- crates/zeta/src/rate_completion_modal.rs | 1 + docs/src/languages/markdown.md | 2 +- docs/src/model-improvement.md | 2 +- 156 files changed, 25840 insertions(+), 451 deletions(-) create mode 100644 .zed/debug.json create mode 100644 assets/icons/debug.svg create mode 100644 assets/icons/debug_breakpoint.svg create mode 100644 assets/icons/debug_continue.svg create mode 100644 assets/icons/debug_disconnect.svg create mode 100644 assets/icons/debug_ignore_breakpoints.svg create mode 100644 assets/icons/debug_log_breakpoint.svg create mode 100644 assets/icons/debug_pause.svg create mode 100644 assets/icons/debug_restart.svg create mode 100644 assets/icons/debug_step_back.svg create mode 100644 assets/icons/debug_step_into.svg create mode 100644 assets/icons/debug_step_out.svg create mode 100644 assets/icons/debug_step_over.svg create mode 100644 assets/icons/debug_stop.svg create mode 100644 assets/settings/initial_debug_tasks.json create mode 100644 crates/collab/migrations/20241121185750_add_breakpoints.sql create mode 100644 crates/collab/src/tests/debug_panel_tests.rs create mode 100644 crates/dap/Cargo.toml create mode 120000 crates/dap/LICENSE-GPL create mode 100644 crates/dap/docs/breakpoints.md create mode 100644 crates/dap/src/adapters.rs create mode 100644 crates/dap/src/client.rs create mode 100644 crates/dap/src/debugger_settings.rs create mode 100644 crates/dap/src/lib.rs create mode 100644 crates/dap/src/proto_conversions.rs create mode 100644 crates/dap/src/transport.rs create mode 100644 crates/dap_adapters/Cargo.toml create mode 120000 crates/dap_adapters/LICENSE-GPL create mode 100644 crates/dap_adapters/src/custom.rs create mode 100644 crates/dap_adapters/src/dap_adapters.rs create mode 100644 crates/dap_adapters/src/gdb.rs create mode 100644 crates/dap_adapters/src/go.rs create mode 100644 crates/dap_adapters/src/javascript.rs create mode 100644 crates/dap_adapters/src/lldb.rs create mode 100644 crates/dap_adapters/src/php.rs create mode 100644 crates/dap_adapters/src/python.rs create mode 100644 crates/debugger_tools/Cargo.toml create mode 120000 crates/debugger_tools/LICENSE-GPL create mode 100644 crates/debugger_tools/src/dap_log.rs create mode 100644 crates/debugger_tools/src/debugger_tools.rs create mode 100644 crates/debugger_ui/Cargo.toml create mode 120000 crates/debugger_ui/LICENSE-GPL create mode 100644 crates/debugger_ui/src/attach_modal.rs create mode 100644 crates/debugger_ui/src/debugger_panel.rs create mode 100644 crates/debugger_ui/src/lib.rs create mode 100644 crates/debugger_ui/src/session.rs create mode 100644 crates/debugger_ui/src/session/failed.rs create mode 100644 crates/debugger_ui/src/session/inert.rs create mode 100644 crates/debugger_ui/src/session/running.rs create mode 100644 crates/debugger_ui/src/session/running/console.rs create mode 100644 crates/debugger_ui/src/session/running/loaded_source_list.rs create mode 100644 crates/debugger_ui/src/session/running/module_list.rs create mode 100644 crates/debugger_ui/src/session/running/stack_frame_list.rs create mode 100644 crates/debugger_ui/src/session/running/variable_list.rs create mode 100644 crates/debugger_ui/src/session/starting.rs create mode 100644 crates/debugger_ui/src/tests.rs create mode 100644 crates/debugger_ui/src/tests/attach_modal.rs create mode 100644 crates/debugger_ui/src/tests/console.rs create mode 100644 crates/debugger_ui/src/tests/debugger_panel.rs create mode 100644 crates/debugger_ui/src/tests/module_list.rs create mode 100644 crates/debugger_ui/src/tests/stack_frame_list.rs create mode 100644 crates/debugger_ui/src/tests/variable_list.rs create mode 100644 crates/project/src/debugger.rs create mode 100644 crates/project/src/debugger/README.md create mode 100644 crates/project/src/debugger/breakpoint_store.rs create mode 100644 crates/project/src/debugger/dap_command.rs create mode 100644 crates/project/src/debugger/dap_store.rs create mode 100644 crates/project/src/debugger/session.rs create mode 100644 crates/task/src/debug_format.rs diff --git a/.zed/debug.json b/.zed/debug.json new file mode 100644 index 0000000000..b7646ee3bd --- /dev/null +++ b/.zed/debug.json @@ -0,0 +1,19 @@ +[ + { + "label": "Debug Zed with LLDB", + "adapter": "lldb", + "program": "$ZED_WORKTREE_ROOT/target/debug/zed", + "request": "launch", + "cwd": "$ZED_WORKTREE_ROOT" + }, + { + "label": "Debug Zed with GDB", + "adapter": "gdb", + "program": "$ZED_WORKTREE_ROOT/target/debug/zed", + "request": "launch", + "cwd": "$ZED_WORKTREE_ROOT", + "initialize_args": { + "stopAtBeginningOfMainSubprogram": true + } + } +] diff --git a/Cargo.lock b/Cargo.lock index 91538b0184..5b458740b0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13,7 +13,6 @@ dependencies = [ "futures 0.3.31", "gpui", "language", - "lsp", "project", "smallvec", "ui", @@ -2644,6 +2643,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "circular-buffer" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dacb91f972298e70fc507a2ffcaf1545807f1a36da586fb846646030adc542f" + [[package]] name = "clang-sys" version = "1.8.1" @@ -2893,9 +2898,12 @@ dependencies = [ "clock", "collab_ui", "collections", + "command_palette_hooks", "context_server", "ctor", + "dap", "dashmap 6.1.0", + "debugger_ui", "derive_more", "editor", "env_logger 0.11.7", @@ -3839,6 +3847,66 @@ dependencies = [ "syn 2.0.100", ] +[[package]] +name = "dap" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-compression", + "async-pipe", + "async-tar", + "async-trait", + "client", + "collections", + "dap-types", + "env_logger 0.11.7", + "fs", + "futures 0.3.31", + "gpui", + "http_client", + "language", + "log", + "node_runtime", + "parking_lot", + "paths", + "schemars", + "serde", + "serde_json", + "settings", + "smallvec", + "smol", + "task", + "util", +] + +[[package]] +name = "dap-types" +version = "0.0.1" +source = "git+https://github.com/zed-industries/dap-types?rev=bfd4af0#bfd4af084bbaa5f344e6925370d7642e41d0b5b8" +dependencies = [ + "schemars", + "serde", + "serde_json", +] + +[[package]] +name = "dap_adapters" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "dap", + "gpui", + "language", + "paths", + "regex", + "serde", + "serde_json", + "sysinfo", + "task", + "util", +] + [[package]] name = "dashmap" version = "5.5.3" @@ -3912,6 +3980,58 @@ dependencies = [ "winapi", ] +[[package]] +name = "debugger_tools" +version = "0.1.0" +dependencies = [ + "anyhow", + "dap", + "editor", + "futures 0.3.31", + "gpui", + "project", + "serde_json", + "settings", + "smol", + "util", + "workspace", +] + +[[package]] +name = "debugger_ui" +version = "0.1.0" +dependencies = [ + "anyhow", + "client", + "collections", + "command_palette_hooks", + "dap", + "editor", + "env_logger 0.11.7", + "feature_flags", + "futures 0.3.31", + "fuzzy", + "gpui", + "language", + "log", + "menu", + "picker", + "pretty_assertions", + "project", + "rpc", + "serde", + "serde_json", + "settings", + "sysinfo", + "task", + "terminal_view", + "theme", + "ui", + "unindent", + "util", + "workspace", +] + [[package]] name = "deepseek" version = "0.1.0" @@ -4212,6 +4332,7 @@ dependencies = [ "db", "emojis", "env_logger 0.11.7", + "feature_flags", "file_icons", "fs", "futures 0.3.31", @@ -4228,6 +4349,7 @@ dependencies = [ "log", "lsp", "markdown", + "menu", "multi_buffer", "ordered-float 2.10.1", "parking_lot", @@ -10422,9 +10544,12 @@ dependencies = [ "askpass", "async-trait", "buffer_diff", + "circular-buffer", "client", "clock", "collections", + "dap", + "dap_adapters", "env_logger 0.11.7", "extension", "fancy-regex 0.14.0", @@ -10437,6 +10562,7 @@ dependencies = [ "gpui", "http_client", "image", + "indexmap", "itertools 0.14.0", "language", "log", @@ -11100,6 +11226,7 @@ version = "0.1.0" dependencies = [ "anyhow", "auto_update", + "dap", "editor", "extension_host", "file_finder", @@ -13657,12 +13784,14 @@ version = "0.1.0" dependencies = [ "anyhow", "collections", + "dap-types", "futures 0.3.31", "gpui", "hex", "parking_lot", "schemars", "serde", + "serde_json", "serde_json_lenient", "sha2", "shellexpand 2.1.2", @@ -13675,7 +13804,9 @@ name = "tasks_ui" version = "0.1.0" dependencies = [ "anyhow", + "debugger_ui", "editor", + "feature_flags", "file_icons", "fuzzy", "gpui", @@ -17218,6 +17349,8 @@ dependencies = [ "component_preview", "copilot", "db", + "debugger_tools", + "debugger_ui", "diagnostics", "editor", "env_logger 0.11.7", diff --git a/Cargo.toml b/Cargo.toml index f9a6835c5a..22e26ff796 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,10 @@ members = [ "crates/context_server_settings", "crates/copilot", "crates/credentials_provider", + "crates/dap", + "crates/dap_adapters", + "crates/debugger_tools", + "crates/debugger_ui", "crates/db", "crates/deepseek", "crates/diagnostics", @@ -236,7 +240,11 @@ context_server = { path = "crates/context_server" } context_server_settings = { path = "crates/context_server_settings" } copilot = { path = "crates/copilot" } credentials_provider = { path = "crates/credentials_provider" } +dap = { path = "crates/dap" } +dap_adapters = { path = "crates/dap_adapters" } db = { path = "crates/db" } +debugger_ui = { path = "crates/debugger_ui" } +debugger_tools = { path = "crates/debugger_tools" } deepseek = { path = "crates/deepseek" } diagnostics = { path = "crates/diagnostics" } buffer_diff = { path = "crates/buffer_diff" } @@ -402,6 +410,7 @@ bytes = "1.0" cargo_metadata = "0.19" cargo_toml = "0.21" chrono = { version = "0.4", features = ["serde"] } +circular-buffer = "1.0" clap = { version = "4.4", features = ["derive"] } cocoa = "0.26" cocoa-foundation = "0.2.0" @@ -410,6 +419,7 @@ core-foundation = "0.9.3" core-foundation-sys = "0.8.6" ctor = "0.4.0" dashmap = "6.0" +dap-types = { git = "https://github.com/zed-industries/dap-types", rev = "bfd4af0" } derive_more = "0.99.17" dirs = "4.0" ec4rs = "1.1" diff --git a/assets/icons/debug.svg b/assets/icons/debug.svg new file mode 100644 index 0000000000..8cea0c4604 --- /dev/null +++ b/assets/icons/debug.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/debug_breakpoint.svg b/assets/icons/debug_breakpoint.svg new file mode 100644 index 0000000000..f6a7b35658 --- /dev/null +++ b/assets/icons/debug_breakpoint.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/debug_continue.svg b/assets/icons/debug_continue.svg new file mode 100644 index 0000000000..e2a99c38d0 --- /dev/null +++ b/assets/icons/debug_continue.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/debug_disconnect.svg b/assets/icons/debug_disconnect.svg new file mode 100644 index 0000000000..0eb2537152 --- /dev/null +++ b/assets/icons/debug_disconnect.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/debug_ignore_breakpoints.svg b/assets/icons/debug_ignore_breakpoints.svg new file mode 100644 index 0000000000..ba7074e083 --- /dev/null +++ b/assets/icons/debug_ignore_breakpoints.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/debug_log_breakpoint.svg b/assets/icons/debug_log_breakpoint.svg new file mode 100644 index 0000000000..a878ce3e04 --- /dev/null +++ b/assets/icons/debug_log_breakpoint.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/debug_pause.svg b/assets/icons/debug_pause.svg new file mode 100644 index 0000000000..bea531bc5a --- /dev/null +++ b/assets/icons/debug_pause.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/debug_restart.svg b/assets/icons/debug_restart.svg new file mode 100644 index 0000000000..4eff13b94b --- /dev/null +++ b/assets/icons/debug_restart.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/debug_step_back.svg b/assets/icons/debug_step_back.svg new file mode 100644 index 0000000000..bc7c9b8444 --- /dev/null +++ b/assets/icons/debug_step_back.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/debug_step_into.svg b/assets/icons/debug_step_into.svg new file mode 100644 index 0000000000..69e5cff3f1 --- /dev/null +++ b/assets/icons/debug_step_into.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/debug_step_out.svg b/assets/icons/debug_step_out.svg new file mode 100644 index 0000000000..680e13e65e --- /dev/null +++ b/assets/icons/debug_step_out.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/debug_step_over.svg b/assets/icons/debug_step_over.svg new file mode 100644 index 0000000000..005b901da3 --- /dev/null +++ b/assets/icons/debug_step_over.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/debug_stop.svg b/assets/icons/debug_stop.svg new file mode 100644 index 0000000000..fef651c586 --- /dev/null +++ b/assets/icons/debug_stop.svg @@ -0,0 +1 @@ + diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index eb66cf6ed4..674c196f0e 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -30,6 +30,13 @@ "ctrl-0": ["zed::ResetBufferFontSize", { "persist": false }], "ctrl-,": "zed::OpenSettings", "ctrl-q": "zed::Quit", + "f4": "debugger::Start", + "f5": "debugger::Continue", + "shift-f5": "debugger::Stop", + "f6": "debugger::Pause", + "f7": "debugger::StepOver", + "cmd-f11": "debugger::StepInto", + "shift-f11": "debugger::StepOut", "f11": "zed::ToggleFullScreen", "ctrl-alt-z": "edit_prediction::RateCompletions", "ctrl-shift-i": "edit_prediction::ToggleMenu" @@ -124,7 +131,9 @@ "alt-g b": "editor::ToggleGitBlame", "menu": "editor::OpenContextMenu", "shift-f10": "editor::OpenContextMenu", - "ctrl-shift-e": "editor::ToggleEditPrediction" + "ctrl-shift-e": "editor::ToggleEditPrediction", + "f9": "editor::ToggleBreakpoint", + "shift-f9": "editor::EditLogBreakpoint" } }, { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 4252351a79..4c626ead6d 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -14,6 +14,13 @@ { "use_key_equivalents": true, "bindings": { + "f4": "debugger::Start", + "f5": "debugger::Continue", + "shift-f5": "debugger::Stop", + "f6": "debugger::Pause", + "f7": "debugger::StepOver", + "f11": "debugger::StepInto", + "shift-f11": "debugger::StepOut", "home": "menu::SelectFirst", "shift-pageup": "menu::SelectFirst", "pageup": "menu::SelectFirst", @@ -148,6 +155,8 @@ "cmd-\"": "editor::ExpandAllDiffHunks", "cmd-alt-g b": "editor::ToggleGitBlame", "cmd-i": "editor::ShowSignatureHelp", + "f9": "editor::ToggleBreakpoint", + "shift-f9": "editor::EditLogBreakpoint", "ctrl-f12": "editor::GoToDeclaration", "alt-ctrl-f12": "editor::GoToDeclarationSplit", "ctrl-cmd-e": "editor::ToggleEditPrediction" @@ -756,6 +765,14 @@ "space": "project_panel::Open" } }, + { + "context": "VariableList", + "use_key_equivalents": true, + "bindings": { + "left": "variable_list::CollapseSelectedEntry", + "right": "variable_list::ExpandSelectedEntry" + } + }, { "context": "GitPanel && ChangesList", "use_key_equivalents": true, diff --git a/assets/keymaps/linux/jetbrains.json b/assets/keymaps/linux/jetbrains.json index 89d01dfb5d..43937701cf 100644 --- a/assets/keymaps/linux/jetbrains.json +++ b/assets/keymaps/linux/jetbrains.json @@ -3,7 +3,14 @@ "bindings": { "ctrl-alt-s": "zed::OpenSettings", "ctrl-{": "pane::ActivatePreviousItem", - "ctrl-}": "pane::ActivateNextItem" + "ctrl-}": "pane::ActivateNextItem", + "ctrl-f2": "debugger::Stop", + "f6": "debugger::Pause", + "f7": "debugger::StepInto", + "f8": "debugger::StepOver", + "shift-f8": "debugger::StepOut", + "f9": "debugger::Continue", + "alt-shift-f9": "debugger::Start" } }, { @@ -49,7 +56,9 @@ "ctrl-home": "editor::MoveToBeginning", "ctrl-end": "editor::MoveToEnd", "ctrl-shift-home": "editor::SelectToBeginning", - "ctrl-shift-end": "editor::SelectToEnd" + "ctrl-shift-end": "editor::SelectToEnd", + "ctrl-f8": "editor::ToggleBreakpoint", + "ctrl-shift-f8": "editor::EditLogBreakpoint" } }, { diff --git a/assets/keymaps/macos/jetbrains.json b/assets/keymaps/macos/jetbrains.json index 4adacb945c..355e860908 100644 --- a/assets/keymaps/macos/jetbrains.json +++ b/assets/keymaps/macos/jetbrains.json @@ -2,7 +2,14 @@ { "bindings": { "cmd-{": "pane::ActivatePreviousItem", - "cmd-}": "pane::ActivateNextItem" + "cmd-}": "pane::ActivateNextItem", + "ctrl-f2": "debugger::Stop", + "f6": "debugger::Pause", + "f7": "debugger::StepInto", + "f8": "debugger::StepOver", + "shift-f8": "debugger::StepOut", + "f9": "debugger::Continue", + "alt-shift-f9": "debugger::Start" } }, { @@ -46,7 +53,9 @@ "cmd-home": "editor::MoveToBeginning", "cmd-end": "editor::MoveToEnd", "cmd-shift-home": "editor::SelectToBeginning", - "cmd-shift-end": "editor::SelectToEnd" + "cmd-shift-end": "editor::SelectToEnd", + "ctrl-f8": "editor::ToggleBreakpoint", + "ctrl-shift-f8": "editor::EditLogBreakpoint" } }, { diff --git a/assets/settings/default.json b/assets/settings/default.json index 800c276123..70d016fb79 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -324,6 +324,8 @@ "code_actions": true, // Whether to show runnables buttons in the gutter. "runnables": true, + // Whether to show breakpoints in the gutter. + "breakpoints": true, // Whether to show fold buttons in the gutter. "folds": true }, @@ -1453,6 +1455,12 @@ // } // ] "ssh_connections": [], + // Configures context servers for use in the Assistant. - "context_servers": {} + "context_servers": {}, + "debugger": { + "stepping_granularity": "line", + "save_breakpoints": true, + "button": true + } } diff --git a/assets/settings/initial_debug_tasks.json b/assets/settings/initial_debug_tasks.json new file mode 100644 index 0000000000..e77d7c8727 --- /dev/null +++ b/assets/settings/initial_debug_tasks.json @@ -0,0 +1,32 @@ +[ + { + "label": "Debug active PHP file", + "adapter": "php", + "program": "$ZED_FILE", + "request": "launch", + "cwd": "$ZED_WORKTREE_ROOT" + }, + { + "label": "Debug active Python file", + "adapter": "python", + "program": "$ZED_FILE", + "request": "launch", + "cwd": "$ZED_WORKTREE_ROOT" + }, + { + "label": "Debug active JavaScript file", + "adapter": "javascript", + "program": "$ZED_FILE", + "request": "launch", + "cwd": "$ZED_WORKTREE_ROOT" + }, + { + "label": "JavaScript debug terminal", + "adapter": "javascript", + "request": "launch", + "cwd": "$ZED_WORKTREE_ROOT", + "initialize_args": { + "console": "integratedTerminal" + } + } +] diff --git a/crates/activity_indicator/Cargo.toml b/crates/activity_indicator/Cargo.toml index e28c30ca88..a17846690a 100644 --- a/crates/activity_indicator/Cargo.toml +++ b/crates/activity_indicator/Cargo.toml @@ -20,7 +20,6 @@ extension_host.workspace = true futures.workspace = true gpui.workspace = true language.workspace = true -lsp.workspace = true project.workspace = true smallvec.workspace = true ui.workspace = true diff --git a/crates/activity_indicator/src/activity_indicator.rs b/crates/activity_indicator/src/activity_indicator.rs index 105bcff8db..5fd1f27631 100644 --- a/crates/activity_indicator/src/activity_indicator.rs +++ b/crates/activity_indicator/src/activity_indicator.rs @@ -7,8 +7,7 @@ use gpui::{ EventEmitter, InteractiveElement as _, ParentElement as _, Render, SharedString, StatefulInteractiveElement, Styled, Transformation, Window, }; -use language::{LanguageRegistry, LanguageServerBinaryStatus, LanguageServerId}; -use lsp::LanguageServerName; +use language::{BinaryStatus, LanguageRegistry, LanguageServerId}; use project::{ EnvironmentErrorMessage, LanguageServerProgress, LspStoreEvent, Project, ProjectEnvironmentEvent, WorktreeId, @@ -23,21 +22,21 @@ actions!(activity_indicator, [ShowErrorMessage]); pub enum Event { ShowError { - lsp_name: LanguageServerName, + server_name: SharedString, error: String, }, } pub struct ActivityIndicator { - statuses: Vec, + statuses: Vec, project: Entity, auto_updater: Option>, context_menu_handle: PopoverMenuHandle, } -struct LspStatus { - name: LanguageServerName, - status: LanguageServerBinaryStatus, +struct ServerStatus { + name: SharedString, + status: BinaryStatus, } struct PendingWork<'a> { @@ -68,7 +67,20 @@ impl ActivityIndicator { while let Some((name, status)) = status_events.next().await { this.update(&mut cx, |this: &mut ActivityIndicator, cx| { this.statuses.retain(|s| s.name != name); - this.statuses.push(LspStatus { name, status }); + this.statuses.push(ServerStatus { name, status }); + cx.notify(); + })?; + } + anyhow::Ok(()) + }) + .detach(); + + let mut status_events = languages.dap_server_binary_statuses(); + cx.spawn(|this, mut cx| async move { + while let Some((name, status)) = status_events.next().await { + this.update(&mut cx, |this, cx| { + this.statuses.retain(|s| s.name != name); + this.statuses.push(ServerStatus { name, status }); cx.notify(); })?; } @@ -106,18 +118,18 @@ impl ActivityIndicator { }); cx.subscribe_in(&this, window, move |_, _, event, window, cx| match event { - Event::ShowError { lsp_name, error } => { + Event::ShowError { server_name, error } => { let create_buffer = project.update(cx, |project, cx| project.create_buffer(cx)); let project = project.clone(); let error = error.clone(); - let lsp_name = lsp_name.clone(); + let server_name = server_name.clone(); cx.spawn_in(window, |workspace, mut cx| async move { let buffer = create_buffer.await?; buffer.update(&mut cx, |buffer, cx| { buffer.edit( [( 0..0, - format!("Language server error: {}\n\n{}", lsp_name, error), + format!("Language server error: {}\n\n{}", server_name, error), )], None, cx, @@ -147,9 +159,9 @@ impl ActivityIndicator { fn show_error_message(&mut self, _: &ShowErrorMessage, _: &mut Window, cx: &mut Context) { self.statuses.retain(|status| { - if let LanguageServerBinaryStatus::Failed { error } = &status.status { + if let BinaryStatus::Failed { error } = &status.status { cx.emit(Event::ShowError { - lsp_name: status.name.clone(), + server_name: status.name.clone(), error: error.clone(), }); false @@ -278,12 +290,10 @@ impl ActivityIndicator { let mut failed = SmallVec::<[_; 3]>::new(); for status in &self.statuses { match status.status { - LanguageServerBinaryStatus::CheckingForUpdate => { - checking_for_update.push(status.name.clone()) - } - LanguageServerBinaryStatus::Downloading => downloading.push(status.name.clone()), - LanguageServerBinaryStatus::Failed { .. } => failed.push(status.name.clone()), - LanguageServerBinaryStatus::None => {} + BinaryStatus::CheckingForUpdate => checking_for_update.push(status.name.clone()), + BinaryStatus::Downloading => downloading.push(status.name.clone()), + BinaryStatus::Failed { .. } => failed.push(status.name.clone()), + BinaryStatus::None => {} } } @@ -296,7 +306,7 @@ impl ActivityIndicator { ), message: format!( "Downloading {}...", - downloading.iter().map(|name| name.0.as_ref()).fold( + downloading.iter().map(|name| name.as_ref()).fold( String::new(), |mut acc, s| { if !acc.is_empty() { @@ -324,7 +334,7 @@ impl ActivityIndicator { ), message: format!( "Checking for updates to {}...", - checking_for_update.iter().map(|name| name.0.as_ref()).fold( + checking_for_update.iter().map(|name| name.as_ref()).fold( String::new(), |mut acc, s| { if !acc.is_empty() { @@ -354,7 +364,7 @@ impl ActivityIndicator { "Failed to run {}. Click to show error.", failed .iter() - .map(|name| name.0.as_ref()) + .map(|name| name.as_ref()) .fold(String::new(), |mut acc, s| { if !acc.is_empty() { acc.push_str(", "); diff --git a/crates/assistant_context_editor/src/context_editor.rs b/crates/assistant_context_editor/src/context_editor.rs index 72b4467015..4f74da00a8 100644 --- a/crates/assistant_context_editor/src/context_editor.rs +++ b/crates/assistant_context_editor/src/context_editor.rs @@ -229,6 +229,7 @@ impl ContextEditor { editor.set_show_git_diff_gutter(false, cx); editor.set_show_code_actions(false, cx); editor.set_show_runnables(false, cx); + editor.set_show_breakpoints(false, cx); editor.set_show_wrap_guides(false, cx); editor.set_show_indent_guides(false, cx); editor.set_completion_provider(Some(Box::new(completion_provider))); diff --git a/crates/collab/Cargo.toml b/crates/collab/Cargo.toml index 520b7b19ff..c3299a8ffd 100644 --- a/crates/collab/Cargo.toml +++ b/crates/collab/Cargo.toml @@ -89,8 +89,11 @@ channel.workspace = true client = { workspace = true, features = ["test-support"] } collab_ui = { workspace = true, features = ["test-support"] } collections = { workspace = true, features = ["test-support"] } +command_palette_hooks.workspace = true context_server.workspace = true ctor.workspace = true +dap = { workspace = true, features = ["test-support"] } +debugger_ui = { workspace = true, features = ["test-support"] } editor = { workspace = true, features = ["test-support"] } env_logger.workspace = true extension.workspace = true diff --git a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql index 30d36cfe8c..750a21818c 100644 --- a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql +++ b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql @@ -469,3 +469,14 @@ CREATE TABLE IF NOT EXISTS processed_stripe_events ( ); CREATE INDEX "ix_processed_stripe_events_on_stripe_event_created_timestamp" ON processed_stripe_events (stripe_event_created_timestamp); + +CREATE TABLE IF NOT EXISTS "breakpoints" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "project_id" INTEGER NOT NULL REFERENCES projects (id) ON DELETE CASCADE, + "position" INTEGER NOT NULL, + "log_message" TEXT NULL, + "worktree_id" BIGINT NOT NULL, + "path" TEXT NOT NULL, + "kind" VARCHAR NOT NULL +); +CREATE INDEX "index_breakpoints_on_project_id" ON "breakpoints" ("project_id"); diff --git a/crates/collab/migrations/20241121185750_add_breakpoints.sql b/crates/collab/migrations/20241121185750_add_breakpoints.sql new file mode 100644 index 0000000000..4b30714573 --- /dev/null +++ b/crates/collab/migrations/20241121185750_add_breakpoints.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS "breakpoints" ( + "id" SERIAL PRIMARY KEY, + "project_id" INTEGER NOT NULL REFERENCES projects (id) ON DELETE CASCADE, + "position" INTEGER NOT NULL, + "log_message" TEXT NULL, + "worktree_id" BIGINT NOT NULL, + "path" TEXT NOT NULL, + "kind" VARCHAR NOT NULL +); + +CREATE INDEX "index_breakpoints_on_project_id" ON "breakpoints" ("project_id"); diff --git a/crates/collab/src/db/queries/projects.rs b/crates/collab/src/db/queries/projects.rs index 1cff5b53b0..2970c9be0f 100644 --- a/crates/collab/src/db/queries/projects.rs +++ b/crates/collab/src/db/queries/projects.rs @@ -1,5 +1,5 @@ use anyhow::Context as _; - +use collections::HashSet; use util::ResultExt; use super::*; @@ -1106,41 +1106,52 @@ impl Database { exclude_dev_server: bool, ) -> Result>> { self.project_transaction(project_id, |tx| async move { - let project = project::Entity::find_by_id(project_id) - .one(&*tx) - .await? - .ok_or_else(|| anyhow!("no such project"))?; - - let mut collaborators = project_collaborator::Entity::find() - .filter(project_collaborator::Column::ProjectId.eq(project_id)) - .stream(&*tx) - .await?; - - let mut connection_ids = HashSet::default(); - if let Some(host_connection) = project.host_connection().log_err() { - if !exclude_dev_server { - connection_ids.insert(host_connection); - } - } - - while let Some(collaborator) = collaborators.next().await { - let collaborator = collaborator?; - connection_ids.insert(collaborator.connection()); - } - - if connection_ids.contains(&connection_id) - || Some(connection_id) == project.host_connection().ok() - { - Ok(connection_ids) - } else { - Err(anyhow!( - "can only send project updates to a project you're in" - ))? - } + self.internal_project_connection_ids(project_id, connection_id, exclude_dev_server, &tx) + .await }) .await } + async fn internal_project_connection_ids( + &self, + project_id: ProjectId, + connection_id: ConnectionId, + exclude_dev_server: bool, + tx: &DatabaseTransaction, + ) -> Result> { + let project = project::Entity::find_by_id(project_id) + .one(tx) + .await? + .ok_or_else(|| anyhow!("no such project"))?; + + let mut collaborators = project_collaborator::Entity::find() + .filter(project_collaborator::Column::ProjectId.eq(project_id)) + .stream(tx) + .await?; + + let mut connection_ids = HashSet::default(); + if let Some(host_connection) = project.host_connection().log_err() { + if !exclude_dev_server { + connection_ids.insert(host_connection); + } + } + + while let Some(collaborator) = collaborators.next().await { + let collaborator = collaborator?; + connection_ids.insert(collaborator.connection()); + } + + if connection_ids.contains(&connection_id) + || Some(connection_id) == project.host_connection().ok() + { + Ok(connection_ids) + } else { + Err(anyhow!( + "can only send project updates to a project you're in" + ))? + } + } + async fn project_guest_connection_ids( &self, project_id: ProjectId, diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 1d6c4bb01f..a22f5e5646 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -404,6 +404,8 @@ impl Server { .add_request_handler(forward_read_only_project_request::) .add_request_handler(forward_read_only_project_request::) .add_request_handler(forward_mutating_project_request::) + .add_request_handler(forward_mutating_project_request::) + .add_message_handler(broadcast_project_message_from_host::) .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) @@ -2064,7 +2066,7 @@ async fn update_worktree_settings( Ok(()) } -/// Notify other participants that a language server has started. +/// Notify other participants that a language server has started. async fn start_language_server( request: proto::StartLanguageServer, session: Session, diff --git a/crates/collab/src/tests.rs b/crates/collab/src/tests.rs index d8acfb5819..5ec4937168 100644 --- a/crates/collab/src/tests.rs +++ b/crates/collab/src/tests.rs @@ -11,6 +11,7 @@ mod channel_buffer_tests; mod channel_guest_tests; mod channel_message_tests; mod channel_tests; +// mod debug_panel_tests; mod editor_tests; mod following_tests; mod git_tests; diff --git a/crates/collab/src/tests/debug_panel_tests.rs b/crates/collab/src/tests/debug_panel_tests.rs new file mode 100644 index 0000000000..a8e9b745ac --- /dev/null +++ b/crates/collab/src/tests/debug_panel_tests.rs @@ -0,0 +1,2454 @@ +use call::ActiveCall; +use dap::requests::{Initialize, Launch, StackTrace}; +use dap::DebugRequestType; +use dap::{requests::SetBreakpoints, SourceBreakpoint}; +use debugger_ui::debugger_panel::DebugPanel; +use debugger_ui::session::DebugSession; +use editor::Editor; +use gpui::{Entity, TestAppContext, VisualTestContext}; +use project::{Project, ProjectPath, WorktreeId}; +use serde_json::json; +use std::sync::Arc; +use std::{ + path::Path, + sync::atomic::{AtomicBool, Ordering}, +}; +use workspace::{dock::Panel, Workspace}; + +use super::{TestClient, TestServer}; + +pub fn init_test(cx: &mut gpui::TestAppContext) { + if std::env::var("RUST_LOG").is_ok() { + env_logger::try_init().ok(); + } + + cx.update(|cx| { + theme::init(theme::LoadThemes::JustBase, cx); + command_palette_hooks::init(cx); + language::init(cx); + workspace::init_settings(cx); + project::Project::init_settings(cx); + debugger_ui::init(cx); + editor::init(cx); + }); +} + +async fn add_debugger_panel(workspace: &Entity, cx: &mut VisualTestContext) { + let debugger_panel = workspace + .update_in(cx, |_workspace, window, cx| { + cx.spawn_in(window, DebugPanel::load) + }) + .await + .unwrap(); + + workspace.update_in(cx, |workspace, window, cx| { + workspace.add_panel(debugger_panel, window, cx); + }); +} + +pub fn _active_session( + workspace: Entity, + cx: &mut VisualTestContext, +) -> Entity { + workspace.update_in(cx, |workspace, _window, cx| { + let debug_panel = workspace.panel::(cx).unwrap(); + debug_panel + .update(cx, |this, cx| this.active_session(cx)) + .unwrap() + }) +} + +struct ZedInstance<'a> { + client: TestClient, + project: Option>, + active_call: Entity, + cx: &'a mut TestAppContext, +} + +impl<'a> ZedInstance<'a> { + fn new(client: TestClient, cx: &'a mut TestAppContext) -> Self { + ZedInstance { + project: None, + client, + active_call: cx.read(ActiveCall::global), + cx, + } + } + + async fn host_project( + &mut self, + project_files: Option, + ) -> (u64, WorktreeId) { + let (project, worktree_id) = self.client.build_local_project("/project", self.cx).await; + self.active_call + .update(self.cx, |call, cx| call.set_location(Some(&project), cx)) + .await + .unwrap(); + + if let Some(tree) = project_files { + self.client.fs().insert_tree("/project", tree).await; + } + + self.project = Some(project.clone()); + + let project_id = self + .active_call + .update(self.cx, |call, cx| call.share_project(project, cx)) + .await + .unwrap(); + + (project_id, worktree_id) + } + + async fn join_project(&mut self, project_id: u64) { + let remote_project = self.client.join_remote_project(project_id, self.cx).await; + self.project = Some(remote_project); + + self.active_call + .update(self.cx, |call, cx| { + call.set_location(self.project.as_ref(), cx) + }) + .await + .unwrap(); + } + + async fn expand( + &'a mut self, + ) -> ( + &'a TestClient, + Entity, + Entity, + &'a mut VisualTestContext, + ) { + let (workspace, cx) = self.client.build_workspace( + self.project + .as_ref() + .expect("Project should be hosted or built before expanding"), + self.cx, + ); + add_debugger_panel(&workspace, cx).await; + (&self.client, workspace, self.project.clone().unwrap(), cx) + } +} + +async fn _setup_three_member_test<'a, 'b, 'c>( + server: &mut TestServer, + host_cx: &'a mut TestAppContext, + first_remote_cx: &'b mut TestAppContext, + second_remote_cx: &'c mut TestAppContext, +) -> (ZedInstance<'a>, ZedInstance<'b>, ZedInstance<'c>) { + let host_client = server.create_client(host_cx, "user_host").await; + let first_remote_client = server.create_client(first_remote_cx, "user_remote_1").await; + let second_remote_client = server + .create_client(second_remote_cx, "user_remote_2") + .await; + + init_test(host_cx); + init_test(first_remote_cx); + init_test(second_remote_cx); + + server + .create_room(&mut [ + (&host_client, host_cx), + (&first_remote_client, first_remote_cx), + (&second_remote_client, second_remote_cx), + ]) + .await; + + let host_zed = ZedInstance::new(host_client, host_cx); + let first_remote_zed = ZedInstance::new(first_remote_client, first_remote_cx); + let second_remote_zed = ZedInstance::new(second_remote_client, second_remote_cx); + + (host_zed, first_remote_zed, second_remote_zed) +} + +async fn setup_two_member_test<'a, 'b>( + server: &mut TestServer, + host_cx: &'a mut TestAppContext, + remote_cx: &'b mut TestAppContext, +) -> (ZedInstance<'a>, ZedInstance<'b>) { + let host_client = server.create_client(host_cx, "user_host").await; + let remote_client = server.create_client(remote_cx, "user_remote").await; + + init_test(host_cx); + init_test(remote_cx); + + server + .create_room(&mut [(&host_client, host_cx), (&remote_client, remote_cx)]) + .await; + + let host_zed = ZedInstance::new(host_client, host_cx); + let remote_zed = ZedInstance::new(remote_client, remote_cx); + + (host_zed, remote_zed) +} + +#[gpui::test] +async fn test_debug_panel_item_opens_on_remote( + host_cx: &mut TestAppContext, + remote_cx: &mut TestAppContext, +) { + let executor = host_cx.executor(); + let mut server = TestServer::start(executor).await; + + let (mut host_zed, mut remote_zed) = + setup_two_member_test(&mut server, host_cx, remote_cx).await; + + let (host_project_id, _) = host_zed.host_project(None).await; + remote_zed.join_project(host_project_id).await; + + let (_client_host, _host_workspace, host_project, host_cx) = host_zed.expand().await; + let (_client_remote, remote_workspace, _remote_project, remote_cx) = remote_zed.expand().await; + + remote_cx.run_until_parked(); + + let task = host_project.update(host_cx, |project, cx| { + project.start_debug_session(dap::test_config(DebugRequestType::Launch, None, None), cx) + }); + + let session = task.await.unwrap(); + let client = session.read_with(host_cx, |project, _| project.adapter_client().unwrap()); + + client + .on_request::(move |_, _| { + Ok(dap::Capabilities { + supports_step_back: Some(false), + ..Default::default() + }) + }) + .await; + + client.on_request::(move |_, _| Ok(())).await; + + client + .on_request::(move |_, _| { + Ok(dap::StackTraceResponse { + stack_frames: Vec::default(), + total_frames: None, + }) + }) + .await; + + client + .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { + reason: dap::StoppedEventReason::Pause, + description: None, + thread_id: Some(1), + preserve_focus_hint: None, + text: None, + all_threads_stopped: None, + hit_breakpoint_ids: None, + })) + .await; + + host_cx.run_until_parked(); + remote_cx.run_until_parked(); + + remote_workspace.update(remote_cx, |workspace, cx| { + let debug_panel = workspace.panel::(cx).unwrap(); + let _active_session = debug_panel + .update(cx, |this, cx| this.active_session(cx)) + .unwrap(); + + assert_eq!( + 1, + debug_panel.update(cx, |this, cx| this.pane().unwrap().read(cx).items_len()) + ); + // assert_eq!(client.id(), active_session.read(cx).()); + // assert_eq!(1, active_session.read(cx).thread_id().0); + // todo(debugger) check selected thread id + }); + + let shutdown_client = host_project.update(host_cx, |project, cx| { + project.dap_store().update(cx, |dap_store, cx| { + dap_store.shutdown_session(session.read(cx).session_id(), cx) + }) + }); + + shutdown_client.await.unwrap(); +} + +#[gpui::test] +async fn test_active_debug_panel_item_set_on_join_project( + host_cx: &mut TestAppContext, + remote_cx: &mut TestAppContext, +) { + let executor = host_cx.executor(); + let mut server = TestServer::start(executor).await; + + let (mut host_zed, mut remote_zed) = + setup_two_member_test(&mut server, host_cx, remote_cx).await; + + let (host_project_id, _) = host_zed.host_project(None).await; + + let (_client_host, _host_workspace, host_project, host_cx) = host_zed.expand().await; + + host_cx.run_until_parked(); + + let task = host_project.update(host_cx, |project, cx| { + project.start_debug_session(dap::test_config(DebugRequestType::Launch, None, None), cx) + }); + + let session = task.await.unwrap(); + let client = session.read_with(host_cx, |project, _| project.adapter_client().unwrap()); + + client + .on_request::(move |_, _| { + Ok(dap::Capabilities { + supports_step_back: Some(false), + ..Default::default() + }) + }) + .await; + + client.on_request::(move |_, _| Ok(())).await; + + client + .on_request::(move |_, _| { + Ok(dap::StackTraceResponse { + stack_frames: Vec::default(), + total_frames: None, + }) + }) + .await; + + client + .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { + reason: dap::StoppedEventReason::Pause, + description: None, + thread_id: Some(1), + preserve_focus_hint: None, + text: None, + all_threads_stopped: None, + hit_breakpoint_ids: None, + })) + .await; + + // Give host_client time to send a debug panel item to collab server + host_cx.run_until_parked(); + + remote_zed.join_project(host_project_id).await; + let (_client_remote, remote_workspace, _remote_project, remote_cx) = remote_zed.expand().await; + + host_cx.run_until_parked(); + remote_cx.run_until_parked(); + + remote_workspace.update(remote_cx, |workspace, cx| { + let debug_panel = workspace.panel::(cx).unwrap(); + let _active_session = debug_panel + .update(cx, |this, cx| this.active_session(cx)) + .unwrap(); + + assert_eq!( + 1, + debug_panel.update(cx, |this, cx| this.pane().unwrap().read(cx).items_len()) + ); + // assert_eq!(cl, active_session.read(cx).client_id()); + // assert_eq!(1, active_session.read(cx).thread_id().0); + // todo(debugger) + }); + + let shutdown_client = host_project.update(host_cx, |project, cx| { + project.dap_store().update(cx, |dap_store, cx| { + dap_store.shutdown_session(session.read(cx).session_id(), cx) + }) + }); + + shutdown_client.await.unwrap(); + + remote_cx.run_until_parked(); + + // assert we don't have a debug panel item anymore because the client shutdown + remote_workspace.update(remote_cx, |workspace, cx| { + let debug_panel = workspace.panel::(cx).unwrap(); + + debug_panel.update(cx, |this, cx| { + assert!(this.active_session(cx).is_none()); + assert_eq!(0, this.pane().unwrap().read(cx).items_len()); + }); + }); +} + +#[gpui::test] +async fn test_debug_panel_remote_button_presses( + _host_cx: &mut TestAppContext, + _remote_cx: &mut TestAppContext, +) { + unimplemented!("Collab is still being refactored"); + // let executor = host_cx.executor(); + // let mut server = TestServer::start(executor).await; + + // let (mut host_zed, mut remote_zed) = + // setup_two_member_test(&mut server, host_cx, remote_cx).await; + + // let (host_project_id, _) = host_zed.host_project(None).await; + // remote_zed.join_project(host_project_id).await; + + // let (_client_host, host_workspace, host_project, host_cx) = host_zed.expand().await; + // let (_client_remote, remote_workspace, _remote_project, remote_cx) = remote_zed.expand().await; + + // let task = host_project.update(host_cx, |project, cx| { + // project.start_debug_session(dap::test_config(None), cx) + // }); + + // let session = task.await.unwrap(); + // let client = session.read_with(host_cx, |project, _| project.adapter_client().unwrap()); + + // client + // .on_request::(move |_, _| { + // Ok(dap::Capabilities { + // supports_step_back: Some(true), + // ..Default::default() + // }) + // }) + // .await; + + // client.on_request::(move |_, _| Ok(())).await; + + // client + // .on_request::(move |_, _| { + // Ok(dap::StackTraceResponse { + // stack_frames: Vec::default(), + // total_frames: None, + // }) + // }) + // .await; + + // client + // .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { + // reason: dap::StoppedEventReason::Pause, + // description: None, + // thread_id: Some(1), + // preserve_focus_hint: None, + // text: None, + // all_threads_stopped: None, + // hit_breakpoint_ids: None, + // })) + // .await; + + // client + // .on_request::(move |_, _| { + // Ok(dap::ContinueResponse { + // all_threads_continued: Some(true), + // }) + // }) + // .await; + + // host_cx.run_until_parked(); + // remote_cx.run_until_parked(); + + // let remote_debug_item = remote_workspace.update(remote_cx, |workspace, cx| { + // let debug_panel = workspace.panel::(cx).unwrap(); + // let active_session = debug_panel + // .update(cx, |this, cx| this.active_session(cx)) + // .unwrap(); + + // assert_eq!( + // 1, + // debug_panel.update(cx, |this, cx| this.pane().unwrap().read(cx).items_len()) + // ); + // // assert_eq!(client.id(), active_session.read(cx).client_id()); + // // assert_eq!(1, active_session.read(cx).thread_id().0); + // // todo(debugger) + // active_session + // }); + + // let local_debug_item = host_workspace.update(host_cx, |workspace, cx| { + // let debug_panel = workspace.panel::(cx).unwrap(); + // let active_session = debug_panel + // .update(cx, |this, cx| this.active_session(cx)) + // .unwrap(); + + // assert_eq!( + // 1, + // debug_panel.update(cx, |this, cx| this.pane().unwrap().read(cx).items_len()) + // ); + // // assert_eq!(client.id(), active_session.read(cx).client_id()); + // // assert_eq!(1, active_session.read(cx).thread_id().0); + // // todo(debugger) + // active_session + // }); + + // remote_debug_item.update(remote_cx, |this, cx| { + // this.continue_thread(cx); + // }); + + // host_cx.run_until_parked(); + // remote_cx.run_until_parked(); + + // local_debug_item.update(host_cx, |debug_panel_item, cx| { + // assert_eq!( + // debugger_ui::debugger_panel::ThreadStatus::Running, + // debug_panel_item.thread_state().read(cx).status, + // ); + // }); + + // remote_debug_item.update(remote_cx, |debug_panel_item, cx| { + // assert_eq!( + // debugger_ui::debugger_panel::ThreadStatus::Running, + // debug_panel_item.thread_state().read(cx).status, + // ); + // }); + + // client + // .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { + // reason: dap::StoppedEventReason::Pause, + // description: None, + // thread_id: Some(1), + // preserve_focus_hint: None, + // text: None, + // all_threads_stopped: None, + // hit_breakpoint_ids: None, + // })) + // .await; + + // client + // .on_request::(move |_, _| { + // Ok(dap::StackTraceResponse { + // stack_frames: Vec::default(), + // total_frames: None, + // }) + // }) + // .await; + + // host_cx.run_until_parked(); + // remote_cx.run_until_parked(); + + // local_debug_item.update(host_cx, |debug_panel_item, cx| { + // assert_eq!( + // debugger_ui::debugger_panel::ThreadStatus::Stopped, + // debug_panel_item.thread_state().read(cx).status, + // ); + // }); + + // remote_debug_item.update(remote_cx, |debug_panel_item, cx| { + // assert_eq!( + // debugger_ui::debugger_panel::ThreadStatus::Stopped, + // debug_panel_item.thread_state().read(cx).status, + // ); + // }); + + // client + // .on_request::(move |_, _| { + // Ok(dap::ContinueResponse { + // all_threads_continued: Some(true), + // }) + // }) + // .await; + + // local_debug_item.update(host_cx, |this, cx| { + // this.continue_thread(cx); + // }); + + // host_cx.run_until_parked(); + // remote_cx.run_until_parked(); + + // local_debug_item.update(host_cx, |debug_panel_item, cx| { + // assert_eq!( + // debugger_ui::debugger_panel::ThreadStatus::Running, + // debug_panel_item.thread_state().read(cx).status, + // ); + // }); + + // remote_debug_item.update(remote_cx, |debug_panel_item, cx| { + // assert_eq!( + // debugger_ui::debugger_panel::ThreadStatus::Running, + // debug_panel_item.thread_state().read(cx).status, + // ); + // }); + + // client + // .on_request::(move |_, _| Ok(())) + // .await; + + // client + // .on_request::(move |_, _| { + // Ok(dap::StackTraceResponse { + // stack_frames: Vec::default(), + // total_frames: None, + // }) + // }) + // .await; + + // client + // .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { + // reason: dap::StoppedEventReason::Pause, + // description: None, + // thread_id: Some(1), + // preserve_focus_hint: None, + // text: None, + // all_threads_stopped: None, + // hit_breakpoint_ids: None, + // })) + // .await; + + // remote_debug_item.update(remote_cx, |this, cx| { + // this.pause_thread(cx); + // }); + + // remote_cx.run_until_parked(); + // host_cx.run_until_parked(); + + // client + // .on_request::(move |_, _| Ok(())) + // .await; + + // remote_debug_item.update(remote_cx, |this, cx| { + // this.step_out(cx); + // }); + + // client + // .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { + // reason: dap::StoppedEventReason::Pause, + // description: None, + // thread_id: Some(1), + // preserve_focus_hint: None, + // text: None, + // all_threads_stopped: None, + // hit_breakpoint_ids: None, + // })) + // .await; + + // remote_cx.run_until_parked(); + // host_cx.run_until_parked(); + + // client + // .on_request::(move |_, _| Ok(())) + // .await; + + // remote_debug_item.update(remote_cx, |this, cx| { + // this.step_over(cx); + // }); + + // client + // .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { + // reason: dap::StoppedEventReason::Pause, + // description: None, + // thread_id: Some(1), + // preserve_focus_hint: None, + // text: None, + // all_threads_stopped: None, + // hit_breakpoint_ids: None, + // })) + // .await; + + // remote_cx.run_until_parked(); + // host_cx.run_until_parked(); + + // client + // .on_request::(move |_, _| Ok(())) + // .await; + + // remote_debug_item.update(remote_cx, |this, cx| { + // this.step_in(cx); + // }); + + // client + // .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { + // reason: dap::StoppedEventReason::Pause, + // description: None, + // thread_id: Some(1), + // preserve_focus_hint: None, + // text: None, + // all_threads_stopped: None, + // hit_breakpoint_ids: None, + // })) + // .await; + + // remote_cx.run_until_parked(); + // host_cx.run_until_parked(); + + // client + // .on_request::(move |_, _| Ok(())) + // .await; + + // remote_debug_item.update(remote_cx, |this, cx| { + // this.step_back(cx); + // }); + + // client + // .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { + // reason: dap::StoppedEventReason::Pause, + // description: None, + // thread_id: Some(1), + // preserve_focus_hint: None, + // text: None, + // all_threads_stopped: None, + // hit_breakpoint_ids: None, + // })) + // .await; + + // remote_cx.run_until_parked(); + // host_cx.run_until_parked(); + + // remote_debug_item.update(remote_cx, |this, cx| { + // this.stop_thread(cx); + // }); + + // host_cx.run_until_parked(); + // remote_cx.run_until_parked(); + + // // assert we don't have a debug panel item anymore because the client shutdown + // remote_workspace.update(remote_cx, |workspace, cx| { + // let debug_panel = workspace.panel::(cx).unwrap(); + + // debug_panel.update(cx, |this, cx| { + // assert!(this.active_session(cx).is_none()); + // assert_eq!(0, this.pane().unwrap().read(cx).items_len()); + // }); + // }); +} + +#[gpui::test] +async fn test_restart_stack_frame(_host_cx: &mut TestAppContext, _remote_cx: &mut TestAppContext) { + unimplemented!("Collab is still being refactored"); + // let executor = host_cx.executor(); + // let mut server = TestServer::start(executor).await; + + // let (mut host_zed, mut remote_zed) = + // setup_two_member_test(&mut server, host_cx, remote_cx).await; + + // let (host_project_id, _) = host_zed.host_project(None).await; + // remote_zed.join_project(host_project_id).await; + + // let (_client_host, _host_workspace, host_project, host_cx) = host_zed.expand().await; + // let (_client_remote, remote_workspace, _remote_project, remote_cx) = remote_zed.expand().await; + + // let called_restart_frame = Arc::new(AtomicBool::new(false)); + + // let task = host_project.update(host_cx, |project, cx| { + // project.start_debug_session(dap::test_config(None), cx) + // }); + + // let session = task.await.unwrap(); + // let client = session.read(cx).adapter_client().unwrap(); + + // client + // .on_request::(move |_, _| { + // Ok(dap::Capabilities { + // supports_restart_frame: Some(true), + // ..Default::default() + // }) + // }) + // .await; + + // client.on_request::(move |_, _| Ok(())).await; + + // let stack_frames = vec![StackFrame { + // id: 1, + // name: "Stack Frame 1".into(), + // source: Some(dap::Source { + // name: Some("test.js".into()), + // path: Some("/project/src/test.js".into()), + // source_reference: None, + // presentation_hint: None, + // origin: None, + // sources: None, + // adapter_data: None, + // checksums: None, + // }), + // line: 3, + // column: 1, + // end_line: None, + // end_column: None, + // can_restart: None, + // instruction_pointer_reference: None, + // module_id: None, + // presentation_hint: None, + // }]; + + // client + // .on_request::({ + // let stack_frames = Arc::new(stack_frames.clone()); + // move |_, args| { + // assert_eq!(1, args.thread_id); + + // Ok(dap::StackTraceResponse { + // stack_frames: (*stack_frames).clone(), + // total_frames: None, + // }) + // } + // }) + // .await; + + // client + // .on_request::({ + // let called_restart_frame = called_restart_frame.clone(); + // move |_, args| { + // assert_eq!(1, args.frame_id); + + // called_restart_frame.store(true, Ordering::SeqCst); + + // Ok(()) + // } + // }) + // .await; + + // client + // .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { + // reason: dap::StoppedEventReason::Pause, + // description: None, + // thread_id: Some(1), + // preserve_focus_hint: None, + // text: None, + // all_threads_stopped: None, + // hit_breakpoint_ids: None, + // })) + // .await; + + // host_cx.run_until_parked(); + // remote_cx.run_until_parked(); + + // // try to restart stack frame 1 from the guest side + // remote_workspace.update(remote_cx, |workspace, cx| { + // let debug_panel = workspace.panel::(cx).unwrap(); + // let active_session = debug_panel + // .update(cx, |this, cx| this.active_session(cx)) + // .unwrap(); + + // active_session.update(cx, |debug_panel_item, cx| { + // debug_panel_item + // .stack_frame_list() + // .update(cx, |stack_frame_list, cx| { + // stack_frame_list.restart_stack_frame(1, cx); + // }); + // }); + // }); + + // host_cx.run_until_parked(); + // remote_cx.run_until_parked(); + + // assert!( + // called_restart_frame.load(std::sync::atomic::Ordering::SeqCst), + // "Restart stack frame was not called" + // ); + + // let shutdown_client = host_project.update(host_cx, |project, cx| { + // project.dap_store().update(cx, |dap_store, cx| { + // dap_store.shutdown_session(&session.read(cx).session_id(), cx) + // }) + // }); + + // shutdown_client.await.unwrap(); +} + +#[gpui::test] +async fn test_updated_breakpoints_send_to_dap( + host_cx: &mut TestAppContext, + remote_cx: &mut TestAppContext, +) { + let executor = host_cx.executor(); + let mut server = TestServer::start(executor).await; + + let (mut host_zed, mut remote_zed) = + setup_two_member_test(&mut server, host_cx, remote_cx).await; + + let (host_project_id, worktree_id) = host_zed + .host_project(Some(json!({"test.txt": "one\ntwo\nthree\nfour\nfive"}))) + .await; + + remote_zed.join_project(host_project_id).await; + + let (_client_host, host_workspace, host_project, host_cx) = host_zed.expand().await; + let (_client_remote, remote_workspace, _remote_project, remote_cx) = remote_zed.expand().await; + + let project_path = ProjectPath { + worktree_id, + path: Arc::from(Path::new(&"test.txt")), + }; + + let task = host_project.update(host_cx, |project, cx| { + project.start_debug_session(dap::test_config(DebugRequestType::Launch, None, None), cx) + }); + + let session = task.await.unwrap(); + let client = session.read_with(host_cx, |project, _| project.adapter_client().unwrap()); + + client + .on_request::(move |_, _| { + Ok(dap::Capabilities { + supports_restart_frame: Some(true), + ..Default::default() + }) + }) + .await; + + client.on_request::(move |_, _| Ok(())).await; + client + .on_request::(move |_, _| { + Ok(dap::StackTraceResponse { + stack_frames: Vec::default(), + total_frames: None, + }) + }) + .await; + + let called_set_breakpoints = Arc::new(AtomicBool::new(false)); + client + .on_request::({ + let called_set_breakpoints = called_set_breakpoints.clone(); + move |_, args| { + assert_eq!("/project/test.txt", args.source.path.unwrap()); + assert_eq!( + vec![SourceBreakpoint { + line: 3, + column: None, + condition: None, + hit_condition: None, + log_message: None, + mode: None + }], + args.breakpoints.unwrap() + ); + // assert!(!args.source_modified.unwrap()); + // todo(debugger): Implement source_modified handling + + called_set_breakpoints.store(true, Ordering::SeqCst); + + Ok(dap::SetBreakpointsResponse { + breakpoints: Vec::default(), + }) + } + }) + .await; + + client + .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { + reason: dap::StoppedEventReason::Pause, + description: None, + thread_id: Some(1), + preserve_focus_hint: None, + text: None, + all_threads_stopped: None, + hit_breakpoint_ids: None, + })) + .await; + + host_cx.run_until_parked(); + remote_cx.run_until_parked(); + + // Client B opens an editor. + let editor_b = remote_workspace + .update_in(remote_cx, |workspace, window, cx| { + workspace.open_path(project_path.clone(), None, true, window, cx) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + + editor_b.update_in(remote_cx, |editor, window, cx| { + editor.move_down(&editor::actions::MoveDown, window, cx); + editor.move_down(&editor::actions::MoveDown, window, cx); + editor.toggle_breakpoint(&editor::actions::ToggleBreakpoint, window, cx); + }); + + // Client A opens an editor. + let editor_a = host_workspace + .update_in(host_cx, |workspace, window, cx| { + workspace.open_path(project_path.clone(), None, true, window, cx) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + + host_cx.run_until_parked(); + remote_cx.run_until_parked(); + + let called_set_breakpoints = Arc::new(AtomicBool::new(false)); + client + .on_request::({ + let called_set_breakpoints = called_set_breakpoints.clone(); + move |_, args| { + assert_eq!("/project/test.txt", args.source.path.unwrap()); + assert!(args.breakpoints.unwrap().is_empty()); + // assert!(!args.source_modified.unwrap()); + // todo(debugger) Implement source modified support + + called_set_breakpoints.store(true, Ordering::SeqCst); + + Ok(dap::SetBreakpointsResponse { + breakpoints: Vec::default(), + }) + } + }) + .await; + + // remove the breakpoint that client B added + editor_a.update_in(host_cx, |editor, window, cx| { + editor.move_down(&editor::actions::MoveDown, window, cx); + editor.move_down(&editor::actions::MoveDown, window, cx); + editor.toggle_breakpoint(&editor::actions::ToggleBreakpoint, window, cx); + }); + + host_cx.run_until_parked(); + remote_cx.run_until_parked(); + + assert!( + called_set_breakpoints.load(std::sync::atomic::Ordering::SeqCst), + "SetBreakpoint request must be called" + ); + + let called_set_breakpoints = Arc::new(AtomicBool::new(false)); + client + .on_request::({ + let called_set_breakpoints = called_set_breakpoints.clone(); + move |_, args| { + assert_eq!("/project/test.txt", args.source.path.unwrap()); + let mut breakpoints = args.breakpoints.unwrap(); + breakpoints.sort_by_key(|b| b.line); + assert_eq!( + vec![ + SourceBreakpoint { + line: 2, + column: None, + condition: None, + hit_condition: None, + log_message: None, + mode: None + }, + SourceBreakpoint { + line: 3, + column: None, + condition: None, + hit_condition: None, + log_message: None, + mode: None + } + ], + breakpoints + ); + // assert!(!args.source_modified.unwrap()); + // todo(debugger) Implement source modified support + + called_set_breakpoints.store(true, Ordering::SeqCst); + + Ok(dap::SetBreakpointsResponse { + breakpoints: Vec::default(), + }) + } + }) + .await; + + // Add our own breakpoint now + editor_a.update_in(host_cx, |editor, window, cx| { + editor.toggle_breakpoint(&editor::actions::ToggleBreakpoint, window, cx); + editor.move_up(&editor::actions::MoveUp, window, cx); + editor.toggle_breakpoint(&editor::actions::ToggleBreakpoint, window, cx); + }); + + host_cx.run_until_parked(); + remote_cx.run_until_parked(); + + assert!( + called_set_breakpoints.load(std::sync::atomic::Ordering::SeqCst), + "SetBreakpoint request must be called" + ); + + let shutdown_client = host_project.update(host_cx, |project, cx| { + project.dap_store().update(cx, |dap_store, cx| { + dap_store.shutdown_session(session.read(cx).session_id(), cx) + }) + }); + + shutdown_client.await.unwrap(); +} + +#[gpui::test] +async fn test_module_list( + _host_cx: &mut TestAppContext, + _remote_cx: &mut TestAppContext, + _late_join_cx: &mut TestAppContext, +) { + unimplemented!("Collab is still being refactored"); + // let executor = host_cx.executor(); + // let mut server = TestServer::start(executor).await; + + // let (mut host_zed, mut remote_zed, mut late_join_zed) = + // setup_three_member_test(&mut server, host_cx, remote_cx, late_join_cx).await; + + // let (host_project_id, _worktree_id) = host_zed.host_project(None).await; + + // remote_zed.join_project(host_project_id).await; + + // let (_client_host, host_workspace, host_project, host_cx) = host_zed.expand().await; + // let (_client_remote, remote_workspace, _remote_project, remote_cx) = remote_zed.expand().await; + + // let task = host_project.update(host_cx, |project, cx| { + // project.start_debug_session(dap::test_config(None), cx) + // }); + + // let session = task.await.unwrap(); + // let client = session.read_with(host_cx, |project, _| project.adapter_client().unwrap()); + + // let called_initialize = Arc::new(AtomicBool::new(false)); + + // client + // .on_request::({ + // let called_initialize = called_initialize.clone(); + // move |_, _| { + // called_initialize.store(true, Ordering::SeqCst); + // Ok(dap::Capabilities { + // supports_restart_frame: Some(true), + // supports_modules_request: Some(true), + // ..Default::default() + // }) + // } + // }) + // .await; + + // client.on_request::(move |_, _| Ok(())).await; + // client + // .on_request::(move |_, _| { + // Ok(dap::StackTraceResponse { + // stack_frames: Vec::default(), + // total_frames: None, + // }) + // }) + // .await; + + // let called_modules = Arc::new(AtomicBool::new(false)); + // let modules = vec![ + // dap::Module { + // id: dap::ModuleId::Number(1), + // name: "First Module".into(), + // address_range: None, + // date_time_stamp: None, + // path: None, + // symbol_file_path: None, + // symbol_status: None, + // version: None, + // is_optimized: None, + // is_user_code: None, + // }, + // dap::Module { + // id: dap::ModuleId::Number(2), + // name: "Second Module".into(), + // address_range: None, + // date_time_stamp: None, + // path: None, + // symbol_file_path: None, + // symbol_status: None, + // version: None, + // is_optimized: None, + // is_user_code: None, + // }, + // ]; + + // client + // .on_request::({ + // let called_modules = called_modules.clone(); + // let modules = modules.clone(); + // move |_, _| unsafe { + // static mut REQUEST_COUNT: i32 = 1; + // assert_eq!( + // 1, REQUEST_COUNT, + // "This request should only be called once from the host" + // ); + // REQUEST_COUNT += 1; + // called_modules.store(true, Ordering::SeqCst); + + // Ok(dap::ModulesResponse { + // modules: modules.clone(), + // total_modules: Some(2u64), + // }) + // } + // }) + // .await; + + // host_cx.run_until_parked(); + // remote_cx.run_until_parked(); + + // assert!( + // called_initialize.load(std::sync::atomic::Ordering::SeqCst), + // "Request Initialize must be called" + // ); + + // client + // .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { + // reason: dap::StoppedEventReason::Pause, + // description: None, + // thread_id: Some(1), + // preserve_focus_hint: None, + // text: None, + // all_threads_stopped: None, + // hit_breakpoint_ids: None, + // })) + // .await; + + // host_cx.run_until_parked(); + // remote_cx.run_until_parked(); + + // assert!( + // called_modules.load(std::sync::atomic::Ordering::SeqCst), + // "Request Modules must be called" + // ); + + // host_workspace.update(host_cx, |workspace, cx| { + // let debug_panel = workspace.panel::(cx).unwrap(); + // let debug_panel_item = debug_panel + // .update(cx, |this, cx| this.active_session(cx)) + // .unwrap(); + + // debug_panel_item.update(cx, |item, cx| { + // assert_eq!( + // true, + // item.capabilities(cx).supports_modules_request.unwrap(), + // "Local supports modules request should be true" + // ); + + // let local_module_list = item.module_list().update(cx, |list, cx| list.modules(cx)); + + // assert_eq!( + // 2usize, + // local_module_list.len(), + // "Local module list should have two items in it" + // ); + // assert_eq!( + // modules.clone(), + // local_module_list, + // "Local module list should match module list from response" + // ); + // }) + // }); + + // remote_workspace.update(remote_cx, |workspace, cx| { + // let debug_panel = workspace.panel::(cx).unwrap(); + // let debug_panel_item = debug_panel + // .update(cx, |this, cx| this.active_session(cx)) + // .unwrap(); + + // debug_panel_item.update(cx, |item, cx| { + // assert_eq!( + // true, + // item.capabilities(cx).supports_modules_request.unwrap(), + // "Remote capabilities supports modules request should be true" + // ); + // let remote_module_list = item.module_list().update(cx, |list, cx| list.modules(cx)); + + // assert_eq!( + // 2usize, + // remote_module_list.len(), + // "Remote module list should have two items in it" + // ); + // assert_eq!( + // modules.clone(), + // remote_module_list, + // "Remote module list should match module list from response" + // ); + // }) + // }); + + // late_join_zed.join_project(host_project_id).await; + // let (_late_join_client, late_join_workspace, _late_join_project, late_join_cx) = + // late_join_zed.expand().await; + + // late_join_workspace.update(late_join_cx, |workspace, cx| { + // let debug_panel = workspace.panel::(cx).unwrap(); + // let debug_panel_item = debug_panel + // .update(cx, |this, cx| this.active_session(cx)) + // .unwrap(); + + // debug_panel_item.update(cx, |item, cx| { + // assert_eq!( + // true, + // item.capabilities(cx).supports_modules_request.unwrap(), + // "Remote (mid session join) capabilities supports modules request should be true" + // ); + // let remote_module_list = item.module_list().update(cx, |list, cx| list.modules(cx)); + + // assert_eq!( + // 2usize, + // remote_module_list.len(), + // "Remote (mid session join) module list should have two items in it" + // ); + // assert_eq!( + // modules.clone(), + // remote_module_list, + // "Remote (mid session join) module list should match module list from response" + // ); + // }) + // }); + + // let shutdown_client = host_project.update(host_cx, |project, cx| { + // project.dap_store().update(cx, |dap_store, cx| { + // dap_store.shutdown_session(&session.read(cx).id(), cx) + // }) + // }); + + // shutdown_client.await.unwrap(); +} + +// #[gpui::test] +// async fn test_variable_list( +// host_cx: &mut TestAppContext, +// remote_cx: &mut TestAppContext, +// late_join_cx: &mut TestAppContext, +// ) { +// let executor = host_cx.executor(); +// let mut server = TestServer::start(executor).await; + +// let (mut host_zed, mut remote_zed, mut late_join_zed) = +// setup_three_member_test(&mut server, host_cx, remote_cx, late_join_cx).await; + +// let (host_project_id, _worktree_id) = host_zed +// .host_project(Some(json!({"test.txt": "one\ntwo\nthree\nfour\nfive"}))) +// .await; + +// remote_zed.join_project(host_project_id).await; + +// let (_client_host, host_workspace, host_project, host_cx) = host_zed.expand().await; +// let (_client_remote, remote_workspace, _remote_project, remote_cx) = remote_zed.expand().await; + +// let task = host_project.update(host_cx, |project, cx| { +// project.start_debug_session( +// dap::DebugAdapterConfig { +// label: "test config".into(), +// kind: dap::DebugAdapterKind::Fake, +// request: dap::DebugRequestType::Launch, +// program: None, +// cwd: None, +// initialize_args: None, +// }, +// cx, +// ) +// }); + +// let (session, client) = task.await.unwrap(); + +// client +// .on_request::(move |_, _| { +// Ok(dap::Capabilities { +// supports_step_back: Some(true), +// ..Default::default() +// }) +// }) +// .await; + +// client.on_request::(move |_, _| Ok(())).await; + +// let stack_frames = vec![dap::StackFrame { +// id: 1, +// name: "Stack Frame 1".into(), +// source: Some(dap::Source { +// name: Some("test.js".into()), +// path: Some("/project/src/test.js".into()), +// source_reference: None, +// presentation_hint: None, +// origin: None, +// sources: None, +// adapter_data: None, +// checksums: None, +// }), +// line: 1, +// column: 1, +// end_line: None, +// end_column: None, +// can_restart: None, +// instruction_pointer_reference: None, +// module_id: None, +// presentation_hint: None, +// }]; + +// let scopes = vec![Scope { +// name: "Scope 1".into(), +// presentation_hint: None, +// variables_reference: 1, +// named_variables: None, +// indexed_variables: None, +// expensive: false, +// source: None, +// line: None, +// column: None, +// end_line: None, +// end_column: None, +// }]; + +// let variable_1 = Variable { +// name: "variable 1".into(), +// value: "1".into(), +// type_: None, +// presentation_hint: None, +// evaluate_name: None, +// variables_reference: 2, +// named_variables: None, +// indexed_variables: None, +// memory_reference: None, +// }; + +// let variable_2 = Variable { +// name: "variable 2".into(), +// value: "2".into(), +// type_: None, +// presentation_hint: None, +// evaluate_name: None, +// variables_reference: 3, +// named_variables: None, +// indexed_variables: None, +// memory_reference: None, +// }; + +// let variable_3 = Variable { +// name: "variable 3".into(), +// value: "hello world".into(), +// type_: None, +// presentation_hint: None, +// evaluate_name: None, +// variables_reference: 4, +// named_variables: None, +// indexed_variables: None, +// memory_reference: None, +// }; + +// let variable_4 = Variable { +// name: "variable 4".into(), +// value: "hello world this is the final variable".into(), +// type_: None, +// presentation_hint: None, +// evaluate_name: None, +// variables_reference: 0, +// named_variables: None, +// indexed_variables: None, +// memory_reference: None, +// }; + +// client +// .on_request::({ +// let stack_frames = std::sync::Arc::new(stack_frames.clone()); +// move |_, args| { +// assert_eq!(1, args.thread_id); + +// Ok(dap::StackTraceResponse { +// stack_frames: (*stack_frames).clone(), +// total_frames: None, +// }) +// } +// }) +// .await; + +// client +// .on_request::({ +// let scopes = Arc::new(scopes.clone()); +// move |_, args| { +// assert_eq!(1, args.frame_id); + +// Ok(dap::ScopesResponse { +// scopes: (*scopes).clone(), +// }) +// } +// }) +// .await; + +// let first_variable_request = vec![variable_1.clone(), variable_2.clone()]; + +// client +// .on_request::({ +// move |_, args| { +// assert_eq!(1, args.variables_reference); + +// Ok(dap::VariablesResponse { +// variables: first_variable_request.clone(), +// }) +// } +// }) +// .await; + +// client +// .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { +// reason: dap::StoppedEventReason::Pause, +// description: None, +// thread_id: Some(1), +// preserve_focus_hint: None, +// text: None, +// all_threads_stopped: None, +// hit_breakpoint_ids: None, +// })) +// .await; + +// host_cx.run_until_parked(); +// remote_cx.run_until_parked(); + +// let local_debug_item = host_workspace.update(host_cx, |workspace, cx| { +// let debug_panel = workspace.panel::(cx).unwrap(); +// let active_debug_panel_item = debug_panel +// .update(cx, |this, cx| this.active_debug_panel_item(cx)) +// .unwrap(); + +// assert_eq!( +// 1, +// debug_panel.update(cx, |this, cx| this.pane().unwrap().read(cx).items_len()) +// ); +// assert_eq!(client.id(), active_debug_panel_item.read(cx).client_id()); +// assert_eq!(1, active_debug_panel_item.read(cx).thread_id()); +// active_debug_panel_item +// }); + +// let remote_debug_item = remote_workspace.update(remote_cx, |workspace, cx| { +// let debug_panel = workspace.panel::(cx).unwrap(); +// let active_debug_panel_item = debug_panel +// .update(cx, |this, cx| this.active_debug_panel_item(cx)) +// .unwrap(); + +// assert_eq!( +// 1, +// debug_panel.update(cx, |this, cx| this.pane().unwrap().read(cx).items_len()) +// ); +// assert_eq!(client.id(), active_debug_panel_item.read(cx).client_id()); +// assert_eq!(1, active_debug_panel_item.read(cx).thread_id()); +// active_debug_panel_item +// }); + +// let first_visual_entries = vec!["v Scope 1", " > variable 1", " > variable 2"]; +// let first_variable_containers = vec![ +// VariableContainer { +// container_reference: scopes[0].variables_reference, +// variable: variable_1.clone(), +// depth: 1, +// }, +// VariableContainer { +// container_reference: scopes[0].variables_reference, +// variable: variable_2.clone(), +// depth: 1, +// }, +// ]; + +// local_debug_item +// .update(host_cx, |this, _| this.variable_list().clone()) +// .update(host_cx, |variable_list, cx| { +// assert_eq!(1, variable_list.scopes().len()); +// assert_eq!(scopes, variable_list.scopes().get(&1).unwrap().clone()); +// assert_eq!(&first_variable_containers, &variable_list.variables()); + +// variable_list.assert_visual_entries(first_visual_entries.clone(), cx); +// }); + +// client +// .on_request::({ +// let variables = Arc::new(vec![variable_3.clone()]); +// move |_, args| { +// assert_eq!(2, args.variables_reference); + +// Ok(dap::VariablesResponse { +// variables: (*variables).clone(), +// }) +// } +// }) +// .await; + +// remote_debug_item +// .update(remote_cx, |this, _| this.variable_list().clone()) +// .update(remote_cx, |variable_list, cx| { +// assert_eq!(1, variable_list.scopes().len()); +// assert_eq!(scopes, variable_list.scopes().get(&1).unwrap().clone()); +// assert_eq!(&first_variable_containers, &variable_list.variables()); + +// variable_list.assert_visual_entries(first_visual_entries.clone(), cx); + +// variable_list.toggle_variable(&scopes[0], &variable_1, 1, cx); +// }); + +// host_cx.run_until_parked(); +// remote_cx.run_until_parked(); + +// let second_req_variable_list = vec![ +// VariableContainer { +// container_reference: scopes[0].variables_reference, +// variable: variable_1.clone(), +// depth: 1, +// }, +// VariableContainer { +// container_reference: variable_1.variables_reference, +// variable: variable_3.clone(), +// depth: 2, +// }, +// VariableContainer { +// container_reference: scopes[0].variables_reference, +// variable: variable_2.clone(), +// depth: 1, +// }, +// ]; + +// remote_debug_item +// .update(remote_cx, |this, _| this.variable_list().clone()) +// .update(remote_cx, |variable_list, cx| { +// assert_eq!(1, variable_list.scopes().len()); +// assert_eq!(3, variable_list.variables().len()); +// assert_eq!(scopes, variable_list.scopes().get(&1).unwrap().clone()); +// assert_eq!(&second_req_variable_list, &variable_list.variables()); + +// variable_list.assert_visual_entries( +// vec![ +// "v Scope 1", +// " v variable 1", +// " > variable 3", +// " > variable 2", +// ], +// cx, +// ); +// }); + +// client +// .on_request::({ +// let variables = Arc::new(vec![variable_4.clone()]); +// move |_, args| { +// assert_eq!(3, args.variables_reference); + +// Ok(dap::VariablesResponse { +// variables: (*variables).clone(), +// }) +// } +// }) +// .await; + +// local_debug_item +// .update(host_cx, |this, _| this.variable_list().clone()) +// .update(host_cx, |variable_list, cx| { +// assert_eq!(1, variable_list.scopes().len()); +// assert_eq!(3, variable_list.variables().len()); +// assert_eq!(scopes, variable_list.scopes().get(&1).unwrap().clone()); +// assert_eq!(&second_req_variable_list, &variable_list.variables()); + +// variable_list.assert_visual_entries(first_visual_entries.clone(), cx); + +// variable_list.toggle_variable(&scopes[0], &variable_2.clone(), 1, cx); +// }); + +// host_cx.run_until_parked(); +// remote_cx.run_until_parked(); + +// let final_variable_containers: Vec = vec![ +// VariableContainer { +// container_reference: scopes[0].variables_reference, +// variable: variable_1.clone(), +// depth: 1, +// }, +// VariableContainer { +// container_reference: variable_1.variables_reference, +// variable: variable_3.clone(), +// depth: 2, +// }, +// VariableContainer { +// container_reference: scopes[0].variables_reference, +// variable: variable_2.clone(), +// depth: 1, +// }, +// VariableContainer { +// container_reference: variable_2.variables_reference, +// variable: variable_4.clone(), +// depth: 2, +// }, +// ]; + +// remote_debug_item +// .update(remote_cx, |this, _| this.variable_list().clone()) +// .update(remote_cx, |variable_list, cx| { +// assert_eq!(1, variable_list.scopes().len()); +// assert_eq!(4, variable_list.variables().len()); +// assert_eq!(scopes, variable_list.scopes().get(&1).unwrap().clone()); +// assert_eq!(&final_variable_containers, &variable_list.variables()); + +// variable_list.assert_visual_entries( +// vec![ +// "v Scope 1", +// " v variable 1", +// " > variable 3", +// " > variable 2", +// ], +// cx, +// ); +// }); + +// local_debug_item +// .update(host_cx, |this, _| this.variable_list().clone()) +// .update(host_cx, |variable_list, cx| { +// assert_eq!(1, variable_list.scopes().len()); +// assert_eq!(4, variable_list.variables().len()); +// assert_eq!(scopes, variable_list.scopes().get(&1).unwrap().clone()); +// assert_eq!(&final_variable_containers, &variable_list.variables()); + +// variable_list.assert_visual_entries( +// vec![ +// "v Scope 1", +// " > variable 1", +// " v variable 2", +// " > variable 4", +// ], +// cx, +// ); +// }); + +// late_join_zed.join_project(host_project_id).await; +// let (_late_join_client, late_join_workspace, _late_join_project, late_join_cx) = +// late_join_zed.expand().await; + +// late_join_cx.run_until_parked(); + +// let last_join_remote_item = late_join_workspace.update(late_join_cx, |workspace, cx| { +// let debug_panel = workspace.panel::(cx).unwrap(); +// let active_debug_panel_item = debug_panel +// .update(cx, |this, cx| this.active_debug_panel_item(cx)) +// .unwrap(); + +// assert_eq!( +// 1, +// debug_panel.update(cx, |this, cx| this.pane().unwrap().read(cx).items_len()) +// ); +// assert_eq!(client.id(), active_debug_panel_item.read(cx).client_id()); +// assert_eq!(1, active_debug_panel_item.read(cx).thread_id()); +// active_debug_panel_item +// }); + +// last_join_remote_item +// .update(late_join_cx, |this, _| this.variable_list().clone()) +// .update(late_join_cx, |variable_list, cx| { +// assert_eq!(1, variable_list.scopes().len()); +// assert_eq!(4, variable_list.variables().len()); +// assert_eq!(scopes, variable_list.scopes().get(&1).unwrap().clone()); +// assert_eq!(final_variable_containers, variable_list.variables()); + +// variable_list.assert_visual_entries(first_visual_entries, cx); +// }); + +// let shutdown_client = host_project.update(host_cx, |project, cx| { +// project.dap_store().update(cx, |dap_store, cx| { +// dap_store.shutdown_session(&session.read(cx).id(), cx) +// }) +// }); + +// shutdown_client.await.unwrap(); +// } + +#[gpui::test] +async fn test_ignore_breakpoints( + _host_cx: &mut TestAppContext, + _remote_cx: &mut TestAppContext, + _cx_c: &mut TestAppContext, +) { + unimplemented!("Collab is still being refactored"); + // let executor = host_cx.executor(); + // let mut server = TestServer::start(executor).await; + + // let (mut host_zed, mut remote_zed, mut late_join_zed) = + // setup_three_member_test(&mut server, host_cx, remote_cx, cx_c).await; + + // let (host_project_id, worktree_id) = host_zed + // .host_project(Some(json!({"test.txt": "one\ntwo\nthree\nfour\nfive"}))) + // .await; + + // remote_zed.join_project(host_project_id).await; + + // let (_client_host, host_workspace, host_project, host_cx) = host_zed.expand().await; + // let (_client_remote, remote_workspace, remote_project, remote_cx) = remote_zed.expand().await; + + // let project_path = ProjectPath { + // worktree_id, + // path: Arc::from(Path::new(&"test.txt")), + // }; + + // let local_editor = host_workspace + // .update_in(host_cx, |workspace, window, cx| { + // workspace.open_path(project_path.clone(), None, true, window, cx) + // }) + // .await + // .unwrap() + // .downcast::() + // .unwrap(); + + // local_editor.update_in(host_cx, |editor, window, cx| { + // editor.move_down(&editor::actions::MoveDown, window, cx); + // editor.toggle_breakpoint(&editor::actions::ToggleBreakpoint, window, cx); // Line 2 + // editor.move_down(&editor::actions::MoveDown, window, cx); + // editor.toggle_breakpoint(&editor::actions::ToggleBreakpoint, window, cx); + // // Line 3 + // }); + + // host_cx.run_until_parked(); + // remote_cx.run_until_parked(); + + // let task = host_project.update(host_cx, |project, cx| { + // project.start_debug_session(dap::test_config(None), cx) + // }); + + // let session = task.await.unwrap(); + // let client = session.read_with(host_cx, |project, _| project.adapter_client().unwrap()); + // let client_id = client.id(); + + // client + // .on_request::(move |_, _| { + // Ok(dap::Capabilities { + // supports_configuration_done_request: Some(true), + // ..Default::default() + // }) + // }) + // .await; + + // let called_set_breakpoints = Arc::new(AtomicBool::new(false)); + // client + // .on_request::({ + // let called_set_breakpoints = called_set_breakpoints.clone(); + // move |_, args| { + // assert_eq!("/project/test.txt", args.source.path.unwrap()); + + // let mut actual_breakpoints = args.breakpoints.unwrap(); + // actual_breakpoints.sort_by_key(|b| b.line); + + // let expected_breakpoints = vec![ + // SourceBreakpoint { + // line: 2, + // column: None, + // condition: None, + // hit_condition: None, + // log_message: None, + // mode: None, + // }, + // SourceBreakpoint { + // line: 3, + // column: None, + // condition: None, + // hit_condition: None, + // log_message: None, + // mode: None, + // }, + // ]; + + // assert_eq!(actual_breakpoints, expected_breakpoints); + + // called_set_breakpoints.store(true, Ordering::SeqCst); + + // Ok(dap::SetBreakpointsResponse { + // breakpoints: Vec::default(), + // }) + // } + // }) + // .await; + + // client.on_request::(move |_, _| Ok(())).await; + // client + // .on_request::(move |_, _| { + // Ok(dap::StackTraceResponse { + // stack_frames: Vec::default(), + // total_frames: None, + // }) + // }) + // .await; + + // client + // .fake_event(dap::messages::Events::Initialized(Some( + // dap::Capabilities { + // supports_configuration_done_request: Some(true), + // ..Default::default() + // }, + // ))) + // .await; + + // host_cx.run_until_parked(); + // remote_cx.run_until_parked(); + + // assert!( + // called_set_breakpoints.load(std::sync::atomic::Ordering::SeqCst), + // "SetBreakpoint request must be called when starting debug session" + // ); + + // client + // .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { + // reason: dap::StoppedEventReason::Pause, + // description: None, + // thread_id: Some(1), + // preserve_focus_hint: None, + // text: None, + // all_threads_stopped: None, + // hit_breakpoint_ids: None, + // })) + // .await; + + // host_cx.run_until_parked(); + // remote_cx.run_until_parked(); + + // let remote_debug_item = remote_workspace.update(remote_cx, |workspace, cx| { + // let debug_panel = workspace.panel::(cx).unwrap(); + // let active_session = debug_panel + // .update(cx, |this, cx| this.active_session(cx)) + // .unwrap(); + + // assert_eq!( + // 1, + // debug_panel.update(cx, |this, cx| this.pane().unwrap().read(cx).items_len()) + // ); + + // let session_id = debug_panel.update(cx, |this, cx| { + // this.dap_store() + // .read(cx) + // .session_by_client_id(client.id()) + // .unwrap() + // .read(cx) + // .id() + // }); + + // let breakpoints_ignored = active_session.read(cx).are_breakpoints_ignored(cx); + + // assert_eq!(session_id, active_session.read(cx).session().read(cx).id()); + // assert_eq!(false, breakpoints_ignored); + // assert_eq!(client.id(), active_session.read(cx).client_id()); + // assert_eq!(1, active_session.read(cx).thread_id().0); + // active_session + // }); + + // called_set_breakpoints.store(false, Ordering::SeqCst); + + // client + // .on_request::({ + // let called_set_breakpoints = called_set_breakpoints.clone(); + // move |_, args| { + // assert_eq!("/project/test.txt", args.source.path.unwrap()); + // assert_eq!(args.breakpoints, Some(vec![])); + + // called_set_breakpoints.store(true, Ordering::SeqCst); + + // Ok(dap::SetBreakpointsResponse { + // breakpoints: Vec::default(), + // }) + // } + // }) + // .await; + + // let local_debug_item = host_workspace.update(host_cx, |workspace, cx| { + // let debug_panel = workspace.panel::(cx).unwrap(); + // let active_session = debug_panel + // .update(cx, |this, cx| this.active_session(cx)) + // .unwrap(); + + // assert_eq!( + // 1, + // debug_panel.update(cx, |this, cx| this.pane().unwrap().read(cx).items_len()) + // ); + + // assert_eq!(false, active_session.read(cx).are_breakpoints_ignored(cx)); + // assert_eq!(client.id(), active_session.read(cx).client_id()); + // assert_eq!(1, active_session.read(cx).thread_id().0); + + // active_session + // }); + + // local_debug_item.update(host_cx, |item, cx| { + // item.toggle_ignore_breakpoints(cx); // Set to true + // assert_eq!(true, item.are_breakpoints_ignored(cx)); + // }); + + // host_cx.run_until_parked(); + // remote_cx.run_until_parked(); + + // assert!( + // called_set_breakpoints.load(std::sync::atomic::Ordering::SeqCst), + // "SetBreakpoint request must be called to ignore breakpoints" + // ); + + // client + // .on_request::({ + // let called_set_breakpoints = called_set_breakpoints.clone(); + // move |_, _args| { + // called_set_breakpoints.store(true, Ordering::SeqCst); + + // Ok(dap::SetBreakpointsResponse { + // breakpoints: Vec::default(), + // }) + // } + // }) + // .await; + + // let remote_editor = remote_workspace + // .update_in(remote_cx, |workspace, window, cx| { + // workspace.open_path(project_path.clone(), None, true, window, cx) + // }) + // .await + // .unwrap() + // .downcast::() + // .unwrap(); + + // called_set_breakpoints.store(false, std::sync::atomic::Ordering::SeqCst); + + // remote_editor.update_in(remote_cx, |editor, window, cx| { + // // Line 1 + // editor.toggle_breakpoint(&editor::actions::ToggleBreakpoint, window, cx); + // }); + + // host_cx.run_until_parked(); + // remote_cx.run_until_parked(); + + // assert!( + // called_set_breakpoints.load(std::sync::atomic::Ordering::SeqCst), + // "SetBreakpoint request be called whenever breakpoints are toggled but with not breakpoints" + // ); + + // remote_debug_item.update(remote_cx, |debug_panel, cx| { + // let breakpoints_ignored = debug_panel.are_breakpoints_ignored(cx); + + // assert_eq!(true, breakpoints_ignored); + // assert_eq!(client.id(), debug_panel.client_id()); + // assert_eq!(1, debug_panel.thread_id().0); + // }); + + // client + // .on_request::({ + // let called_set_breakpoints = called_set_breakpoints.clone(); + // move |_, args| { + // assert_eq!("/project/test.txt", args.source.path.unwrap()); + + // let mut actual_breakpoints = args.breakpoints.unwrap(); + // actual_breakpoints.sort_by_key(|b| b.line); + + // let expected_breakpoints = vec![ + // SourceBreakpoint { + // line: 1, + // column: None, + // condition: None, + // hit_condition: None, + // log_message: None, + // mode: None, + // }, + // SourceBreakpoint { + // line: 2, + // column: None, + // condition: None, + // hit_condition: None, + // log_message: None, + // mode: None, + // }, + // SourceBreakpoint { + // line: 3, + // column: None, + // condition: None, + // hit_condition: None, + // log_message: None, + // mode: None, + // }, + // ]; + + // assert_eq!(actual_breakpoints, expected_breakpoints); + + // called_set_breakpoints.store(true, Ordering::SeqCst); + + // Ok(dap::SetBreakpointsResponse { + // breakpoints: Vec::default(), + // }) + // } + // }) + // .await; + + // late_join_zed.join_project(host_project_id).await; + // let (_late_join_client, late_join_workspace, late_join_project, late_join_cx) = + // late_join_zed.expand().await; + + // late_join_cx.run_until_parked(); + + // let last_join_remote_item = late_join_workspace.update(late_join_cx, |workspace, cx| { + // let debug_panel = workspace.panel::(cx).unwrap(); + // let active_session = debug_panel + // .update(cx, |this, cx| this.active_session(cx)) + // .unwrap(); + + // let breakpoints_ignored = active_session.read(cx).are_breakpoints_ignored(cx); + + // assert_eq!(true, breakpoints_ignored); + + // assert_eq!( + // 1, + // debug_panel.update(cx, |this, cx| this.pane().unwrap().read(cx).items_len()) + // ); + // assert_eq!(client.id(), active_session.read(cx).client_id()); + // assert_eq!(1, active_session.read(cx).thread_id().0); + // active_session + // }); + + // remote_debug_item.update(remote_cx, |item, cx| { + // item.toggle_ignore_breakpoints(cx); + // }); + + // host_cx.run_until_parked(); + // remote_cx.run_until_parked(); + // late_join_cx.run_until_parked(); + + // assert!( + // called_set_breakpoints.load(std::sync::atomic::Ordering::SeqCst), + // "SetBreakpoint request should be called to update breakpoints" + // ); + + // client + // .on_request::({ + // let called_set_breakpoints = called_set_breakpoints.clone(); + // move |_, args| { + // assert_eq!("/project/test.txt", args.source.path.unwrap()); + // assert_eq!(args.breakpoints, Some(vec![])); + + // called_set_breakpoints.store(true, Ordering::SeqCst); + + // Ok(dap::SetBreakpointsResponse { + // breakpoints: Vec::default(), + // }) + // } + // }) + // .await; + + // local_debug_item.update(host_cx, |debug_panel_item, cx| { + // assert_eq!( + // false, + // debug_panel_item.are_breakpoints_ignored(cx), + // "Remote client set this to false" + // ); + // }); + + // remote_debug_item.update(remote_cx, |debug_panel_item, cx| { + // assert_eq!( + // false, + // debug_panel_item.are_breakpoints_ignored(cx), + // "Remote client set this to false" + // ); + // }); + + // last_join_remote_item.update(late_join_cx, |debug_panel_item, cx| { + // assert_eq!( + // false, + // debug_panel_item.are_breakpoints_ignored(cx), + // "Remote client set this to false" + // ); + // }); + + // let shutdown_client = host_project.update(host_cx, |project, cx| { + // project.dap_store().update(cx, |dap_store, cx| { + // dap_store.shutdown_session(&session.read(cx).id(), cx) + // }) + // }); + + // shutdown_client.await.unwrap(); + + // host_cx.run_until_parked(); + // remote_cx.run_until_parked(); + + // remote_project.update(remote_cx, |project, cx| { + // project.dap_store().update(cx, |dap_store, _cx| { + // let sessions = dap_store.sessions().collect::>(); + + // assert_eq!( + // None, + // dap_store.session_by_client_id(&client_id), + // "No client_id to session mapping should exist after shutdown" + // ); + // assert_eq!( + // 0, + // sessions.len(), + // "No sessions should be left after shutdown" + // ); + // }) + // }); + + // late_join_project.update(late_join_cx, |project, cx| { + // project.dap_store().update(cx, |dap_store, _cx| { + // let sessions = dap_store.sessions().collect::>(); + + // assert_eq!( + // None, + // dap_store.session_by_client_id(&client_id), + // "No client_id to session mapping should exist after shutdown" + // ); + // assert_eq!( + // 0, + // sessions.len(), + // "No sessions should be left after shutdown" + // ); + // }) + // }); +} + +#[gpui::test] +async fn test_debug_panel_console(_host_cx: &mut TestAppContext, _remote_cx: &mut TestAppContext) { + unimplemented!("Collab is still being refactored"); + // let executor = host_cx.executor(); + // let mut server = TestServer::start(executor).await; + + // let (mut host_zed, mut remote_zed) = + // setup_two_member_test(&mut server, host_cx, remote_cx).await; + + // let (host_project_id, _) = host_zed.host_project(None).await; + // remote_zed.join_project(host_project_id).await; + + // let (_client_host, _host_workspace, host_project, host_cx) = host_zed.expand().await; + // let (_client_remote, remote_workspace, _remote_project, remote_cx) = remote_zed.expand().await; + + // remote_cx.run_until_parked(); + + // let task = host_project.update(host_cx, |project, cx| { + // project.start_debug_session(dap::test_config(None), cx) + // }); + + // let session = task.await.unwrap(); + // let client = session.read_with(host_cx, |project, _| project.adapter_client().unwrap()); + + // client + // .on_request::(move |_, _| { + // Ok(dap::Capabilities { + // supports_step_back: Some(false), + // ..Default::default() + // }) + // }) + // .await; + + // client.on_request::(move |_, _| Ok(())).await; + + // client + // .on_request::(move |_, _| { + // Ok(dap::StackTraceResponse { + // stack_frames: Vec::default(), + // total_frames: None, + // }) + // }) + // .await; + + // client + // .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { + // reason: dap::StoppedEventReason::Pause, + // description: None, + // thread_id: Some(1), + // preserve_focus_hint: None, + // text: None, + // all_threads_stopped: None, + // hit_breakpoint_ids: None, + // })) + // .await; + + // client + // .fake_event(dap::messages::Events::Output(dap::OutputEvent { + // category: None, + // output: "First line".to_string(), + // data: None, + // variables_reference: None, + // source: None, + // line: None, + // column: None, + // group: None, + // location_reference: None, + // })) + // .await; + + // client + // .fake_event(dap::messages::Events::Output(dap::OutputEvent { + // category: Some(dap::OutputEventCategory::Stdout), + // output: "First group".to_string(), + // data: None, + // variables_reference: None, + // source: None, + // line: None, + // column: None, + // group: Some(dap::OutputEventGroup::Start), + // location_reference: None, + // })) + // .await; + + // client + // .fake_event(dap::messages::Events::Output(dap::OutputEvent { + // category: Some(dap::OutputEventCategory::Stdout), + // output: "First item in group 1".to_string(), + // data: None, + // variables_reference: None, + // source: None, + // line: None, + // column: None, + // group: None, + // location_reference: None, + // })) + // .await; + + // client + // .fake_event(dap::messages::Events::Output(dap::OutputEvent { + // category: Some(dap::OutputEventCategory::Stdout), + // output: "Second item in group 1".to_string(), + // data: None, + // variables_reference: None, + // source: None, + // line: None, + // column: None, + // group: None, + // location_reference: None, + // })) + // .await; + + // client + // .fake_event(dap::messages::Events::Output(dap::OutputEvent { + // category: Some(dap::OutputEventCategory::Stdout), + // output: "Second group".to_string(), + // data: None, + // variables_reference: None, + // source: None, + // line: None, + // column: None, + // group: Some(dap::OutputEventGroup::Start), + // location_reference: None, + // })) + // .await; + + // client + // .fake_event(dap::messages::Events::Output(dap::OutputEvent { + // category: Some(dap::OutputEventCategory::Stdout), + // output: "First item in group 2".to_string(), + // data: None, + // variables_reference: None, + // source: None, + // line: None, + // column: None, + // group: None, + // location_reference: None, + // })) + // .await; + + // client + // .fake_event(dap::messages::Events::Output(dap::OutputEvent { + // category: Some(dap::OutputEventCategory::Stdout), + // output: "Second item in group 2".to_string(), + // data: None, + // variables_reference: None, + // source: None, + // line: None, + // column: None, + // group: None, + // location_reference: None, + // })) + // .await; + + // client + // .fake_event(dap::messages::Events::Output(dap::OutputEvent { + // category: Some(dap::OutputEventCategory::Stdout), + // output: "End group 2".to_string(), + // data: None, + // variables_reference: None, + // source: None, + // line: None, + // column: None, + // group: Some(dap::OutputEventGroup::End), + // location_reference: None, + // })) + // .await; + + // client + // .fake_event(dap::messages::Events::Output(dap::OutputEvent { + // category: Some(dap::OutputEventCategory::Stdout), + // output: "Third group".to_string(), + // data: None, + // variables_reference: None, + // source: None, + // line: None, + // column: None, + // group: Some(dap::OutputEventGroup::StartCollapsed), + // location_reference: None, + // })) + // .await; + + // client + // .fake_event(dap::messages::Events::Output(dap::OutputEvent { + // category: Some(dap::OutputEventCategory::Stdout), + // output: "First item in group 3".to_string(), + // data: None, + // variables_reference: None, + // source: None, + // line: None, + // column: None, + // group: None, + // location_reference: None, + // })) + // .await; + + // client + // .fake_event(dap::messages::Events::Output(dap::OutputEvent { + // category: Some(dap::OutputEventCategory::Stdout), + // output: "Second item in group 3".to_string(), + // data: None, + // variables_reference: None, + // source: None, + // line: None, + // column: None, + // group: None, + // location_reference: None, + // })) + // .await; + + // client + // .fake_event(dap::messages::Events::Output(dap::OutputEvent { + // category: Some(dap::OutputEventCategory::Stdout), + // output: "End group 3".to_string(), + // data: None, + // variables_reference: None, + // source: None, + // line: None, + // column: None, + // group: Some(dap::OutputEventGroup::End), + // location_reference: None, + // })) + // .await; + + // client + // .fake_event(dap::messages::Events::Output(dap::OutputEvent { + // category: Some(dap::OutputEventCategory::Stdout), + // output: "Third item in group 1".to_string(), + // data: None, + // variables_reference: None, + // source: None, + // line: None, + // column: None, + // group: None, + // location_reference: None, + // })) + // .await; + + // client + // .fake_event(dap::messages::Events::Output(dap::OutputEvent { + // category: Some(dap::OutputEventCategory::Stdout), + // output: "Second item".to_string(), + // data: None, + // variables_reference: None, + // source: None, + // line: None, + // column: None, + // group: Some(dap::OutputEventGroup::End), + // location_reference: None, + // })) + // .await; + + // host_cx.run_until_parked(); + // remote_cx.run_until_parked(); + + // active_session(remote_workspace, remote_cx).update(remote_cx, |session_item, cx| { + // session_item + // .mode() + // .as_running() + // .unwrap() + // .read(cx) + // .console() + // .update(cx, |console, cx| { + // console.editor().update(cx, |editor, cx| { + // pretty_assertions::assert_eq!( + // " + // () + .unwrap(); + + // Client B opens same editor as A. + let editor_b = workspace_b + .update_in(cx_b, |workspace, window, cx| { + workspace.open_path(project_path.clone(), None, true, window, cx) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + + cx_a.run_until_parked(); + cx_b.run_until_parked(); + + // Client A adds breakpoint on line (1) + editor_a.update_in(cx_a, |editor, window, cx| { + editor.toggle_breakpoint(&editor::actions::ToggleBreakpoint, window, cx); + }); + + cx_a.run_until_parked(); + cx_b.run_until_parked(); + + let breakpoints_a = editor_a.update(cx_a, |editor, cx| { + editor + .breakpoint_store() + .clone() + .unwrap() + .read(cx) + .all_breakpoints(cx) + .clone() + }); + let breakpoints_b = editor_b.update(cx_b, |editor, cx| { + editor + .breakpoint_store() + .clone() + .unwrap() + .read(cx) + .all_breakpoints(cx) + .clone() + }); + + assert_eq!(1, breakpoints_a.len()); + assert_eq!(1, breakpoints_a.get(&abs_path).unwrap().len()); + assert_eq!(breakpoints_a, breakpoints_b); + + // Client B adds breakpoint on line(2) + editor_b.update_in(cx_b, |editor, window, cx| { + editor.move_down(&editor::actions::MoveDown, window, cx); + editor.move_down(&editor::actions::MoveDown, window, cx); + editor.toggle_breakpoint(&editor::actions::ToggleBreakpoint, window, cx); + }); + + cx_a.run_until_parked(); + cx_b.run_until_parked(); + + let breakpoints_a = editor_a.update(cx_a, |editor, cx| { + editor + .breakpoint_store() + .clone() + .unwrap() + .read(cx) + .all_breakpoints(cx) + .clone() + }); + let breakpoints_b = editor_b.update(cx_b, |editor, cx| { + editor + .breakpoint_store() + .clone() + .unwrap() + .read(cx) + .all_breakpoints(cx) + .clone() + }); + + assert_eq!(1, breakpoints_a.len()); + assert_eq!(breakpoints_a, breakpoints_b); + assert_eq!(2, breakpoints_a.get(&abs_path).unwrap().len()); + + // Client A removes last added breakpoint from client B + editor_a.update_in(cx_a, |editor, window, cx| { + editor.move_down(&editor::actions::MoveDown, window, cx); + editor.move_down(&editor::actions::MoveDown, window, cx); + editor.toggle_breakpoint(&editor::actions::ToggleBreakpoint, window, cx); + }); + + cx_a.run_until_parked(); + cx_b.run_until_parked(); + + let breakpoints_a = editor_a.update(cx_a, |editor, cx| { + editor + .breakpoint_store() + .clone() + .unwrap() + .read(cx) + .all_breakpoints(cx) + .clone() + }); + let breakpoints_b = editor_b.update(cx_b, |editor, cx| { + editor + .breakpoint_store() + .clone() + .unwrap() + .read(cx) + .all_breakpoints(cx) + .clone() + }); + + assert_eq!(1, breakpoints_a.len()); + assert_eq!(breakpoints_a, breakpoints_b); + assert_eq!(1, breakpoints_a.get(&abs_path).unwrap().len()); + + // Client B removes first added breakpoint by client A + editor_b.update_in(cx_b, |editor, window, cx| { + editor.move_up(&editor::actions::MoveUp, window, cx); + editor.move_up(&editor::actions::MoveUp, window, cx); + editor.toggle_breakpoint(&editor::actions::ToggleBreakpoint, window, cx); + }); + + cx_a.run_until_parked(); + cx_b.run_until_parked(); + + let breakpoints_a = editor_a.update(cx_a, |editor, cx| { + editor + .breakpoint_store() + .clone() + .unwrap() + .read(cx) + .all_breakpoints(cx) + .clone() + }); + let breakpoints_b = editor_b.update(cx_b, |editor, cx| { + editor + .breakpoint_store() + .clone() + .unwrap() + .read(cx) + .all_breakpoints(cx) + .clone() + }); + + assert_eq!(0, breakpoints_a.len()); + assert_eq!(breakpoints_a, breakpoints_b); +} + #[track_caller] fn tab_undo_assert( cx_a: &mut EditorTestContext, diff --git a/crates/dap/Cargo.toml b/crates/dap/Cargo.toml new file mode 100644 index 0000000000..7c7bab6110 --- /dev/null +++ b/crates/dap/Cargo.toml @@ -0,0 +1,53 @@ +[package] +name = "dap" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[features] +test-support = [ + "gpui/test-support", + "util/test-support", + "task/test-support", + "async-pipe", + "settings/test-support", +] + +[dependencies] +anyhow.workspace = true +async-compression.workspace = true +async-pipe = { workspace = true, optional = true } +async-tar.workspace = true +async-trait.workspace = true +client.workspace = true +collections.workspace = true +dap-types.workspace = true +fs.workspace = true +futures.workspace = true +gpui.workspace = true +http_client.workspace = true +language.workspace = true +log.workspace = true +node_runtime.workspace = true +parking_lot.workspace = true +paths.workspace = true +schemars.workspace = true +serde.workspace = true +serde_json.workspace = true +settings.workspace = true +smallvec.workspace = true +smol.workspace = true +task.workspace = true +util.workspace = true + +[dev-dependencies] +async-pipe.workspace = true +env_logger.workspace = true +gpui = { workspace = true, features = ["test-support"] } +settings = { workspace = true, features = ["test-support"] } +task = { workspace = true, features = ["test-support"] } +util = { workspace = true, features = ["test-support"] } diff --git a/crates/dap/LICENSE-GPL b/crates/dap/LICENSE-GPL new file mode 120000 index 0000000000..89e542f750 --- /dev/null +++ b/crates/dap/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/dap/docs/breakpoints.md b/crates/dap/docs/breakpoints.md new file mode 100644 index 0000000000..8b819b089b --- /dev/null +++ b/crates/dap/docs/breakpoints.md @@ -0,0 +1,9 @@ +# Overview + +The active `Project` is responsible for maintain opened and closed breakpoints +as well as serializing breakpoints to save. At a high level project serializes +the positions of breakpoints that don't belong to any active buffers and handles +converting breakpoints from serializing to active whenever a buffer is opened/closed. + +`Project` also handles sending all relevant breakpoint information to debug adapter's +during debugging or when starting a debugger. diff --git a/crates/dap/src/adapters.rs b/crates/dap/src/adapters.rs new file mode 100644 index 0000000000..a1053b2ff8 --- /dev/null +++ b/crates/dap/src/adapters.rs @@ -0,0 +1,370 @@ +use ::fs::Fs; +use anyhow::{anyhow, Context as _, Ok, Result}; +use async_compression::futures::bufread::GzipDecoder; +use async_tar::Archive; +use async_trait::async_trait; +use futures::io::BufReader; +use gpui::{AsyncApp, SharedString}; +pub use http_client::{github::latest_github_release, HttpClient}; +use language::LanguageToolchainStore; +use node_runtime::NodeRuntime; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use settings::WorktreeId; +use smol::{self, fs::File, lock::Mutex}; +use std::{ + collections::{HashMap, HashSet}, + ffi::{OsStr, OsString}, + fmt::Debug, + net::Ipv4Addr, + ops::Deref, + path::{Path, PathBuf}, + sync::Arc, +}; +use task::DebugAdapterConfig; +use util::ResultExt; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum DapStatus { + None, + CheckingForUpdate, + Downloading, + Failed { error: String }, +} + +#[async_trait(?Send)] +pub trait DapDelegate { + fn worktree_id(&self) -> WorktreeId; + fn http_client(&self) -> Arc; + fn node_runtime(&self) -> NodeRuntime; + fn toolchain_store(&self) -> Arc; + fn fs(&self) -> Arc; + fn updated_adapters(&self) -> Arc>>; + fn update_status(&self, dap_name: DebugAdapterName, status: DapStatus); + fn which(&self, command: &OsStr) -> Option; + async fn shell_env(&self) -> collections::HashMap; +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)] +pub struct DebugAdapterName(pub Arc); + +impl Deref for DebugAdapterName { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl AsRef for DebugAdapterName { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl AsRef for DebugAdapterName { + fn as_ref(&self) -> &Path { + Path::new(&*self.0) + } +} + +impl std::fmt::Display for DebugAdapterName { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + std::fmt::Display::fmt(&self.0, f) + } +} + +impl From for SharedString { + fn from(name: DebugAdapterName) -> Self { + SharedString::from(name.0) + } +} + +impl<'a> From<&'a str> for DebugAdapterName { + fn from(str: &'a str) -> DebugAdapterName { + DebugAdapterName(str.to_string().into()) + } +} + +#[derive(Debug, Clone)] +pub struct TcpArguments { + pub host: Ipv4Addr, + pub port: u16, + pub timeout: Option, +} +#[derive(Debug, Clone)] +pub struct DebugAdapterBinary { + pub command: String, + pub arguments: Option>, + pub envs: Option>, + pub cwd: Option, + pub connection: Option, +} + +pub struct AdapterVersion { + pub tag_name: String, + pub url: String, +} + +pub enum DownloadedFileType { + Vsix, + GzipTar, + Zip, +} + +pub struct GithubRepo { + pub repo_name: String, + pub repo_owner: String, +} + +pub async fn download_adapter_from_github( + adapter_name: DebugAdapterName, + github_version: AdapterVersion, + file_type: DownloadedFileType, + delegate: &dyn DapDelegate, +) -> Result { + let adapter_path = paths::debug_adapters_dir().join(&adapter_name); + let version_path = adapter_path.join(format!("{}_{}", adapter_name, github_version.tag_name)); + let fs = delegate.fs(); + + if version_path.exists() { + return Ok(version_path); + } + + if !adapter_path.exists() { + fs.create_dir(&adapter_path.as_path()) + .await + .context("Failed creating adapter path")?; + } + + log::debug!( + "Downloading adapter {} from {}", + adapter_name, + &github_version.url, + ); + + let mut response = delegate + .http_client() + .get(&github_version.url, Default::default(), true) + .await + .context("Error downloading release")?; + if !response.status().is_success() { + Err(anyhow!( + "download failed with status {}", + response.status().to_string() + ))?; + } + + match file_type { + DownloadedFileType::GzipTar => { + let decompressed_bytes = GzipDecoder::new(BufReader::new(response.body_mut())); + let archive = Archive::new(decompressed_bytes); + archive.unpack(&version_path).await?; + } + DownloadedFileType::Zip | DownloadedFileType::Vsix => { + let zip_path = version_path.with_extension("zip"); + + let mut file = File::create(&zip_path).await?; + futures::io::copy(response.body_mut(), &mut file).await?; + + // we cannot check the status as some adapter include files with names that trigger `Illegal byte sequence` + util::command::new_smol_command("unzip") + .arg(&zip_path) + .arg("-d") + .arg(&version_path) + .output() + .await?; + + util::fs::remove_matching(&adapter_path, |entry| { + entry + .file_name() + .is_some_and(|file| file.to_string_lossy().ends_with(".zip")) + }) + .await; + } + } + + // remove older versions + util::fs::remove_matching(&adapter_path, |entry| { + entry.to_string_lossy() != version_path.to_string_lossy() + }) + .await; + + Ok(version_path) +} + +pub async fn fetch_latest_adapter_version_from_github( + github_repo: GithubRepo, + delegate: &dyn DapDelegate, +) -> Result { + let release = latest_github_release( + &format!("{}/{}", github_repo.repo_owner, github_repo.repo_name), + false, + false, + delegate.http_client(), + ) + .await?; + + Ok(AdapterVersion { + tag_name: release.tag_name, + url: release.zipball_url, + }) +} + +#[async_trait(?Send)] +pub trait DebugAdapter: 'static + Send + Sync { + fn name(&self) -> DebugAdapterName; + + async fn get_binary( + &self, + delegate: &dyn DapDelegate, + config: &DebugAdapterConfig, + user_installed_path: Option, + cx: &mut AsyncApp, + ) -> Result { + if delegate + .updated_adapters() + .lock() + .await + .contains(&self.name()) + { + log::info!("Using cached debug adapter binary {}", self.name()); + + if let Some(binary) = self + .get_installed_binary(delegate, &config, user_installed_path.clone(), cx) + .await + .log_err() + { + return Ok(binary); + } + + log::info!( + "Cached binary {} is corrupt falling back to install", + self.name() + ); + } + + log::info!("Getting latest version of debug adapter {}", self.name()); + delegate.update_status(self.name(), DapStatus::CheckingForUpdate); + if let Some(version) = self.fetch_latest_adapter_version(delegate).await.log_err() { + log::info!( + "Installiing latest version of debug adapter {}", + self.name() + ); + delegate.update_status(self.name(), DapStatus::Downloading); + self.install_binary(version, delegate).await?; + + delegate + .updated_adapters() + .lock_arc() + .await + .insert(self.name()); + } + + self.get_installed_binary(delegate, &config, user_installed_path, cx) + .await + } + + async fn fetch_latest_adapter_version( + &self, + delegate: &dyn DapDelegate, + ) -> Result; + + /// Installs the binary for the debug adapter. + /// This method is called when the adapter binary is not found or needs to be updated. + /// It should download and install the necessary files for the debug adapter to function. + async fn install_binary( + &self, + version: AdapterVersion, + delegate: &dyn DapDelegate, + ) -> Result<()>; + + async fn get_installed_binary( + &self, + delegate: &dyn DapDelegate, + config: &DebugAdapterConfig, + user_installed_path: Option, + cx: &mut AsyncApp, + ) -> Result; + + /// Should return base configuration to make the debug adapter work + fn request_args(&self, config: &DebugAdapterConfig) -> Value; +} + +#[cfg(any(test, feature = "test-support"))] +pub struct FakeAdapter {} + +#[cfg(any(test, feature = "test-support"))] +impl FakeAdapter { + const ADAPTER_NAME: &'static str = "fake-adapter"; + + pub fn new() -> Self { + Self {} + } +} + +#[cfg(any(test, feature = "test-support"))] +#[async_trait(?Send)] +impl DebugAdapter for FakeAdapter { + fn name(&self) -> DebugAdapterName { + DebugAdapterName(Self::ADAPTER_NAME.into()) + } + + async fn get_binary( + &self, + _: &dyn DapDelegate, + _: &DebugAdapterConfig, + _: Option, + _: &mut AsyncApp, + ) -> Result { + Ok(DebugAdapterBinary { + command: "command".into(), + arguments: None, + connection: None, + envs: None, + cwd: None, + }) + } + + async fn fetch_latest_adapter_version( + &self, + _delegate: &dyn DapDelegate, + ) -> Result { + unimplemented!("fetch latest adapter version"); + } + + async fn install_binary( + &self, + _version: AdapterVersion, + _delegate: &dyn DapDelegate, + ) -> Result<()> { + unimplemented!("install binary"); + } + + async fn get_installed_binary( + &self, + _: &dyn DapDelegate, + _: &DebugAdapterConfig, + _: Option, + _: &mut AsyncApp, + ) -> Result { + unimplemented!("get installed binary"); + } + + fn request_args(&self, config: &DebugAdapterConfig) -> Value { + use serde_json::json; + use task::DebugRequestType; + + json!({ + "request": match config.request { + DebugRequestType::Launch => "launch", + DebugRequestType::Attach(_) => "attach", + }, + "process_id": if let DebugRequestType::Attach(attach_config) = &config.request { + attach_config.process_id + } else { + None + }, + }) + } +} diff --git a/crates/dap/src/client.rs b/crates/dap/src/client.rs new file mode 100644 index 0000000000..a4dc394d0b --- /dev/null +++ b/crates/dap/src/client.rs @@ -0,0 +1,490 @@ +use crate::{ + adapters::{DebugAdapterBinary, DebugAdapterName}, + transport::{IoKind, LogKind, TransportDelegate}, +}; +use anyhow::{anyhow, Result}; +use dap_types::{ + messages::{Message, Response}, + requests::Request, +}; +use futures::{channel::oneshot, select, FutureExt as _}; +use gpui::{AppContext, AsyncApp, BackgroundExecutor}; +use smol::channel::{Receiver, Sender}; +use std::{ + hash::Hash, + sync::atomic::{AtomicU64, Ordering}, + time::Duration, +}; + +#[cfg(any(test, feature = "test-support"))] +const DAP_REQUEST_TIMEOUT: Duration = Duration::from_secs(2); + +#[cfg(not(any(test, feature = "test-support")))] +const DAP_REQUEST_TIMEOUT: Duration = Duration::from_secs(12); + +#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[repr(transparent)] +pub struct SessionId(pub u32); + +impl SessionId { + pub fn from_proto(client_id: u64) -> Self { + Self(client_id as u32) + } + + pub fn to_proto(&self) -> u64 { + self.0 as u64 + } +} + +/// Represents a connection to the debug adapter process, either via stdout/stdin or a socket. +pub struct DebugAdapterClient { + id: SessionId, + name: DebugAdapterName, + sequence_count: AtomicU64, + binary: DebugAdapterBinary, + executor: BackgroundExecutor, + transport_delegate: TransportDelegate, +} + +pub type DapMessageHandler = Box; + +impl DebugAdapterClient { + pub async fn start( + id: SessionId, + name: DebugAdapterName, + binary: DebugAdapterBinary, + message_handler: DapMessageHandler, + cx: AsyncApp, + ) -> Result { + let ((server_rx, server_tx), transport_delegate) = + TransportDelegate::start(&binary, cx.clone()).await?; + let this = Self { + id, + name, + binary, + transport_delegate, + sequence_count: AtomicU64::new(1), + executor: cx.background_executor().clone(), + }; + log::info!("Successfully connected to debug adapter"); + + let client_id = this.id; + + // start handling events/reverse requests + + cx.background_spawn(Self::handle_receive_messages( + client_id, + server_rx, + server_tx.clone(), + message_handler, + )) + .detach(); + + Ok(this) + } + + pub async fn reconnect( + &self, + session_id: SessionId, + binary: DebugAdapterBinary, + message_handler: DapMessageHandler, + cx: AsyncApp, + ) -> Result { + let binary = match self.transport_delegate.transport() { + crate::transport::Transport::Tcp(tcp_transport) => DebugAdapterBinary { + command: binary.command, + arguments: binary.arguments, + envs: binary.envs, + cwd: binary.cwd, + connection: Some(crate::adapters::TcpArguments { + host: tcp_transport.host, + port: tcp_transport.port, + timeout: Some(tcp_transport.timeout), + }), + }, + _ => self.binary.clone(), + }; + + Self::start(session_id, self.name(), binary, message_handler, cx).await + } + + async fn handle_receive_messages( + client_id: SessionId, + server_rx: Receiver, + client_tx: Sender, + mut message_handler: DapMessageHandler, + ) -> Result<()> { + let result = loop { + let message = match server_rx.recv().await { + Ok(message) => message, + Err(e) => break Err(e.into()), + }; + + match message { + Message::Event(ev) => { + log::debug!("Client {} received event `{}`", client_id.0, &ev); + + message_handler(Message::Event(ev)) + } + Message::Request(req) => { + log::debug!( + "Client {} received reverse request `{}`", + client_id.0, + &req.command + ); + + message_handler(Message::Request(req)) + } + Message::Response(response) => { + log::debug!("Received response after request timeout: {:#?}", response); + } + } + + smol::future::yield_now().await; + }; + + drop(client_tx); + + log::debug!("Handle receive messages dropped"); + + result + } + + /// Send a request to an adapter and get a response back + /// Note: This function will block until a response is sent back from the adapter + pub async fn request(&self, arguments: R::Arguments) -> Result { + let serialized_arguments = serde_json::to_value(arguments)?; + + let (callback_tx, callback_rx) = oneshot::channel::>(); + + let sequence_id = self.next_sequence_id(); + + let request = crate::messages::Request { + seq: sequence_id, + command: R::COMMAND.to_string(), + arguments: Some(serialized_arguments), + }; + + self.transport_delegate + .add_pending_request(sequence_id, callback_tx) + .await; + + log::debug!( + "Client {} send `{}` request with sequence_id: {}", + self.id.0, + R::COMMAND.to_string(), + sequence_id + ); + + self.send_message(Message::Request(request)).await?; + + let mut timeout = self.executor.timer(DAP_REQUEST_TIMEOUT).fuse(); + let command = R::COMMAND.to_string(); + + select! { + response = callback_rx.fuse() => { + log::debug!( + "Client {} received response for: `{}` sequence_id: {}", + self.id.0, + command, + sequence_id + ); + + let response = response??; + match response.success { + true => Ok(serde_json::from_value(response.body.unwrap_or_default())?), + false => Err(anyhow!("Request failed: {}", response.message.unwrap_or_default())), + } + } + + _ = timeout => { + self.transport_delegate.cancel_pending_request(&sequence_id).await; + log::error!("Cancelled DAP request for {command:?} id {sequence_id} which took over {DAP_REQUEST_TIMEOUT:?}"); + anyhow::bail!("DAP request timeout"); + } + } + } + + pub async fn send_message(&self, message: Message) -> Result<()> { + self.transport_delegate.send_message(message).await + } + + pub fn id(&self) -> SessionId { + self.id + } + + pub fn name(&self) -> DebugAdapterName { + self.name.clone() + } + pub fn binary(&self) -> &DebugAdapterBinary { + &self.binary + } + + /// Get the next sequence id to be used in a request + pub fn next_sequence_id(&self) -> u64 { + self.sequence_count.fetch_add(1, Ordering::Relaxed) + } + + pub async fn shutdown(&self) -> Result<()> { + self.transport_delegate.shutdown().await + } + + pub fn has_adapter_logs(&self) -> bool { + self.transport_delegate.has_adapter_logs() + } + + pub fn add_log_handler(&self, f: F, kind: LogKind) + where + F: 'static + Send + FnMut(IoKind, &str), + { + self.transport_delegate.add_log_handler(f, kind); + } + + #[cfg(any(test, feature = "test-support"))] + pub async fn on_request(&self, handler: F) + where + F: 'static + + Send + + FnMut(u64, R::Arguments) -> Result, + { + let transport = self.transport_delegate.transport().as_fake(); + transport.on_request::(handler).await; + } + + #[cfg(any(test, feature = "test-support"))] + pub async fn fake_reverse_request(&self, args: R::Arguments) { + self.send_message(Message::Request(dap_types::messages::Request { + seq: self.sequence_count.load(Ordering::Relaxed), + command: R::COMMAND.into(), + arguments: serde_json::to_value(args).ok(), + })) + .await + .unwrap(); + } + + #[cfg(any(test, feature = "test-support"))] + pub async fn on_response(&self, handler: F) + where + F: 'static + Send + Fn(Response), + { + let transport = self.transport_delegate.transport().as_fake(); + transport.on_response::(handler).await; + } + + #[cfg(any(test, feature = "test-support"))] + pub async fn fake_event(&self, event: dap_types::messages::Events) { + self.send_message(Message::Event(Box::new(event))) + .await + .unwrap(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{client::DebugAdapterClient, debugger_settings::DebuggerSettings}; + use dap_types::{ + messages::Events, + requests::{Initialize, Request, RunInTerminal}, + Capabilities, InitializeRequestArguments, InitializeRequestArgumentsPathFormat, + RunInTerminalRequestArguments, + }; + use gpui::TestAppContext; + use serde_json::json; + use settings::{Settings, SettingsStore}; + use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }; + + pub fn init_test(cx: &mut gpui::TestAppContext) { + if std::env::var("RUST_LOG").is_ok() { + env_logger::try_init().ok(); + } + + cx.update(|cx| { + let settings = SettingsStore::test(cx); + cx.set_global(settings); + DebuggerSettings::register(cx); + }); + } + + #[gpui::test] + pub async fn test_initialize_client(cx: &mut TestAppContext) { + init_test(cx); + + let client = DebugAdapterClient::start( + crate::client::SessionId(1), + DebugAdapterName("adapter".into()), + DebugAdapterBinary { + command: "command".into(), + arguments: Default::default(), + envs: Default::default(), + connection: None, + cwd: None, + }, + Box::new(|_| panic!("Did not expect to hit this code path")), + cx.to_async(), + ) + .await + .unwrap(); + + client + .on_request::(move |_, _| { + Ok(dap_types::Capabilities { + supports_configuration_done_request: Some(true), + ..Default::default() + }) + }) + .await; + + cx.run_until_parked(); + + let response = client + .request::(InitializeRequestArguments { + client_id: Some("zed".to_owned()), + client_name: Some("Zed".to_owned()), + adapter_id: "fake-adapter".to_owned(), + locale: Some("en-US".to_owned()), + path_format: Some(InitializeRequestArgumentsPathFormat::Path), + supports_variable_type: Some(true), + supports_variable_paging: Some(false), + supports_run_in_terminal_request: Some(true), + supports_memory_references: Some(true), + supports_progress_reporting: Some(false), + supports_invalidated_event: Some(false), + lines_start_at1: Some(true), + columns_start_at1: Some(true), + supports_memory_event: Some(false), + supports_args_can_be_interpreted_by_shell: Some(false), + supports_start_debugging_request: Some(true), + supports_ansistyling: Some(false), + }) + .await + .unwrap(); + + cx.run_until_parked(); + + assert_eq!( + dap_types::Capabilities { + supports_configuration_done_request: Some(true), + ..Default::default() + }, + response + ); + + client.shutdown().await.unwrap(); + } + + #[gpui::test] + pub async fn test_calls_event_handler(cx: &mut TestAppContext) { + init_test(cx); + + let called_event_handler = Arc::new(AtomicBool::new(false)); + + let client = DebugAdapterClient::start( + crate::client::SessionId(1), + DebugAdapterName("adapter".into()), + DebugAdapterBinary { + command: "command".into(), + arguments: Default::default(), + envs: Default::default(), + connection: None, + cwd: None, + }, + Box::new({ + let called_event_handler = called_event_handler.clone(); + move |event| { + called_event_handler.store(true, Ordering::SeqCst); + + assert_eq!( + Message::Event(Box::new(Events::Initialized( + Some(Capabilities::default()) + ))), + event + ); + } + }), + cx.to_async(), + ) + .await + .unwrap(); + + cx.run_until_parked(); + + client + .fake_event(Events::Initialized(Some(Capabilities::default()))) + .await; + + cx.run_until_parked(); + + assert!( + called_event_handler.load(std::sync::atomic::Ordering::SeqCst), + "Event handler was not called" + ); + + client.shutdown().await.unwrap(); + } + + #[gpui::test] + pub async fn test_calls_event_handler_for_reverse_request(cx: &mut TestAppContext) { + init_test(cx); + + let called_event_handler = Arc::new(AtomicBool::new(false)); + + let client = DebugAdapterClient::start( + crate::client::SessionId(1), + DebugAdapterName(Arc::from("test-adapter")), + DebugAdapterBinary { + command: "command".into(), + arguments: Default::default(), + envs: Default::default(), + connection: None, + cwd: None, + }, + Box::new({ + let called_event_handler = called_event_handler.clone(); + move |event| { + called_event_handler.store(true, Ordering::SeqCst); + + assert_eq!( + Message::Request(dap_types::messages::Request { + seq: 1, + command: RunInTerminal::COMMAND.into(), + arguments: Some(json!({ + "cwd": "/project/path/src", + "args": ["node", "test.js"], + })) + }), + event + ); + } + }), + cx.to_async(), + ) + .await + .unwrap(); + + cx.run_until_parked(); + + client + .fake_reverse_request::(RunInTerminalRequestArguments { + kind: None, + title: None, + cwd: "/project/path/src".into(), + args: vec!["node".into(), "test.js".into()], + env: None, + args_can_be_interpreted_by_shell: None, + }) + .await; + + cx.run_until_parked(); + + assert!( + called_event_handler.load(std::sync::atomic::Ordering::SeqCst), + "Event handler was not called" + ); + + client.shutdown().await.unwrap(); + } +} diff --git a/crates/dap/src/debugger_settings.rs b/crates/dap/src/debugger_settings.rs new file mode 100644 index 0000000000..5b83927dfe --- /dev/null +++ b/crates/dap/src/debugger_settings.rs @@ -0,0 +1,59 @@ +use dap_types::SteppingGranularity; +use gpui::{App, Global}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use settings::{Settings, SettingsSources}; + +#[derive(Serialize, Deserialize, JsonSchema, Clone, Copy)] +#[serde(default)] +pub struct DebuggerSettings { + /// Determines the stepping granularity. + /// + /// Default: line + pub stepping_granularity: SteppingGranularity, + /// Whether the breakpoints should be reused across Zed sessions. + /// + /// Default: true + pub save_breakpoints: bool, + /// Whether to show the debug button in the status bar. + /// + /// Default: true + pub button: bool, + /// Time in milliseconds until timeout error when connecting to a TCP debug adapter + /// + /// Default: 2000ms + pub timeout: u64, + /// Whether to log messages between active debug adapters and Zed + /// + /// Default: true + pub log_dap_communications: bool, + /// Whether to format dap messages in when adding them to debug adapter logger + /// + /// Default: true + pub format_dap_log_messages: bool, +} + +impl Default for DebuggerSettings { + fn default() -> Self { + Self { + button: true, + save_breakpoints: true, + stepping_granularity: SteppingGranularity::Line, + timeout: 2000, + log_dap_communications: true, + format_dap_log_messages: true, + } + } +} + +impl Settings for DebuggerSettings { + const KEY: Option<&'static str> = Some("debugger"); + + type FileContent = Self; + + fn load(sources: SettingsSources, _: &mut App) -> anyhow::Result { + sources.json_merge() + } +} + +impl Global for DebuggerSettings {} diff --git a/crates/dap/src/lib.rs b/crates/dap/src/lib.rs new file mode 100644 index 0000000000..f8e54aa51c --- /dev/null +++ b/crates/dap/src/lib.rs @@ -0,0 +1,38 @@ +pub mod adapters; +pub mod client; +pub mod debugger_settings; +pub mod proto_conversions; +pub mod transport; + +pub use dap_types::*; +pub use task::{DebugAdapterConfig, DebugAdapterKind, DebugRequestType}; + +pub type ScopeId = u64; +pub type VariableReference = u64; +pub type StackFrameId = u64; + +#[cfg(any(test, feature = "test-support"))] +pub use adapters::FakeAdapter; + +#[cfg(any(test, feature = "test-support"))] +pub fn test_config( + request: DebugRequestType, + fail: Option, + caps: Option, +) -> DebugAdapterConfig { + DebugAdapterConfig { + label: "test config".into(), + kind: DebugAdapterKind::Fake(( + fail.unwrap_or_default(), + caps.unwrap_or(Capabilities { + supports_step_back: Some(false), + ..Default::default() + }), + )), + request, + program: None, + supports_attach: false, + cwd: None, + initialize_args: None, + } +} diff --git a/crates/dap/src/proto_conversions.rs b/crates/dap/src/proto_conversions.rs new file mode 100644 index 0000000000..097593b11c --- /dev/null +++ b/crates/dap/src/proto_conversions.rs @@ -0,0 +1,591 @@ +use anyhow::{anyhow, Result}; +use client::proto::{ + self, DapChecksum, DapChecksumAlgorithm, DapEvaluateContext, DapModule, DapScope, + DapScopePresentationHint, DapSource, DapSourcePresentationHint, DapStackFrame, DapVariable, +}; +use dap_types::{OutputEventCategory, OutputEventGroup, ScopePresentationHint, Source}; + +pub trait ProtoConversion { + type ProtoType; + type Output; + + fn to_proto(&self) -> Self::ProtoType; + fn from_proto(payload: Self::ProtoType) -> Self::Output; +} + +impl ProtoConversion for Vec +where + T: ProtoConversion, +{ + type ProtoType = Vec; + type Output = Self; + + fn to_proto(&self) -> Self::ProtoType { + self.iter().map(|item| item.to_proto()).collect() + } + + fn from_proto(payload: Self::ProtoType) -> Self { + payload + .into_iter() + .map(|item| T::from_proto(item)) + .collect() + } +} + +impl ProtoConversion for dap_types::Scope { + type ProtoType = DapScope; + type Output = Self; + + fn to_proto(&self) -> Self::ProtoType { + Self::ProtoType { + name: self.name.clone(), + presentation_hint: self + .presentation_hint + .as_ref() + .map(|hint| hint.to_proto().into()), + variables_reference: self.variables_reference, + named_variables: self.named_variables, + indexed_variables: self.indexed_variables, + expensive: self.expensive, + source: self.source.as_ref().map(Source::to_proto), + line: self.line, + end_line: self.end_line, + column: self.column, + end_column: self.end_column, + } + } + + fn from_proto(payload: Self::ProtoType) -> Self { + let presentation_hint = payload + .presentation_hint + .and_then(DapScopePresentationHint::from_i32); + Self { + name: payload.name, + presentation_hint: presentation_hint.map(ScopePresentationHint::from_proto), + variables_reference: payload.variables_reference, + named_variables: payload.named_variables, + indexed_variables: payload.indexed_variables, + expensive: payload.expensive, + source: payload.source.map(dap_types::Source::from_proto), + line: payload.line, + end_line: payload.end_line, + column: payload.column, + end_column: payload.end_column, + } + } +} + +impl ProtoConversion for dap_types::Variable { + type ProtoType = DapVariable; + type Output = Self; + + fn to_proto(&self) -> Self::ProtoType { + Self::ProtoType { + name: self.name.clone(), + value: self.value.clone(), + r#type: self.type_.clone(), + evaluate_name: self.evaluate_name.clone(), + variables_reference: self.variables_reference, + named_variables: self.named_variables, + indexed_variables: self.indexed_variables, + memory_reference: self.memory_reference.clone(), + } + } + + fn from_proto(payload: Self::ProtoType) -> Self { + Self { + name: payload.name, + value: payload.value, + type_: payload.r#type, + evaluate_name: payload.evaluate_name, + presentation_hint: None, // TODO Debugger Collab Add this + variables_reference: payload.variables_reference, + named_variables: payload.named_variables, + indexed_variables: payload.indexed_variables, + memory_reference: payload.memory_reference, + declaration_location_reference: None, // TODO + value_location_reference: None, // TODO + } + } +} + +impl ProtoConversion for dap_types::ScopePresentationHint { + type ProtoType = DapScopePresentationHint; + type Output = Self; + + fn to_proto(&self) -> Self::ProtoType { + match self { + dap_types::ScopePresentationHint::Locals => DapScopePresentationHint::Locals, + dap_types::ScopePresentationHint::Arguments => DapScopePresentationHint::Arguments, + dap_types::ScopePresentationHint::Registers => DapScopePresentationHint::Registers, + dap_types::ScopePresentationHint::ReturnValue => DapScopePresentationHint::ReturnValue, + dap_types::ScopePresentationHint::Unknown => DapScopePresentationHint::ScopeUnknown, + &_ => unreachable!(), + } + } + + fn from_proto(payload: Self::ProtoType) -> Self { + match payload { + DapScopePresentationHint::Locals => dap_types::ScopePresentationHint::Locals, + DapScopePresentationHint::Arguments => dap_types::ScopePresentationHint::Arguments, + DapScopePresentationHint::Registers => dap_types::ScopePresentationHint::Registers, + DapScopePresentationHint::ReturnValue => dap_types::ScopePresentationHint::ReturnValue, + DapScopePresentationHint::ScopeUnknown => dap_types::ScopePresentationHint::Unknown, + } + } +} + +impl ProtoConversion for dap_types::SourcePresentationHint { + type ProtoType = DapSourcePresentationHint; + type Output = Self; + + fn to_proto(&self) -> Self::ProtoType { + match self { + dap_types::SourcePresentationHint::Normal => DapSourcePresentationHint::SourceNormal, + dap_types::SourcePresentationHint::Emphasize => DapSourcePresentationHint::Emphasize, + dap_types::SourcePresentationHint::Deemphasize => { + DapSourcePresentationHint::Deemphasize + } + dap_types::SourcePresentationHint::Unknown => DapSourcePresentationHint::SourceUnknown, + } + } + + fn from_proto(payload: Self::ProtoType) -> Self { + match payload { + DapSourcePresentationHint::SourceNormal => dap_types::SourcePresentationHint::Normal, + DapSourcePresentationHint::Emphasize => dap_types::SourcePresentationHint::Emphasize, + DapSourcePresentationHint::Deemphasize => { + dap_types::SourcePresentationHint::Deemphasize + } + DapSourcePresentationHint::SourceUnknown => dap_types::SourcePresentationHint::Unknown, + } + } +} + +impl ProtoConversion for dap_types::Checksum { + type ProtoType = DapChecksum; + type Output = Self; + + fn to_proto(&self) -> Self::ProtoType { + DapChecksum { + algorithm: self.algorithm.to_proto().into(), + checksum: self.checksum.clone(), + } + } + + fn from_proto(payload: Self::ProtoType) -> Self { + Self { + algorithm: dap_types::ChecksumAlgorithm::from_proto(payload.algorithm()), + checksum: payload.checksum, + } + } +} + +impl ProtoConversion for dap_types::ChecksumAlgorithm { + type ProtoType = DapChecksumAlgorithm; + type Output = Self; + + fn to_proto(&self) -> Self::ProtoType { + match self { + dap_types::ChecksumAlgorithm::Md5 => DapChecksumAlgorithm::Md5, + dap_types::ChecksumAlgorithm::Sha1 => DapChecksumAlgorithm::Sha1, + dap_types::ChecksumAlgorithm::Sha256 => DapChecksumAlgorithm::Sha256, + dap_types::ChecksumAlgorithm::Timestamp => DapChecksumAlgorithm::Timestamp, + } + } + + fn from_proto(payload: Self::ProtoType) -> Self { + match payload { + DapChecksumAlgorithm::Md5 => dap_types::ChecksumAlgorithm::Md5, + DapChecksumAlgorithm::Sha1 => dap_types::ChecksumAlgorithm::Sha1, + DapChecksumAlgorithm::Sha256 => dap_types::ChecksumAlgorithm::Sha256, + DapChecksumAlgorithm::Timestamp => dap_types::ChecksumAlgorithm::Timestamp, + DapChecksumAlgorithm::ChecksumAlgorithmUnspecified => unreachable!(), + } + } +} + +impl ProtoConversion for dap_types::Source { + type ProtoType = DapSource; + type Output = Self; + + fn to_proto(&self) -> Self::ProtoType { + Self::ProtoType { + name: self.name.clone(), + path: self.path.clone(), + source_reference: self.source_reference, + presentation_hint: self.presentation_hint.map(|hint| hint.to_proto().into()), + origin: self.origin.clone(), + sources: self + .sources + .clone() + .map(|src| src.to_proto()) + .unwrap_or_default(), + adapter_data: Default::default(), // TODO Debugger Collab + checksums: self + .checksums + .clone() + .map(|c| c.to_proto()) + .unwrap_or_default(), + } + } + + fn from_proto(payload: Self::ProtoType) -> Self { + Self { + name: payload.name.clone(), + path: payload.path.clone(), + source_reference: payload.source_reference, + presentation_hint: payload + .presentation_hint + .and_then(DapSourcePresentationHint::from_i32) + .map(dap_types::SourcePresentationHint::from_proto), + origin: payload.origin.clone(), + sources: Some(Vec::::from_proto(payload.sources)), + checksums: Some(Vec::::from_proto(payload.checksums)), + adapter_data: None, // TODO Debugger Collab + } + } +} + +impl ProtoConversion for dap_types::StackFrame { + type ProtoType = DapStackFrame; + type Output = Self; + + fn to_proto(&self) -> Self::ProtoType { + Self::ProtoType { + id: self.id, + name: self.name.clone(), + source: self.source.as_ref().map(|src| src.to_proto()), + line: self.line, + column: self.column, + end_line: self.end_line, + end_column: self.end_column, + can_restart: self.can_restart, + instruction_pointer_reference: self.instruction_pointer_reference.clone(), + module_id: None, // TODO Debugger Collab + presentation_hint: None, // TODO Debugger Collab + } + } + + fn from_proto(payload: Self::ProtoType) -> Self { + Self { + id: payload.id, + name: payload.name, + source: payload.source.map(dap_types::Source::from_proto), + line: payload.line, + column: payload.column, + end_line: payload.end_line, + end_column: payload.end_column, + can_restart: payload.can_restart, + instruction_pointer_reference: payload.instruction_pointer_reference, + module_id: None, // TODO Debugger Collab + presentation_hint: None, // TODO Debugger Collab + } + } +} + +impl ProtoConversion for dap_types::Module { + type ProtoType = DapModule; + type Output = Result; + + fn to_proto(&self) -> Self::ProtoType { + let id = match &self.id { + dap_types::ModuleId::Number(num) => proto::dap_module_id::Id::Number(*num), + dap_types::ModuleId::String(string) => proto::dap_module_id::Id::String(string.clone()), + }; + + DapModule { + id: Some(proto::DapModuleId { id: Some(id) }), + name: self.name.clone(), + path: self.path.clone(), + is_optimized: self.is_optimized, + is_user_code: self.is_user_code, + version: self.version.clone(), + symbol_status: self.symbol_status.clone(), + symbol_file_path: self.symbol_file_path.clone(), + date_time_stamp: self.date_time_stamp.clone(), + address_range: self.address_range.clone(), + } + } + + fn from_proto(payload: Self::ProtoType) -> Result { + let id = match payload + .id + .ok_or(anyhow!("All DapModule proto messages must have an id"))? + .id + .ok_or(anyhow!("All DapModuleID proto messages must have an id"))? + { + proto::dap_module_id::Id::String(string) => dap_types::ModuleId::String(string), + proto::dap_module_id::Id::Number(num) => dap_types::ModuleId::Number(num), + }; + + Ok(Self { + id, + name: payload.name, + path: payload.path, + is_optimized: payload.is_optimized, + is_user_code: payload.is_user_code, + version: payload.version, + symbol_status: payload.symbol_status, + symbol_file_path: payload.symbol_file_path, + date_time_stamp: payload.date_time_stamp, + address_range: payload.address_range, + }) + } +} + +impl ProtoConversion for dap_types::SteppingGranularity { + type ProtoType = proto::SteppingGranularity; + type Output = Self; + + fn to_proto(&self) -> Self::ProtoType { + match self { + dap_types::SteppingGranularity::Statement => proto::SteppingGranularity::Statement, + dap_types::SteppingGranularity::Line => proto::SteppingGranularity::Line, + dap_types::SteppingGranularity::Instruction => proto::SteppingGranularity::Instruction, + } + } + + fn from_proto(payload: Self::ProtoType) -> Self { + match payload { + proto::SteppingGranularity::Line => dap_types::SteppingGranularity::Line, + proto::SteppingGranularity::Instruction => dap_types::SteppingGranularity::Instruction, + proto::SteppingGranularity::Statement => dap_types::SteppingGranularity::Statement, + } + } +} + +impl ProtoConversion for dap_types::OutputEventCategory { + type ProtoType = proto::DapOutputCategory; + type Output = Self; + + fn to_proto(&self) -> Self::ProtoType { + match self { + Self::Console => proto::DapOutputCategory::ConsoleOutput, + Self::Important => proto::DapOutputCategory::Important, + Self::Stdout => proto::DapOutputCategory::Stdout, + Self::Stderr => proto::DapOutputCategory::Stderr, + _ => proto::DapOutputCategory::Unknown, + } + } + + fn from_proto(payload: Self::ProtoType) -> Self { + match payload { + proto::DapOutputCategory::ConsoleOutput => Self::Console, + proto::DapOutputCategory::Important => Self::Important, + proto::DapOutputCategory::Stdout => Self::Stdout, + proto::DapOutputCategory::Stderr => Self::Stderr, + proto::DapOutputCategory::Unknown => Self::Unknown, + } + } +} + +impl ProtoConversion for dap_types::OutputEvent { + type ProtoType = proto::DapOutputEvent; + type Output = Self; + + fn to_proto(&self) -> Self::ProtoType { + proto::DapOutputEvent { + category: self + .category + .as_ref() + .map(|category| category.to_proto().into()), + output: self.output.clone(), + variables_reference: self.variables_reference, + source: self.source.as_ref().map(|source| source.to_proto()), + line: self.line.map(|line| line as u32), + column: self.column.map(|column| column as u32), + group: self.group.map(|group| group.to_proto().into()), + } + } + + fn from_proto(payload: Self::ProtoType) -> Self { + dap_types::OutputEvent { + category: payload + .category + .and_then(proto::DapOutputCategory::from_i32) + .map(OutputEventCategory::from_proto), + output: payload.output.clone(), + variables_reference: payload.variables_reference, + source: payload.source.map(Source::from_proto), + line: payload.line.map(|line| line as u64), + column: payload.column.map(|column| column as u64), + group: payload + .group + .and_then(proto::DapOutputEventGroup::from_i32) + .map(OutputEventGroup::from_proto), + data: None, + location_reference: None, + } + } +} + +impl ProtoConversion for dap_types::OutputEventGroup { + type ProtoType = proto::DapOutputEventGroup; + type Output = Self; + + fn to_proto(&self) -> Self::ProtoType { + match self { + dap_types::OutputEventGroup::Start => proto::DapOutputEventGroup::Start, + dap_types::OutputEventGroup::StartCollapsed => { + proto::DapOutputEventGroup::StartCollapsed + } + dap_types::OutputEventGroup::End => proto::DapOutputEventGroup::End, + } + } + + fn from_proto(payload: Self::ProtoType) -> Self { + match payload { + proto::DapOutputEventGroup::Start => Self::Start, + proto::DapOutputEventGroup::StartCollapsed => Self::StartCollapsed, + proto::DapOutputEventGroup::End => Self::End, + } + } +} + +impl ProtoConversion for dap_types::CompletionItem { + type ProtoType = proto::DapCompletionItem; + type Output = Self; + + fn to_proto(&self) -> Self::ProtoType { + proto::DapCompletionItem { + label: self.label.clone(), + text: self.text.clone(), + detail: self.detail.clone(), + typ: self + .type_ + .as_ref() + .map(ProtoConversion::to_proto) + .map(|typ| typ.into()), + start: self.start, + length: self.length, + selection_start: self.selection_start, + selection_length: self.selection_length, + sort_text: self.sort_text.clone(), + } + } + + fn from_proto(payload: Self::ProtoType) -> Self { + let typ = payload.typ(); // todo(debugger): This might be a potential issue/bug because it defaults to a type when it's None + + Self { + label: payload.label, + detail: payload.detail, + sort_text: payload.sort_text, + text: payload.text.clone(), + type_: Some(dap_types::CompletionItemType::from_proto(typ)), + start: payload.start, + length: payload.length, + selection_start: payload.selection_start, + selection_length: payload.selection_length, + } + } +} + +impl ProtoConversion for dap_types::EvaluateArgumentsContext { + type ProtoType = DapEvaluateContext; + type Output = Self; + + fn to_proto(&self) -> Self::ProtoType { + match self { + dap_types::EvaluateArgumentsContext::Variables => { + proto::DapEvaluateContext::EvaluateVariables + } + dap_types::EvaluateArgumentsContext::Watch => proto::DapEvaluateContext::Watch, + dap_types::EvaluateArgumentsContext::Hover => proto::DapEvaluateContext::Hover, + dap_types::EvaluateArgumentsContext::Repl => proto::DapEvaluateContext::Repl, + dap_types::EvaluateArgumentsContext::Clipboard => proto::DapEvaluateContext::Clipboard, + dap_types::EvaluateArgumentsContext::Unknown => { + proto::DapEvaluateContext::EvaluateUnknown + } + _ => proto::DapEvaluateContext::EvaluateUnknown, + } + } + + fn from_proto(payload: Self::ProtoType) -> Self { + match payload { + proto::DapEvaluateContext::EvaluateVariables => { + dap_types::EvaluateArgumentsContext::Variables + } + proto::DapEvaluateContext::Watch => dap_types::EvaluateArgumentsContext::Watch, + proto::DapEvaluateContext::Hover => dap_types::EvaluateArgumentsContext::Hover, + proto::DapEvaluateContext::Repl => dap_types::EvaluateArgumentsContext::Repl, + proto::DapEvaluateContext::Clipboard => dap_types::EvaluateArgumentsContext::Clipboard, + proto::DapEvaluateContext::EvaluateUnknown => { + dap_types::EvaluateArgumentsContext::Unknown + } + } + } +} + +impl ProtoConversion for dap_types::CompletionItemType { + type ProtoType = proto::DapCompletionItemType; + type Output = Self; + + fn to_proto(&self) -> Self::ProtoType { + match self { + dap_types::CompletionItemType::Class => proto::DapCompletionItemType::Class, + dap_types::CompletionItemType::Color => proto::DapCompletionItemType::Color, + dap_types::CompletionItemType::Constructor => proto::DapCompletionItemType::Constructor, + dap_types::CompletionItemType::Customcolor => proto::DapCompletionItemType::Customcolor, + dap_types::CompletionItemType::Enum => proto::DapCompletionItemType::Enum, + dap_types::CompletionItemType::Field => proto::DapCompletionItemType::Field, + dap_types::CompletionItemType::File => proto::DapCompletionItemType::CompletionItemFile, + dap_types::CompletionItemType::Function => proto::DapCompletionItemType::Function, + dap_types::CompletionItemType::Interface => proto::DapCompletionItemType::Interface, + dap_types::CompletionItemType::Keyword => proto::DapCompletionItemType::Keyword, + dap_types::CompletionItemType::Method => proto::DapCompletionItemType::Method, + dap_types::CompletionItemType::Module => proto::DapCompletionItemType::Module, + dap_types::CompletionItemType::Property => proto::DapCompletionItemType::Property, + dap_types::CompletionItemType::Reference => proto::DapCompletionItemType::Reference, + dap_types::CompletionItemType::Snippet => proto::DapCompletionItemType::Snippet, + dap_types::CompletionItemType::Text => proto::DapCompletionItemType::Text, + dap_types::CompletionItemType::Unit => proto::DapCompletionItemType::Unit, + dap_types::CompletionItemType::Value => proto::DapCompletionItemType::Value, + dap_types::CompletionItemType::Variable => proto::DapCompletionItemType::Variable, + } + } + + fn from_proto(payload: Self::ProtoType) -> Self { + match payload { + proto::DapCompletionItemType::Class => dap_types::CompletionItemType::Class, + proto::DapCompletionItemType::Color => dap_types::CompletionItemType::Color, + proto::DapCompletionItemType::CompletionItemFile => dap_types::CompletionItemType::File, + proto::DapCompletionItemType::Constructor => dap_types::CompletionItemType::Constructor, + proto::DapCompletionItemType::Customcolor => dap_types::CompletionItemType::Customcolor, + proto::DapCompletionItemType::Enum => dap_types::CompletionItemType::Enum, + proto::DapCompletionItemType::Field => dap_types::CompletionItemType::Field, + proto::DapCompletionItemType::Function => dap_types::CompletionItemType::Function, + proto::DapCompletionItemType::Interface => dap_types::CompletionItemType::Interface, + proto::DapCompletionItemType::Keyword => dap_types::CompletionItemType::Keyword, + proto::DapCompletionItemType::Method => dap_types::CompletionItemType::Method, + proto::DapCompletionItemType::Module => dap_types::CompletionItemType::Module, + proto::DapCompletionItemType::Property => dap_types::CompletionItemType::Property, + proto::DapCompletionItemType::Reference => dap_types::CompletionItemType::Reference, + proto::DapCompletionItemType::Snippet => dap_types::CompletionItemType::Snippet, + proto::DapCompletionItemType::Text => dap_types::CompletionItemType::Text, + proto::DapCompletionItemType::Unit => dap_types::CompletionItemType::Unit, + proto::DapCompletionItemType::Value => dap_types::CompletionItemType::Value, + proto::DapCompletionItemType::Variable => dap_types::CompletionItemType::Variable, + } + } +} + +impl ProtoConversion for dap_types::Thread { + type ProtoType = proto::DapThread; + type Output = Self; + + fn to_proto(&self) -> Self::ProtoType { + proto::DapThread { + id: self.id, + name: self.name.clone(), + } + } + + fn from_proto(payload: Self::ProtoType) -> Self { + Self { + id: payload.id, + name: payload.name, + } + } +} diff --git a/crates/dap/src/transport.rs b/crates/dap/src/transport.rs new file mode 100644 index 0000000000..d960503b55 --- /dev/null +++ b/crates/dap/src/transport.rs @@ -0,0 +1,891 @@ +use anyhow::{anyhow, bail, Context, Result}; +use dap_types::{ + messages::{Message, Response}, + ErrorResponse, +}; +use futures::{channel::oneshot, select, AsyncRead, AsyncReadExt as _, AsyncWrite, FutureExt as _}; +use gpui::AsyncApp; +use settings::Settings as _; +use smallvec::SmallVec; +use smol::{ + channel::{unbounded, Receiver, Sender}, + io::{AsyncBufReadExt as _, AsyncWriteExt, BufReader}, + lock::Mutex, + net::{TcpListener, TcpStream}, + process::Child, +}; +use std::{ + collections::HashMap, + net::{Ipv4Addr, SocketAddrV4}, + process::Stdio, + sync::Arc, + time::Duration, +}; +use task::TCPHost; +use util::ResultExt as _; + +use crate::{adapters::DebugAdapterBinary, debugger_settings::DebuggerSettings}; + +pub type IoHandler = Box; + +#[derive(PartialEq, Eq, Clone, Copy)] +pub enum LogKind { + Adapter, + Rpc, +} + +pub enum IoKind { + StdIn, + StdOut, + StdErr, +} + +pub struct TransportPipe { + input: Box, + output: Box, + stdout: Option>, + stderr: Option>, +} + +impl TransportPipe { + pub fn new( + input: Box, + output: Box, + stdout: Option>, + stderr: Option>, + ) -> Self { + TransportPipe { + input, + output, + stdout, + stderr, + } + } +} + +type Requests = Arc>>>>; +type LogHandlers = Arc>>; + +pub enum Transport { + Stdio(StdioTransport), + Tcp(TcpTransport), + #[cfg(any(test, feature = "test-support"))] + Fake(FakeTransport), +} + +impl Transport { + #[cfg(any(test, feature = "test-support"))] + async fn start(_: &DebugAdapterBinary, cx: AsyncApp) -> Result<(TransportPipe, Self)> { + #[cfg(any(test, feature = "test-support"))] + return FakeTransport::start(cx) + .await + .map(|(transports, fake)| (transports, Self::Fake(fake))); + } + + #[cfg(not(any(test, feature = "test-support")))] + async fn start(binary: &DebugAdapterBinary, cx: AsyncApp) -> Result<(TransportPipe, Self)> { + if binary.connection.is_some() { + TcpTransport::start(binary, cx) + .await + .map(|(transports, tcp)| (transports, Self::Tcp(tcp))) + } else { + StdioTransport::start(binary, cx) + .await + .map(|(transports, stdio)| (transports, Self::Stdio(stdio))) + } + } + + fn has_adapter_logs(&self) -> bool { + match self { + Transport::Stdio(stdio_transport) => stdio_transport.has_adapter_logs(), + Transport::Tcp(tcp_transport) => tcp_transport.has_adapter_logs(), + #[cfg(any(test, feature = "test-support"))] + Transport::Fake(fake_transport) => fake_transport.has_adapter_logs(), + } + } + + async fn kill(&self) -> Result<()> { + match self { + Transport::Stdio(stdio_transport) => stdio_transport.kill().await, + Transport::Tcp(tcp_transport) => tcp_transport.kill().await, + #[cfg(any(test, feature = "test-support"))] + Transport::Fake(fake_transport) => fake_transport.kill().await, + } + } + + #[cfg(any(test, feature = "test-support"))] + pub(crate) fn as_fake(&self) -> &FakeTransport { + match self { + Transport::Fake(fake_transport) => fake_transport, + _ => panic!("Not a fake transport layer"), + } + } +} + +pub(crate) struct TransportDelegate { + log_handlers: LogHandlers, + current_requests: Requests, + pending_requests: Requests, + transport: Transport, + server_tx: Arc>>>, +} + +impl TransportDelegate { + pub(crate) async fn start( + binary: &DebugAdapterBinary, + cx: AsyncApp, + ) -> Result<((Receiver, Sender), Self)> { + let (transport_pipes, transport) = Transport::start(binary, cx.clone()).await?; + let mut this = Self { + transport, + server_tx: Default::default(), + log_handlers: Default::default(), + current_requests: Default::default(), + pending_requests: Default::default(), + }; + let messages = this.start_handlers(transport_pipes, cx).await?; + Ok((messages, this)) + } + + async fn start_handlers( + &mut self, + mut params: TransportPipe, + cx: AsyncApp, + ) -> Result<(Receiver, Sender)> { + let (client_tx, server_rx) = unbounded::(); + let (server_tx, client_rx) = unbounded::(); + + let log_dap_communications = + cx.update(|cx| DebuggerSettings::get_global(cx).log_dap_communications) + .with_context(|| "Failed to get Debugger Setting log dap communications error in transport::start_handlers. Defaulting to false") + .unwrap_or(false); + + let log_handler = if log_dap_communications { + Some(self.log_handlers.clone()) + } else { + None + }; + + cx.update(|cx| { + if let Some(stdout) = params.stdout.take() { + cx.background_executor() + .spawn(Self::handle_adapter_log(stdout, log_handler.clone())) + .detach_and_log_err(cx); + } + + cx.background_executor() + .spawn(Self::handle_output( + params.output, + client_tx, + self.pending_requests.clone(), + log_handler.clone(), + )) + .detach_and_log_err(cx); + + if let Some(stderr) = params.stderr.take() { + cx.background_executor() + .spawn(Self::handle_error(stderr, self.log_handlers.clone())) + .detach_and_log_err(cx); + } + + cx.background_executor() + .spawn(Self::handle_input( + params.input, + client_rx, + self.current_requests.clone(), + self.pending_requests.clone(), + log_handler.clone(), + )) + .detach_and_log_err(cx); + })?; + + { + let mut lock = self.server_tx.lock().await; + *lock = Some(server_tx.clone()); + } + + Ok((server_rx, server_tx)) + } + + pub(crate) async fn add_pending_request( + &self, + sequence_id: u64, + request: oneshot::Sender>, + ) { + let mut pending_requests = self.pending_requests.lock().await; + pending_requests.insert(sequence_id, request); + } + + pub(crate) async fn cancel_pending_request(&self, sequence_id: &u64) { + let mut pending_requests = self.pending_requests.lock().await; + pending_requests.remove(sequence_id); + } + + pub(crate) async fn send_message(&self, message: Message) -> Result<()> { + if let Some(server_tx) = self.server_tx.lock().await.as_ref() { + server_tx + .send(message) + .await + .map_err(|e| anyhow!("Failed to send message: {}", e)) + } else { + Err(anyhow!("Server tx already dropped")) + } + } + + async fn handle_adapter_log( + stdout: Stdout, + log_handlers: Option, + ) -> Result<()> + where + Stdout: AsyncRead + Unpin + Send + 'static, + { + let mut reader = BufReader::new(stdout); + let mut line = String::new(); + + let result = loop { + line.truncate(0); + + let bytes_read = match reader.read_line(&mut line).await { + Ok(bytes_read) => bytes_read, + Err(e) => break Err(e.into()), + }; + + if bytes_read == 0 { + break Err(anyhow!("Debugger log stream closed")); + } + + if let Some(log_handlers) = log_handlers.as_ref() { + for (kind, handler) in log_handlers.lock().iter_mut() { + if matches!(kind, LogKind::Adapter) { + handler(IoKind::StdOut, line.as_str()); + } + } + } + + smol::future::yield_now().await; + }; + + log::debug!("Handle adapter log dropped"); + + result + } + + fn build_rpc_message(message: String) -> String { + format!("Content-Length: {}\r\n\r\n{}", message.len(), message) + } + + async fn handle_input( + mut server_stdin: Stdin, + client_rx: Receiver, + current_requests: Requests, + pending_requests: Requests, + log_handlers: Option, + ) -> Result<()> + where + Stdin: AsyncWrite + Unpin + Send + 'static, + { + let result = loop { + match client_rx.recv().await { + Ok(message) => { + if let Message::Request(request) = &message { + if let Some(sender) = current_requests.lock().await.remove(&request.seq) { + pending_requests.lock().await.insert(request.seq, sender); + } + } + + let message = match serde_json::to_string(&message) { + Ok(message) => message, + Err(e) => break Err(e.into()), + }; + + if let Some(log_handlers) = log_handlers.as_ref() { + for (kind, log_handler) in log_handlers.lock().iter_mut() { + if matches!(kind, LogKind::Rpc) { + log_handler(IoKind::StdIn, &message); + } + } + } + + if let Err(e) = server_stdin + .write_all(Self::build_rpc_message(message).as_bytes()) + .await + { + break Err(e.into()); + } + + if let Err(e) = server_stdin.flush().await { + break Err(e.into()); + } + } + Err(error) => break Err(error.into()), + } + + smol::future::yield_now().await; + }; + + log::debug!("Handle adapter input dropped"); + + result + } + + async fn handle_output( + server_stdout: Stdout, + client_tx: Sender, + pending_requests: Requests, + log_handlers: Option, + ) -> Result<()> + where + Stdout: AsyncRead + Unpin + Send + 'static, + { + let mut recv_buffer = String::new(); + let mut reader = BufReader::new(server_stdout); + + let result = loop { + let message = + Self::receive_server_message(&mut reader, &mut recv_buffer, log_handlers.as_ref()) + .await; + + match message { + Ok(Message::Response(res)) => { + if let Some(tx) = pending_requests.lock().await.remove(&res.request_seq) { + if let Err(e) = tx.send(Self::process_response(res)) { + log::trace!("Did not send response `{:?}` for a cancelled", e); + } + } else { + client_tx.send(Message::Response(res)).await?; + }; + } + Ok(message) => { + client_tx.send(message).await?; + } + Err(e) => break Err(e), + } + + smol::future::yield_now().await; + }; + + drop(client_tx); + + log::debug!("Handle adapter output dropped"); + + result + } + + async fn handle_error(stderr: Stderr, log_handlers: LogHandlers) -> Result<()> + where + Stderr: AsyncRead + Unpin + Send + 'static, + { + let mut buffer = String::new(); + + let mut reader = BufReader::new(stderr); + + let result = loop { + match reader.read_line(&mut buffer).await { + Ok(0) => break Err(anyhow!("debugger error stream closed")), + Ok(_) => { + for (kind, log_handler) in log_handlers.lock().iter_mut() { + if matches!(kind, LogKind::Adapter) { + log_handler(IoKind::StdErr, buffer.as_str()); + } + } + + buffer.truncate(0); + } + Err(error) => break Err(error.into()), + } + + smol::future::yield_now().await; + }; + + log::debug!("Handle adapter error dropped"); + + result + } + + fn process_response(response: Response) -> Result { + if response.success { + Ok(response) + } else { + if let Some(error_message) = response + .body + .clone() + .and_then(|body| serde_json::from_value::(body).ok()) + .and_then(|response| response.error.map(|msg| msg.format)) + .or_else(|| response.message.clone()) + { + return Err(anyhow!(error_message)); + }; + + Err(anyhow!( + "Received error response from adapter. Response: {:?}", + response.clone() + )) + } + } + + async fn receive_server_message( + reader: &mut BufReader, + buffer: &mut String, + log_handlers: Option<&LogHandlers>, + ) -> Result + where + Stdout: AsyncRead + Unpin + Send + 'static, + { + let mut content_length = None; + loop { + buffer.truncate(0); + + if reader + .read_line(buffer) + .await + .with_context(|| "reading a message from server")? + == 0 + { + return Err(anyhow!("debugger reader stream closed")); + }; + + if buffer == "\r\n" { + break; + } + + let parts = buffer.trim().split_once(": "); + + match parts { + Some(("Content-Length", value)) => { + content_length = Some(value.parse().context("invalid content length")?); + } + _ => {} + } + } + + let content_length = content_length.context("missing content length")?; + + let mut content = vec![0; content_length]; + reader + .read_exact(&mut content) + .await + .with_context(|| "reading after a loop")?; + + let message = std::str::from_utf8(&content).context("invalid utf8 from server")?; + + if let Some(log_handlers) = log_handlers { + for (kind, log_handler) in log_handlers.lock().iter_mut() { + if matches!(kind, LogKind::Rpc) { + log_handler(IoKind::StdOut, &message); + } + } + } + + Ok(serde_json::from_str::(message)?) + } + + pub async fn shutdown(&self) -> Result<()> { + log::debug!("Start shutdown client"); + + if let Some(server_tx) = self.server_tx.lock().await.take().as_ref() { + server_tx.close(); + } + + let mut current_requests = self.current_requests.lock().await; + let mut pending_requests = self.pending_requests.lock().await; + + current_requests.clear(); + pending_requests.clear(); + + let _ = self.transport.kill().await.log_err(); + + drop(current_requests); + drop(pending_requests); + + log::debug!("Shutdown client completed"); + + anyhow::Ok(()) + } + + pub fn has_adapter_logs(&self) -> bool { + self.transport.has_adapter_logs() + } + + pub fn transport(&self) -> &Transport { + &self.transport + } + + pub fn add_log_handler(&self, f: F, kind: LogKind) + where + F: 'static + Send + FnMut(IoKind, &str), + { + let mut log_handlers = self.log_handlers.lock(); + log_handlers.push((kind, Box::new(f))); + } +} + +pub struct TcpTransport { + pub port: u16, + pub host: Ipv4Addr, + pub timeout: u64, + process: Mutex, +} + +impl TcpTransport { + /// Get an open port to use with the tcp client when not supplied by debug config + pub async fn port(host: &TCPHost) -> Result { + if let Some(port) = host.port { + Ok(port) + } else { + Ok(TcpListener::bind(SocketAddrV4::new(host.host(), 0)) + .await? + .local_addr()? + .port()) + } + } + + #[allow(dead_code, reason = "This is used in non test builds of Zed")] + async fn start(binary: &DebugAdapterBinary, cx: AsyncApp) -> Result<(TransportPipe, Self)> { + let Some(connection_args) = binary.connection.as_ref() else { + return Err(anyhow!("No connection arguments provided")); + }; + + let host = connection_args.host; + let port = connection_args.port; + + let mut command = util::command::new_smol_command(&binary.command); + + if let Some(cwd) = &binary.cwd { + command.current_dir(cwd); + } + + if let Some(args) = &binary.arguments { + command.args(args); + } + + if let Some(envs) = &binary.envs { + command.envs(envs); + } + + command + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + + let mut process = command + .spawn() + .with_context(|| "failed to start debug adapter.")?; + + let address = SocketAddrV4::new(host, port); + + let timeout = connection_args.timeout.unwrap_or_else(|| { + cx.update(|cx| DebuggerSettings::get_global(cx).timeout) + .unwrap_or(2000u64) + }); + + let (rx, tx) = select! { + _ = cx.background_executor().timer(Duration::from_millis(timeout)).fuse() => { + return Err(anyhow!(format!("Connection to TCP DAP timeout {}:{}", host, port))) + }, + result = cx.spawn(|cx| async move { + loop { + match TcpStream::connect(address).await { + Ok(stream) => return stream.split(), + Err(_) => { + cx.background_executor().timer(Duration::from_millis(100)).await; + } + } + } + }).fuse() => result + }; + log::info!( + "Debug adapter has connected to TCP server {}:{}", + host, + port + ); + let stdout = process.stdout.take(); + let stderr = process.stderr.take(); + + let this = Self { + port, + host, + process: Mutex::new(process), + timeout, + }; + + let pipe = TransportPipe::new( + Box::new(tx), + Box::new(BufReader::new(rx)), + stdout.map(|s| Box::new(s) as Box), + stderr.map(|s| Box::new(s) as Box), + ); + + Ok((pipe, this)) + } + + fn has_adapter_logs(&self) -> bool { + true + } + + async fn kill(&self) -> Result<()> { + self.process.lock().await.kill()?; + + Ok(()) + } +} + +pub struct StdioTransport { + process: Mutex, +} + +impl StdioTransport { + #[allow(dead_code, reason = "This is used in non test builds of Zed")] + async fn start(binary: &DebugAdapterBinary, _: AsyncApp) -> Result<(TransportPipe, Self)> { + let mut command = util::command::new_smol_command(&binary.command); + + if let Some(cwd) = &binary.cwd { + command.current_dir(cwd); + } + + if let Some(args) = &binary.arguments { + command.args(args); + } + + if let Some(envs) = &binary.envs { + command.envs(envs); + } + + command + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + + let mut process = command + .spawn() + .with_context(|| "failed to spawn command.")?; + + let stdin = process + .stdin + .take() + .ok_or_else(|| anyhow!("Failed to open stdin"))?; + let stdout = process + .stdout + .take() + .ok_or_else(|| anyhow!("Failed to open stdout"))?; + let stderr = process + .stderr + .take() + .map(|io_err| Box::new(io_err) as Box); + + if stderr.is_none() { + bail!( + "Failed to connect to stderr for debug adapter command {}", + &binary.command + ); + } + + log::info!("Debug adapter has connected to stdio adapter"); + + let process = Mutex::new(process); + + Ok(( + TransportPipe::new( + Box::new(stdin), + Box::new(BufReader::new(stdout)), + None, + stderr, + ), + Self { process }, + )) + } + + fn has_adapter_logs(&self) -> bool { + false + } + + async fn kill(&self) -> Result<()> { + self.process.lock().await.kill()?; + Ok(()) + } +} + +#[cfg(any(test, feature = "test-support"))] +type RequestHandler = Box< + dyn Send + + FnMut( + u64, + serde_json::Value, + Arc>, + ) -> std::pin::Pin + Send>>, +>; + +#[cfg(any(test, feature = "test-support"))] +type ResponseHandler = Box; + +#[cfg(any(test, feature = "test-support"))] +pub struct FakeTransport { + // for sending fake response back from adapter side + request_handlers: Arc>>, + // for reverse request responses + response_handlers: Arc>>, +} + +#[cfg(any(test, feature = "test-support"))] +impl FakeTransport { + pub async fn on_request(&self, mut handler: F) + where + F: 'static + Send + FnMut(u64, R::Arguments) -> Result, + { + self.request_handlers.lock().await.insert( + R::COMMAND, + Box::new( + move |seq, args, writer: Arc>| { + let response = handler(seq, serde_json::from_value(args).unwrap()); + + let message = serde_json::to_string(&Message::Response(Response { + seq: seq + 1, + request_seq: seq, + success: response.as_ref().is_ok(), + command: R::COMMAND.into(), + body: util::maybe!({ serde_json::to_value(response.ok()?).ok() }), + message: None, + })) + .unwrap(); + + let writer = writer.clone(); + + Box::pin(async move { + let mut writer = writer.lock().await; + writer + .write_all(TransportDelegate::build_rpc_message(message).as_bytes()) + .await + .unwrap(); + writer.flush().await.unwrap(); + }) + }, + ), + ); + } + + pub async fn on_response(&self, handler: F) + where + F: 'static + Send + Fn(Response), + { + self.response_handlers + .lock() + .await + .insert(R::COMMAND, Box::new(handler)); + } + + async fn start(cx: AsyncApp) -> Result<(TransportPipe, Self)> { + let this = Self { + request_handlers: Arc::new(Mutex::new(HashMap::default())), + response_handlers: Arc::new(Mutex::new(HashMap::default())), + }; + use dap_types::requests::{Request, RunInTerminal, StartDebugging}; + use serde_json::json; + + let (stdin_writer, stdin_reader) = async_pipe::pipe(); + let (stdout_writer, stdout_reader) = async_pipe::pipe(); + + let request_handlers = this.request_handlers.clone(); + let response_handlers = this.response_handlers.clone(); + let stdout_writer = Arc::new(Mutex::new(stdout_writer)); + + cx.background_executor() + .spawn(async move { + let mut reader = BufReader::new(stdin_reader); + let mut buffer = String::new(); + + loop { + let message = + TransportDelegate::receive_server_message(&mut reader, &mut buffer, None) + .await; + + match message { + Err(error) => { + break anyhow!(error); + } + Ok(message) => { + match message { + Message::Request(request) => { + // redirect reverse requests to stdout writer/reader + if request.command == RunInTerminal::COMMAND + || request.command == StartDebugging::COMMAND + { + let message = + serde_json::to_string(&Message::Request(request)) + .unwrap(); + + let mut writer = stdout_writer.lock().await; + writer + .write_all( + TransportDelegate::build_rpc_message(message) + .as_bytes(), + ) + .await + .unwrap(); + writer.flush().await.unwrap(); + } else { + if let Some(handle) = request_handlers + .lock() + .await + .get_mut(request.command.as_str()) + { + handle( + request.seq, + request.arguments.unwrap_or(json!({})), + stdout_writer.clone(), + ) + .await; + } else { + log::error!( + "No request handler for {}", + request.command + ); + } + } + } + Message::Event(event) => { + let message = + serde_json::to_string(&Message::Event(event)).unwrap(); + + let mut writer = stdout_writer.lock().await; + writer + .write_all( + TransportDelegate::build_rpc_message(message) + .as_bytes(), + ) + .await + .unwrap(); + writer.flush().await.unwrap(); + } + Message::Response(response) => { + if let Some(handle) = response_handlers + .lock() + .await + .get(response.command.as_str()) + { + handle(response); + } else { + log::error!("No response handler for {}", response.command); + } + } + } + } + } + } + }) + .detach(); + + Ok(( + TransportPipe::new(Box::new(stdin_writer), Box::new(stdout_reader), None, None), + this, + )) + } + + fn has_adapter_logs(&self) -> bool { + false + } + + async fn kill(&self) -> Result<()> { + Ok(()) + } +} diff --git a/crates/dap_adapters/Cargo.toml b/crates/dap_adapters/Cargo.toml new file mode 100644 index 0000000000..0fe055738a --- /dev/null +++ b/crates/dap_adapters/Cargo.toml @@ -0,0 +1,41 @@ +[package] +name = "dap_adapters" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[features] +test-support = [ + "dap/test-support", + "gpui/test-support", + "task/test-support", + "util/test-support", +] + +[lints] +workspace = true + +[lib] +path = "src/dap_adapters.rs" +doctest = false + +[dependencies] +anyhow.workspace = true +async-trait.workspace = true +dap.workspace = true +gpui.workspace = true +language.workspace = true +paths.workspace = true +regex.workspace = true +serde.workspace = true +serde_json.workspace = true +sysinfo.workspace = true +task.workspace = true +util.workspace = true + +[dev-dependencies] +dap = { workspace = true, features = ["test-support"] } +gpui = { workspace = true, features = ["test-support"] } +task = { workspace = true, features = ["test-support"] } +util = { workspace = true, features = ["test-support"] } diff --git a/crates/dap_adapters/LICENSE-GPL b/crates/dap_adapters/LICENSE-GPL new file mode 120000 index 0000000000..89e542f750 --- /dev/null +++ b/crates/dap_adapters/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/dap_adapters/src/custom.rs b/crates/dap_adapters/src/custom.rs new file mode 100644 index 0000000000..9598cf91cc --- /dev/null +++ b/crates/dap_adapters/src/custom.rs @@ -0,0 +1,84 @@ +use dap::transport::TcpTransport; +use gpui::AsyncApp; +use serde_json::Value; +use std::{collections::HashMap, ffi::OsString, path::PathBuf}; +use sysinfo::{Pid, Process}; +use task::DebugAdapterConfig; + +use crate::*; + +pub(crate) struct CustomDebugAdapter { + custom_args: CustomArgs, +} + +impl CustomDebugAdapter { + const ADAPTER_NAME: &'static str = "custom_dap"; + + pub(crate) async fn new(custom_args: CustomArgs) -> Result { + Ok(CustomDebugAdapter { custom_args }) + } + + pub fn attach_processes(processes: &HashMap) -> Vec<(&Pid, &Process)> { + processes.iter().collect::>() + } +} + +#[async_trait(?Send)] +impl DebugAdapter for CustomDebugAdapter { + fn name(&self) -> DebugAdapterName { + DebugAdapterName(Self::ADAPTER_NAME.into()) + } + + async fn get_binary( + &self, + _: &dyn DapDelegate, + config: &DebugAdapterConfig, + _: Option, + _: &mut AsyncApp, + ) -> Result { + let connection = if let DebugConnectionType::TCP(connection) = &self.custom_args.connection + { + Some(adapters::TcpArguments { + host: connection.host(), + port: TcpTransport::port(&connection).await?, + timeout: connection.timeout, + }) + } else { + None + }; + let ret = DebugAdapterBinary { + command: self.custom_args.command.clone(), + arguments: self + .custom_args + .args + .clone() + .map(|args| args.iter().map(OsString::from).collect()), + cwd: config.cwd.clone(), + envs: self.custom_args.envs.clone(), + connection, + }; + Ok(ret) + } + + async fn fetch_latest_adapter_version(&self, _: &dyn DapDelegate) -> Result { + bail!("Custom debug adapters don't have latest versions") + } + + async fn install_binary(&self, _: AdapterVersion, _: &dyn DapDelegate) -> Result<()> { + bail!("Custom debug adapters cannot be installed") + } + + async fn get_installed_binary( + &self, + _: &dyn DapDelegate, + _: &DebugAdapterConfig, + _: Option, + _: &mut AsyncApp, + ) -> Result { + bail!("Custom debug adapters cannot be installed") + } + + fn request_args(&self, config: &DebugAdapterConfig) -> Value { + json!({"program": config.program}) + } +} diff --git a/crates/dap_adapters/src/dap_adapters.rs b/crates/dap_adapters/src/dap_adapters.rs new file mode 100644 index 0000000000..6a576bb621 --- /dev/null +++ b/crates/dap_adapters/src/dap_adapters.rs @@ -0,0 +1,67 @@ +mod custom; +#[cfg(any(target_arch = "x86", target_arch = "x86_64"))] +mod gdb; +mod go; +mod javascript; +mod lldb; +mod php; +mod python; + +use std::{collections::HashMap, sync::Arc}; + +use anyhow::{anyhow, bail, Result}; +use async_trait::async_trait; +use custom::CustomDebugAdapter; +use dap::adapters::{ + self, AdapterVersion, DapDelegate, DebugAdapter, DebugAdapterBinary, DebugAdapterName, + GithubRepo, +}; +#[cfg(any(target_arch = "x86", target_arch = "x86_64"))] +use gdb::GdbDebugAdapter; +use go::GoDebugAdapter; +use javascript::JsDebugAdapter; +use lldb::LldbDebugAdapter; +use php::PhpDebugAdapter; +use python::PythonDebugAdapter; +use serde_json::{json, Value}; +use sysinfo::{Pid, Process}; +use task::{CustomArgs, DebugAdapterConfig, DebugAdapterKind, DebugConnectionType, TCPHost}; + +pub async fn build_adapter(kind: &DebugAdapterKind) -> Result> { + match kind { + DebugAdapterKind::Custom(start_args) => { + Ok(Arc::new(CustomDebugAdapter::new(start_args.clone()).await?)) + } + DebugAdapterKind::Python(host) => Ok(Arc::new(PythonDebugAdapter::new(host).await?)), + DebugAdapterKind::Php(host) => Ok(Arc::new(PhpDebugAdapter::new(host.clone()).await?)), + DebugAdapterKind::Javascript(host) => { + Ok(Arc::new(JsDebugAdapter::new(host.clone()).await?)) + } + DebugAdapterKind::Lldb => Ok(Arc::new(LldbDebugAdapter::new())), + DebugAdapterKind::Go(host) => Ok(Arc::new(GoDebugAdapter::new(host).await?)), + #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] + DebugAdapterKind::Gdb => Ok(Arc::new(GdbDebugAdapter::new())), + #[cfg(any(test, feature = "test-support"))] + DebugAdapterKind::Fake(_) => Ok(Arc::new(dap::adapters::FakeAdapter::new())), + #[cfg(not(any(test, feature = "test-support")))] + #[allow(unreachable_patterns)] + _ => unreachable!("Fake variant only exists with test-support feature"), + } +} + +pub fn attach_processes<'a>( + kind: &DebugAdapterKind, + processes: &'a HashMap, +) -> Vec<(&'a Pid, &'a Process)> { + match kind { + #[cfg(any(test, feature = "test-support"))] + DebugAdapterKind::Fake(_) => processes + .iter() + .filter(|(pid, _)| pid.as_u32() == std::process::id()) + .collect::>(), + DebugAdapterKind::Custom(_) => CustomDebugAdapter::attach_processes(processes), + DebugAdapterKind::Javascript(_) => JsDebugAdapter::attach_processes(processes), + DebugAdapterKind::Lldb => LldbDebugAdapter::attach_processes(processes), + _ => processes.iter().collect::>(), + } +} diff --git a/crates/dap_adapters/src/gdb.rs b/crates/dap_adapters/src/gdb.rs new file mode 100644 index 0000000000..0e3238b900 --- /dev/null +++ b/crates/dap_adapters/src/gdb.rs @@ -0,0 +1,83 @@ +use std::ffi::OsStr; + +use anyhow::Result; +use async_trait::async_trait; +use gpui::AsyncApp; +use task::DebugAdapterConfig; + +use crate::*; + +pub(crate) struct GdbDebugAdapter {} + +impl GdbDebugAdapter { + const ADAPTER_NAME: &'static str = "gdb"; + + pub(crate) fn new() -> Self { + GdbDebugAdapter {} + } +} + +#[async_trait(?Send)] +impl DebugAdapter for GdbDebugAdapter { + fn name(&self) -> DebugAdapterName { + DebugAdapterName(Self::ADAPTER_NAME.into()) + } + + async fn get_binary( + &self, + delegate: &dyn DapDelegate, + config: &DebugAdapterConfig, + user_installed_path: Option, + _: &mut AsyncApp, + ) -> Result { + let user_setting_path = user_installed_path + .filter(|p| p.exists()) + .and_then(|p| p.to_str().map(|s| s.to_string())); + + /* GDB implements DAP natively so just need to */ + let gdb_path = delegate + .which(OsStr::new("gdb")) + .and_then(|p| p.to_str().map(|s| s.to_string())) + .ok_or(anyhow!("Could not find gdb in path")); + + if gdb_path.is_err() && user_setting_path.is_none() { + bail!("Could not find gdb path or it's not installed"); + } + + let gdb_path = user_setting_path.unwrap_or(gdb_path?); + + Ok(DebugAdapterBinary { + command: gdb_path, + arguments: Some(vec!["-i=dap".into()]), + envs: None, + cwd: config.cwd.clone(), + connection: None, + }) + } + + async fn install_binary( + &self, + _version: AdapterVersion, + _delegate: &dyn DapDelegate, + ) -> Result<()> { + unimplemented!("GDB debug adapter cannot be installed by Zed (yet)") + } + + async fn fetch_latest_adapter_version(&self, _: &dyn DapDelegate) -> Result { + unimplemented!("Fetch latest GDB version not implemented (yet)") + } + + async fn get_installed_binary( + &self, + _: &dyn DapDelegate, + _: &DebugAdapterConfig, + _: Option, + _: &mut AsyncApp, + ) -> Result { + unimplemented!("GDB cannot be installed by Zed (yet)") + } + + fn request_args(&self, config: &DebugAdapterConfig) -> Value { + json!({"program": config.program, "cwd": config.cwd}) + } +} diff --git a/crates/dap_adapters/src/go.rs b/crates/dap_adapters/src/go.rs new file mode 100644 index 0000000000..94bd64d057 --- /dev/null +++ b/crates/dap_adapters/src/go.rs @@ -0,0 +1,100 @@ +use dap::transport::TcpTransport; +use gpui::AsyncApp; +use std::{ffi::OsStr, net::Ipv4Addr, path::PathBuf}; + +use crate::*; + +pub(crate) struct GoDebugAdapter { + port: u16, + host: Ipv4Addr, + timeout: Option, +} + +impl GoDebugAdapter { + const ADAPTER_NAME: &'static str = "delve"; + + pub(crate) async fn new(host: &TCPHost) -> Result { + Ok(GoDebugAdapter { + port: TcpTransport::port(host).await?, + host: host.host(), + timeout: host.timeout, + }) + } +} + +#[async_trait(?Send)] +impl DebugAdapter for GoDebugAdapter { + fn name(&self) -> DebugAdapterName { + DebugAdapterName(Self::ADAPTER_NAME.into()) + } + + async fn get_binary( + &self, + delegate: &dyn DapDelegate, + config: &DebugAdapterConfig, + user_installed_path: Option, + cx: &mut AsyncApp, + ) -> Result { + self.get_installed_binary(delegate, config, user_installed_path, cx) + .await + } + + async fn fetch_latest_adapter_version( + &self, + _delegate: &dyn DapDelegate, + ) -> Result { + unimplemented!("This adapter is used from path for now"); + } + + async fn install_binary( + &self, + version: AdapterVersion, + delegate: &dyn DapDelegate, + ) -> Result<()> { + adapters::download_adapter_from_github( + self.name(), + version, + adapters::DownloadedFileType::Zip, + delegate, + ) + .await?; + Ok(()) + } + + async fn get_installed_binary( + &self, + delegate: &dyn DapDelegate, + config: &DebugAdapterConfig, + _: Option, + _: &mut AsyncApp, + ) -> Result { + let delve_path = delegate + .which(OsStr::new("dlv")) + .and_then(|p| p.to_str().map(|p| p.to_string())) + .ok_or(anyhow!("Dlv not found in path"))?; + + Ok(DebugAdapterBinary { + command: delve_path, + arguments: Some(vec![ + "dap".into(), + "--listen".into(), + format!("{}:{}", self.host, self.port).into(), + ]), + cwd: config.cwd.clone(), + envs: None, + connection: Some(adapters::TcpArguments { + host: self.host, + port: self.port, + timeout: self.timeout, + }), + }) + } + + fn request_args(&self, config: &DebugAdapterConfig) -> Value { + json!({ + "program": config.program, + "cwd": config.cwd, + "subProcess": true, + }) + } +} diff --git a/crates/dap_adapters/src/javascript.rs b/crates/dap_adapters/src/javascript.rs new file mode 100644 index 0000000000..dc28d4401d --- /dev/null +++ b/crates/dap_adapters/src/javascript.rs @@ -0,0 +1,148 @@ +use adapters::latest_github_release; +use dap::transport::TcpTransport; +use gpui::AsyncApp; +use regex::Regex; +use std::{collections::HashMap, net::Ipv4Addr, path::PathBuf}; +use sysinfo::{Pid, Process}; +use task::DebugRequestType; + +use crate::*; + +pub(crate) struct JsDebugAdapter { + port: u16, + host: Ipv4Addr, + timeout: Option, +} + +impl JsDebugAdapter { + const ADAPTER_NAME: &'static str = "vscode-js-debug"; + const ADAPTER_PATH: &'static str = "js-debug/src/dapDebugServer.js"; + + pub(crate) async fn new(host: TCPHost) -> Result { + Ok(JsDebugAdapter { + host: host.host(), + timeout: host.timeout, + port: TcpTransport::port(&host).await?, + }) + } + + pub fn attach_processes(processes: &HashMap) -> Vec<(&Pid, &Process)> { + let regex = Regex::new(r"(?i)^(?:node|bun|iojs)(?:$|\b)").unwrap(); + + processes + .iter() + .filter(|(_, process)| regex.is_match(&process.name().to_string_lossy())) + .collect::>() + } +} + +#[async_trait(?Send)] +impl DebugAdapter for JsDebugAdapter { + fn name(&self) -> DebugAdapterName { + DebugAdapterName(Self::ADAPTER_NAME.into()) + } + + async fn fetch_latest_adapter_version( + &self, + delegate: &dyn DapDelegate, + ) -> Result { + let release = latest_github_release( + &format!("{}/{}", "microsoft", Self::ADAPTER_NAME), + true, + false, + delegate.http_client(), + ) + .await?; + + let asset_name = format!("js-debug-dap-{}.tar.gz", release.tag_name); + + Ok(AdapterVersion { + tag_name: release.tag_name, + url: release + .assets + .iter() + .find(|asset| asset.name == asset_name) + .ok_or_else(|| anyhow!("no asset found matching {:?}", asset_name))? + .browser_download_url + .clone(), + }) + } + + async fn get_installed_binary( + &self, + delegate: &dyn DapDelegate, + config: &DebugAdapterConfig, + user_installed_path: Option, + _: &mut AsyncApp, + ) -> Result { + let adapter_path = if let Some(user_installed_path) = user_installed_path { + user_installed_path + } else { + let adapter_path = paths::debug_adapters_dir().join(self.name()); + + let file_name_prefix = format!("{}_", self.name()); + + util::fs::find_file_name_in_dir(adapter_path.as_path(), |file_name| { + file_name.starts_with(&file_name_prefix) + }) + .await + .ok_or_else(|| anyhow!("Couldn't find JavaScript dap directory"))? + }; + + Ok(DebugAdapterBinary { + command: delegate + .node_runtime() + .binary_path() + .await? + .to_string_lossy() + .into_owned(), + arguments: Some(vec![ + adapter_path.join(Self::ADAPTER_PATH).into(), + self.port.to_string().into(), + self.host.to_string().into(), + ]), + cwd: config.cwd.clone(), + envs: None, + connection: Some(adapters::TcpArguments { + host: self.host, + port: self.port, + timeout: self.timeout, + }), + }) + } + + async fn install_binary( + &self, + version: AdapterVersion, + delegate: &dyn DapDelegate, + ) -> Result<()> { + adapters::download_adapter_from_github( + self.name(), + version, + adapters::DownloadedFileType::GzipTar, + delegate, + ) + .await?; + + return Ok(()); + } + + fn request_args(&self, config: &DebugAdapterConfig) -> Value { + let pid = if let DebugRequestType::Attach(attach_config) = &config.request { + attach_config.process_id + } else { + None + }; + + json!({ + "program": config.program, + "type": "pwa-node", + "request": match config.request { + DebugRequestType::Launch => "launch", + DebugRequestType::Attach(_) => "attach", + }, + "processId": pid, + "cwd": config.cwd, + }) + } +} diff --git a/crates/dap_adapters/src/lldb.rs b/crates/dap_adapters/src/lldb.rs new file mode 100644 index 0000000000..be4800cc76 --- /dev/null +++ b/crates/dap_adapters/src/lldb.rs @@ -0,0 +1,104 @@ +use std::{collections::HashMap, ffi::OsStr, path::PathBuf}; + +use anyhow::Result; +use async_trait::async_trait; +use gpui::AsyncApp; +use sysinfo::{Pid, Process}; +use task::{DebugAdapterConfig, DebugRequestType}; + +use crate::*; + +pub(crate) struct LldbDebugAdapter {} + +impl LldbDebugAdapter { + const ADAPTER_NAME: &'static str = "lldb"; + + pub(crate) fn new() -> Self { + LldbDebugAdapter {} + } + + pub fn attach_processes(processes: &HashMap) -> Vec<(&Pid, &Process)> { + processes.iter().collect::>() + } +} + +#[async_trait(?Send)] +impl DebugAdapter for LldbDebugAdapter { + fn name(&self) -> DebugAdapterName { + DebugAdapterName(Self::ADAPTER_NAME.into()) + } + + async fn get_binary( + &self, + delegate: &dyn DapDelegate, + config: &DebugAdapterConfig, + user_installed_path: Option, + _: &mut AsyncApp, + ) -> Result { + let lldb_dap_path = if let Some(user_installed_path) = user_installed_path { + user_installed_path.to_string_lossy().into() + } else if cfg!(target_os = "macos") { + util::command::new_smol_command("xcrun") + .args(&["-f", "lldb-dap"]) + .output() + .await + .ok() + .and_then(|output| String::from_utf8(output.stdout).ok()) + .map(|path| path.trim().to_string()) + .ok_or(anyhow!("Failed to find lldb-dap in user's path"))? + } else { + delegate + .which(OsStr::new("lldb-dap")) + .and_then(|p| p.to_str().map(|s| s.to_string())) + .ok_or(anyhow!("Could not find lldb-dap in path"))? + }; + + Ok(DebugAdapterBinary { + command: lldb_dap_path, + arguments: None, + envs: None, + cwd: config.cwd.clone(), + connection: None, + }) + } + + async fn install_binary( + &self, + _version: AdapterVersion, + _delegate: &dyn DapDelegate, + ) -> Result<()> { + unimplemented!("LLDB debug adapter cannot be installed by Zed (yet)") + } + + async fn fetch_latest_adapter_version(&self, _: &dyn DapDelegate) -> Result { + unimplemented!("Fetch latest adapter version not implemented for lldb (yet)") + } + + async fn get_installed_binary( + &self, + _: &dyn DapDelegate, + _: &DebugAdapterConfig, + _: Option, + _: &mut AsyncApp, + ) -> Result { + unimplemented!("LLDB debug adapter cannot be installed by Zed (yet)") + } + + fn request_args(&self, config: &DebugAdapterConfig) -> Value { + let pid = if let DebugRequestType::Attach(attach_config) = &config.request { + attach_config.process_id + } else { + None + }; + + json!({ + "program": config.program, + "request": match config.request { + DebugRequestType::Launch => "launch", + DebugRequestType::Attach(_) => "attach", + }, + "pid": pid, + "cwd": config.cwd, + }) + } +} diff --git a/crates/dap_adapters/src/php.rs b/crates/dap_adapters/src/php.rs new file mode 100644 index 0000000000..fa7c02694d --- /dev/null +++ b/crates/dap_adapters/src/php.rs @@ -0,0 +1,123 @@ +use adapters::latest_github_release; +use dap::{adapters::TcpArguments, transport::TcpTransport}; +use gpui::AsyncApp; +use std::{net::Ipv4Addr, path::PathBuf}; + +use crate::*; + +pub(crate) struct PhpDebugAdapter { + port: u16, + host: Ipv4Addr, + timeout: Option, +} + +impl PhpDebugAdapter { + const ADAPTER_NAME: &'static str = "vscode-php-debug"; + const ADAPTER_PATH: &'static str = "extension/out/phpDebug.js"; + + pub(crate) async fn new(host: TCPHost) -> Result { + Ok(PhpDebugAdapter { + port: TcpTransport::port(&host).await?, + host: host.host(), + timeout: host.timeout, + }) + } +} + +#[async_trait(?Send)] +impl DebugAdapter for PhpDebugAdapter { + fn name(&self) -> DebugAdapterName { + DebugAdapterName(Self::ADAPTER_NAME.into()) + } + + async fn fetch_latest_adapter_version( + &self, + delegate: &dyn DapDelegate, + ) -> Result { + let release = latest_github_release( + &format!("{}/{}", "xdebug", Self::ADAPTER_NAME), + true, + false, + delegate.http_client(), + ) + .await?; + + let asset_name = format!("php-debug-{}.vsix", release.tag_name.replace("v", "")); + + Ok(AdapterVersion { + tag_name: release.tag_name, + url: release + .assets + .iter() + .find(|asset| asset.name == asset_name) + .ok_or_else(|| anyhow!("no asset found matching {:?}", asset_name))? + .browser_download_url + .clone(), + }) + } + + async fn get_installed_binary( + &self, + delegate: &dyn DapDelegate, + config: &DebugAdapterConfig, + user_installed_path: Option, + _: &mut AsyncApp, + ) -> Result { + let adapter_path = if let Some(user_installed_path) = user_installed_path { + user_installed_path + } else { + let adapter_path = paths::debug_adapters_dir().join(self.name()); + + let file_name_prefix = format!("{}_", self.name()); + + util::fs::find_file_name_in_dir(adapter_path.as_path(), |file_name| { + file_name.starts_with(&file_name_prefix) + }) + .await + .ok_or_else(|| anyhow!("Couldn't find PHP dap directory"))? + }; + + Ok(DebugAdapterBinary { + command: delegate + .node_runtime() + .binary_path() + .await? + .to_string_lossy() + .into_owned(), + arguments: Some(vec![ + adapter_path.join(Self::ADAPTER_PATH).into(), + format!("--server={}", self.port).into(), + ]), + connection: Some(TcpArguments { + port: self.port, + host: self.host, + timeout: self.timeout, + }), + cwd: config.cwd.clone(), + envs: None, + }) + } + + async fn install_binary( + &self, + version: AdapterVersion, + delegate: &dyn DapDelegate, + ) -> Result<()> { + adapters::download_adapter_from_github( + self.name(), + version, + adapters::DownloadedFileType::Vsix, + delegate, + ) + .await?; + + Ok(()) + } + + fn request_args(&self, config: &DebugAdapterConfig) -> Value { + json!({ + "program": config.program, + "cwd": config.cwd, + }) + } +} diff --git a/crates/dap_adapters/src/python.rs b/crates/dap_adapters/src/python.rs new file mode 100644 index 0000000000..42c01da8c7 --- /dev/null +++ b/crates/dap_adapters/src/python.rs @@ -0,0 +1,142 @@ +use crate::*; +use dap::transport::TcpTransport; +use gpui::AsyncApp; +use std::{ffi::OsStr, net::Ipv4Addr, path::PathBuf}; + +pub(crate) struct PythonDebugAdapter { + port: u16, + host: Ipv4Addr, + timeout: Option, +} + +impl PythonDebugAdapter { + const ADAPTER_NAME: &'static str = "debugpy"; + const ADAPTER_PATH: &'static str = "src/debugpy/adapter"; + const LANGUAGE_NAME: &'static str = "Python"; + + pub(crate) async fn new(host: &TCPHost) -> Result { + Ok(PythonDebugAdapter { + port: TcpTransport::port(host).await?, + host: host.host(), + timeout: host.timeout, + }) + } +} + +#[async_trait(?Send)] +impl DebugAdapter for PythonDebugAdapter { + fn name(&self) -> DebugAdapterName { + DebugAdapterName(Self::ADAPTER_NAME.into()) + } + + async fn fetch_latest_adapter_version( + &self, + delegate: &dyn DapDelegate, + ) -> Result { + let github_repo = GithubRepo { + repo_name: Self::ADAPTER_NAME.into(), + repo_owner: "microsoft".into(), + }; + + adapters::fetch_latest_adapter_version_from_github(github_repo, delegate).await + } + + async fn install_binary( + &self, + version: AdapterVersion, + delegate: &dyn DapDelegate, + ) -> Result<()> { + let version_path = adapters::download_adapter_from_github( + self.name(), + version, + adapters::DownloadedFileType::Zip, + delegate, + ) + .await?; + + // only needed when you install the latest version for the first time + if let Some(debugpy_dir) = + util::fs::find_file_name_in_dir(version_path.as_path(), |file_name| { + file_name.starts_with("microsoft-debugpy-") + }) + .await + { + // TODO Debugger: Rename folder instead of moving all files to another folder + // We're doing unnecessary IO work right now + util::fs::move_folder_files_to_folder(debugpy_dir.as_path(), version_path.as_path()) + .await?; + } + + Ok(()) + } + + async fn get_installed_binary( + &self, + delegate: &dyn DapDelegate, + config: &DebugAdapterConfig, + user_installed_path: Option, + cx: &mut AsyncApp, + ) -> Result { + const BINARY_NAMES: [&str; 3] = ["python3", "python", "py"]; + + let debugpy_dir = if let Some(user_installed_path) = user_installed_path { + user_installed_path + } else { + let adapter_path = paths::debug_adapters_dir().join(self.name()); + let file_name_prefix = format!("{}_", self.name()); + + util::fs::find_file_name_in_dir(adapter_path.as_path(), |file_name| { + file_name.starts_with(&file_name_prefix) + }) + .await + .ok_or_else(|| anyhow!("Debugpy directory not found"))? + }; + + let toolchain = delegate + .toolchain_store() + .active_toolchain( + delegate.worktree_id(), + language::LanguageName::new(Self::LANGUAGE_NAME), + cx, + ) + .await; + + let python_path = if let Some(toolchain) = toolchain { + Some(toolchain.path.to_string()) + } else { + BINARY_NAMES + .iter() + .filter_map(|cmd| { + delegate + .which(OsStr::new(cmd)) + .map(|path| path.to_string_lossy().to_string()) + }) + .find(|_| true) + }; + + Ok(DebugAdapterBinary { + command: python_path.ok_or(anyhow!("failed to find binary path for python"))?, + arguments: Some(vec![ + debugpy_dir.join(Self::ADAPTER_PATH).into(), + format!("--port={}", self.port).into(), + format!("--host={}", self.host).into(), + ]), + connection: Some(adapters::TcpArguments { + host: self.host, + port: self.port, + timeout: self.timeout, + }), + cwd: config.cwd.clone(), + envs: None, + }) + } + + fn request_args(&self, config: &DebugAdapterConfig) -> Value { + json!({ + "program": config.program, + "subProcess": true, + "cwd": config.cwd, + "redirectOutput": true, + }) + } +} diff --git a/crates/debugger_tools/Cargo.toml b/crates/debugger_tools/Cargo.toml new file mode 100644 index 0000000000..a7a96587e2 --- /dev/null +++ b/crates/debugger_tools/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "debugger_tools" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/debugger_tools.rs" +doctest = false + +[dependencies] +anyhow.workspace = true +dap.workspace = true +editor.workspace = true +futures.workspace = true +gpui.workspace = true +project.workspace = true +serde_json.workspace = true +settings.workspace = true +smol.workspace = true +util.workspace = true +workspace.workspace = true diff --git a/crates/debugger_tools/LICENSE-GPL b/crates/debugger_tools/LICENSE-GPL new file mode 120000 index 0000000000..89e542f750 --- /dev/null +++ b/crates/debugger_tools/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/debugger_tools/src/dap_log.rs b/crates/debugger_tools/src/dap_log.rs new file mode 100644 index 0000000000..0cf1b2140c --- /dev/null +++ b/crates/debugger_tools/src/dap_log.rs @@ -0,0 +1,845 @@ +use dap::{ + client::SessionId, + debugger_settings::DebuggerSettings, + transport::{IoKind, LogKind}, +}; +use editor::{Editor, EditorEvent}; +use futures::{ + channel::mpsc::{unbounded, UnboundedSender}, + StreamExt, +}; +use gpui::{ + actions, div, App, AppContext, Context, Empty, Entity, EventEmitter, FocusHandle, Focusable, + IntoElement, ParentElement, Render, SharedString, Styled, Subscription, WeakEntity, Window, +}; +use project::{ + debugger::{dap_store, session::Session}, + search::SearchQuery, + Project, +}; +use settings::Settings as _; +use std::{ + borrow::Cow, + collections::{HashMap, VecDeque}, + sync::Arc, +}; +use util::maybe; +use workspace::{ + item::Item, + searchable::{Direction, SearchEvent, SearchableItem, SearchableItemHandle}, + ui::{h_flex, Button, Clickable, ContextMenu, Label, LabelCommon, PopoverMenu}, + ToolbarItemEvent, ToolbarItemView, Workspace, +}; + +struct DapLogView { + editor: Entity, + focus_handle: FocusHandle, + log_store: Entity, + editor_subscriptions: Vec, + current_view: Option<(SessionId, LogKind)>, + project: Entity, + _subscriptions: Vec, +} + +struct LogStore { + projects: HashMap, ProjectState>, + debug_clients: HashMap, + rpc_tx: UnboundedSender<(SessionId, IoKind, String)>, + adapter_log_tx: UnboundedSender<(SessionId, IoKind, String)>, +} + +struct ProjectState { + _subscriptions: [gpui::Subscription; 2], +} + +struct DebugAdapterState { + log_messages: VecDeque, + rpc_messages: RpcMessages, +} + +struct RpcMessages { + messages: VecDeque, + last_message_kind: Option, +} + +impl RpcMessages { + const MESSAGE_QUEUE_LIMIT: usize = 255; + + fn new() -> Self { + Self { + last_message_kind: None, + messages: VecDeque::with_capacity(Self::MESSAGE_QUEUE_LIMIT), + } + } +} + +const SEND: &str = "// Send"; +const RECEIVE: &str = "// Receive"; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum MessageKind { + Send, + Receive, +} + +impl MessageKind { + fn label(&self) -> &'static str { + match self { + Self::Send => SEND, + Self::Receive => RECEIVE, + } + } +} + +impl DebugAdapterState { + fn new() -> Self { + Self { + log_messages: VecDeque::new(), + rpc_messages: RpcMessages::new(), + } + } +} + +impl LogStore { + fn new(cx: &Context) -> Self { + let (rpc_tx, mut rpc_rx) = unbounded::<(SessionId, IoKind, String)>(); + cx.spawn(|this, mut cx| async move { + while let Some((client_id, io_kind, message)) = rpc_rx.next().await { + if let Some(this) = this.upgrade() { + this.update(&mut cx, |this, cx| { + this.on_rpc_log(client_id, io_kind, &message, cx); + })?; + } + + smol::future::yield_now().await; + } + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + + let (adapter_log_tx, mut adapter_log_rx) = unbounded::<(SessionId, IoKind, String)>(); + cx.spawn(|this, mut cx| async move { + while let Some((client_id, io_kind, message)) = adapter_log_rx.next().await { + if let Some(this) = this.upgrade() { + this.update(&mut cx, |this, cx| { + this.on_adapter_log(client_id, io_kind, &message, cx); + })?; + } + + smol::future::yield_now().await; + } + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + Self { + rpc_tx, + adapter_log_tx, + projects: HashMap::new(), + debug_clients: HashMap::new(), + } + } + + fn on_rpc_log( + &mut self, + client_id: SessionId, + io_kind: IoKind, + message: &str, + cx: &mut Context, + ) { + self.add_debug_client_message(client_id, io_kind, message.to_string(), cx); + } + + fn on_adapter_log( + &mut self, + client_id: SessionId, + io_kind: IoKind, + message: &str, + cx: &mut Context, + ) { + self.add_debug_client_log(client_id, io_kind, message.to_string(), cx); + } + + pub fn add_project(&mut self, project: &Entity, cx: &mut Context) { + let weak_project = project.downgrade(); + self.projects.insert( + project.downgrade(), + ProjectState { + _subscriptions: [ + cx.observe_release(project, move |this, _, _| { + this.projects.remove(&weak_project); + }), + cx.subscribe( + &project.read(cx).dap_store(), + |this, dap_store, event, cx| match event { + dap_store::DapStoreEvent::DebugClientStarted(session_id) => { + let session = dap_store.read(cx).session_by_id(session_id); + if let Some(session) = session { + this.add_debug_client(*session_id, session, cx); + } + } + dap_store::DapStoreEvent::DebugClientShutdown(session_id) => { + this.remove_debug_client(*session_id, cx); + } + + _ => {} + }, + ), + ], + }, + ); + } + + fn get_debug_adapter_state(&mut self, id: SessionId) -> Option<&mut DebugAdapterState> { + self.debug_clients.get_mut(&id) + } + + fn add_debug_client_message( + &mut self, + id: SessionId, + io_kind: IoKind, + message: String, + cx: &mut Context, + ) { + let Some(debug_client_state) = self.get_debug_adapter_state(id) else { + return; + }; + + let kind = match io_kind { + IoKind::StdOut | IoKind::StdErr => MessageKind::Receive, + IoKind::StdIn => MessageKind::Send, + }; + + let rpc_messages = &mut debug_client_state.rpc_messages; + if rpc_messages.last_message_kind != Some(kind) { + Self::add_debug_client_entry( + &mut rpc_messages.messages, + id, + kind.label().to_string(), + LogKind::Rpc, + cx, + ); + rpc_messages.last_message_kind = Some(kind); + } + Self::add_debug_client_entry(&mut rpc_messages.messages, id, message, LogKind::Rpc, cx); + + cx.notify(); + } + + fn add_debug_client_log( + &mut self, + id: SessionId, + io_kind: IoKind, + message: String, + cx: &mut Context, + ) { + let Some(debug_client_state) = self.get_debug_adapter_state(id) else { + return; + }; + + let message = match io_kind { + IoKind::StdErr => { + let mut message = message.clone(); + message.insert_str(0, "stderr: "); + message + } + _ => message, + }; + + Self::add_debug_client_entry( + &mut debug_client_state.log_messages, + id, + message, + LogKind::Adapter, + cx, + ); + cx.notify(); + } + + fn add_debug_client_entry( + log_lines: &mut VecDeque, + id: SessionId, + message: String, + kind: LogKind, + cx: &mut Context, + ) { + while log_lines.len() >= RpcMessages::MESSAGE_QUEUE_LIMIT { + log_lines.pop_front(); + } + + let format_messages = DebuggerSettings::get_global(cx).format_dap_log_messages; + + let entry = if format_messages { + maybe!({ + serde_json::to_string_pretty::( + &serde_json::from_str(&message).ok()?, + ) + .ok() + }) + .unwrap_or(message) + } else { + message + }; + log_lines.push_back(entry.clone()); + + cx.emit(Event::NewLogEntry { id, entry, kind }); + } + + fn add_debug_client( + &mut self, + client_id: SessionId, + client: Entity, + cx: &App, + ) -> Option<&mut DebugAdapterState> { + let client_state = self + .debug_clients + .entry(client_id) + .or_insert_with(DebugAdapterState::new); + + let io_tx = self.rpc_tx.clone(); + + let client = client.read(cx).adapter_client()?; + client.add_log_handler( + move |io_kind, message| { + io_tx + .unbounded_send((client_id, io_kind, message.to_string())) + .ok(); + }, + LogKind::Rpc, + ); + + let log_io_tx = self.adapter_log_tx.clone(); + client.add_log_handler( + move |io_kind, message| { + log_io_tx + .unbounded_send((client_id, io_kind, message.to_string())) + .ok(); + }, + LogKind::Adapter, + ); + + Some(client_state) + } + + fn remove_debug_client(&mut self, client_id: SessionId, cx: &mut Context) { + self.debug_clients.remove(&client_id); + cx.notify(); + } + + fn log_messages_for_client(&mut self, client_id: SessionId) -> Option<&mut VecDeque> { + Some(&mut self.debug_clients.get_mut(&client_id)?.log_messages) + } + + fn rpc_messages_for_client(&mut self, client_id: SessionId) -> Option<&mut VecDeque> { + Some( + &mut self + .debug_clients + .get_mut(&client_id)? + .rpc_messages + .messages, + ) + } +} + +pub struct DapLogToolbarItemView { + log_view: Option>, +} + +impl DapLogToolbarItemView { + pub fn new() -> Self { + Self { log_view: None } + } +} + +impl Render for DapLogToolbarItemView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let Some(log_view) = self.log_view.clone() else { + return Empty.into_any_element(); + }; + + let (menu_rows, current_client_id) = log_view.update(cx, |log_view, cx| { + ( + log_view.menu_items(cx).unwrap_or_default(), + log_view.current_view.map(|(client_id, _)| client_id), + ) + }); + + let current_client = current_client_id.and_then(|current_client_id| { + menu_rows + .iter() + .find(|row| row.client_id == current_client_id) + }); + + let dap_menu: PopoverMenu<_> = PopoverMenu::new("DapLogView") + .anchor(gpui::Corner::TopLeft) + .trigger(Button::new( + "debug_client_menu_header", + current_client + .map(|sub_item| { + Cow::Owned(format!( + "{} ({}) - {}", + sub_item.client_name, + sub_item.client_id.0, + match sub_item.selected_entry { + LogKind::Adapter => ADAPTER_LOGS, + LogKind::Rpc => RPC_MESSAGES, + } + )) + }) + .unwrap_or_else(|| "No adapter selected".into()), + )) + .menu(move |mut window, cx| { + let log_view = log_view.clone(); + let menu_rows = menu_rows.clone(); + ContextMenu::build(&mut window, cx, move |mut menu, window, _cx| { + for row in menu_rows.into_iter() { + menu = menu.custom_row(move |_window, _cx| { + div() + .w_full() + .pl_2() + .child( + Label::new( + format!("{}. {}", row.client_id.0, row.client_name,), + ) + .color(workspace::ui::Color::Muted), + ) + .into_any_element() + }); + + if row.has_adapter_logs { + menu = menu.custom_entry( + move |_window, _cx| { + div() + .w_full() + .pl_4() + .child(Label::new(ADAPTER_LOGS)) + .into_any_element() + }, + window.handler_for(&log_view, move |view, window, cx| { + view.show_log_messages_for_adapter(row.client_id, window, cx); + }), + ); + } + + menu = menu.custom_entry( + move |_window, _cx| { + div() + .w_full() + .pl_4() + .child(Label::new(RPC_MESSAGES)) + .into_any_element() + }, + window.handler_for(&log_view, move |view, window, cx| { + view.show_rpc_trace_for_server(row.client_id, window, cx); + }), + ); + } + + menu + }) + .into() + }); + + h_flex() + .size_full() + .child(dap_menu) + .child( + div() + .child( + Button::new("clear_log_button", "Clear").on_click(cx.listener( + |this, _, window, cx| { + if let Some(log_view) = this.log_view.as_ref() { + log_view.update(cx, |log_view, cx| { + log_view.editor.update(cx, |editor, cx| { + editor.set_read_only(false); + editor.clear(window, cx); + editor.set_read_only(true); + }); + }) + } + }, + )), + ) + .ml_2(), + ) + .into_any_element() + } +} + +impl EventEmitter for DapLogToolbarItemView {} + +impl ToolbarItemView for DapLogToolbarItemView { + fn set_active_pane_item( + &mut self, + active_pane_item: Option<&dyn workspace::item::ItemHandle>, + _window: &mut Window, + cx: &mut Context, + ) -> workspace::ToolbarItemLocation { + if let Some(item) = active_pane_item { + if let Some(log_view) = item.downcast::() { + self.log_view = Some(log_view.clone()); + return workspace::ToolbarItemLocation::PrimaryLeft; + } + } + self.log_view = None; + + cx.notify(); + + workspace::ToolbarItemLocation::Hidden + } +} + +impl DapLogView { + pub fn new( + project: Entity, + log_store: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let (editor, editor_subscriptions) = Self::editor_for_logs(String::new(), window, cx); + + let focus_handle = cx.focus_handle(); + + let events_subscriptions = cx.subscribe(&log_store, |log_view, _, event, cx| match event { + Event::NewLogEntry { id, entry, kind } => { + if log_view.current_view == Some((*id, *kind)) { + log_view.editor.update(cx, |editor, cx| { + editor.set_read_only(false); + let last_point = editor.buffer().read(cx).len(cx); + editor.edit( + vec![ + (last_point..last_point, entry.trim()), + (last_point..last_point, "\n"), + ], + cx, + ); + editor.set_read_only(true); + }); + } + } + }); + + Self { + editor, + focus_handle, + project, + log_store, + editor_subscriptions, + current_view: None, + _subscriptions: vec![events_subscriptions], + } + } + + fn editor_for_logs( + log_contents: String, + window: &mut Window, + cx: &mut Context, + ) -> (Entity, Vec) { + let editor = cx.new(|cx| { + let mut editor = Editor::multi_line(window, cx); + editor.set_text(log_contents, window, cx); + editor.move_to_end(&editor::actions::MoveToEnd, window, cx); + editor.set_show_code_actions(false, cx); + editor.set_show_breakpoints(false, cx); + editor.set_show_git_diff_gutter(false, cx); + editor.set_show_runnables(false, cx); + editor.set_input_enabled(false); + editor.set_use_autoclose(false); + editor.set_read_only(true); + editor.set_show_edit_predictions(Some(false), window, cx); + editor + }); + let editor_subscription = cx.subscribe( + &editor, + |_, _, event: &EditorEvent, cx: &mut Context<'_, DapLogView>| cx.emit(event.clone()), + ); + let search_subscription = cx.subscribe( + &editor, + |_, _, event: &SearchEvent, cx: &mut Context<'_, DapLogView>| cx.emit(event.clone()), + ); + (editor, vec![editor_subscription, search_subscription]) + } + + fn menu_items(&self, cx: &App) -> Option> { + let mut menu_items = self + .project + .read(cx) + .dap_store() + .read(cx) + .sessions() + .filter_map(|client| { + let client = client.read(cx).adapter_client()?; + Some(DapMenuItem { + client_id: client.id(), + client_name: client.name().0.as_ref().into(), + has_adapter_logs: client.has_adapter_logs(), + selected_entry: self.current_view.map_or(LogKind::Adapter, |(_, kind)| kind), + }) + }) + .collect::>(); + menu_items.sort_by_key(|item| item.client_id.0); + Some(menu_items) + } + + fn show_rpc_trace_for_server( + &mut self, + client_id: SessionId, + window: &mut Window, + cx: &mut Context, + ) { + let rpc_log = self.log_store.update(cx, |log_store, _| { + log_store + .rpc_messages_for_client(client_id) + .map(|state| log_contents(&state)) + }); + if let Some(rpc_log) = rpc_log { + self.current_view = Some((client_id, LogKind::Rpc)); + let (editor, editor_subscriptions) = Self::editor_for_logs(rpc_log, window, cx); + let language = self.project.read(cx).languages().language_for_name("JSON"); + editor + .read(cx) + .buffer() + .read(cx) + .as_singleton() + .expect("log buffer should be a singleton") + .update(cx, |_, cx| { + cx.spawn({ + let buffer = cx.entity(); + |_, mut cx| async move { + let language = language.await.ok(); + buffer.update(&mut cx, |buffer, cx| { + buffer.set_language(language, cx); + }) + } + }) + .detach_and_log_err(cx); + }); + + self.editor = editor; + self.editor_subscriptions = editor_subscriptions; + cx.notify(); + } + + cx.focus_self(window); + } + + fn show_log_messages_for_adapter( + &mut self, + client_id: SessionId, + window: &mut Window, + cx: &mut Context, + ) { + let message_log = self.log_store.update(cx, |log_store, _| { + log_store + .log_messages_for_client(client_id) + .map(|state| log_contents(&state)) + }); + if let Some(message_log) = message_log { + self.current_view = Some((client_id, LogKind::Adapter)); + let (editor, editor_subscriptions) = Self::editor_for_logs(message_log, window, cx); + editor + .read(cx) + .buffer() + .read(cx) + .as_singleton() + .expect("log buffer should be a singleton"); + + self.editor = editor; + self.editor_subscriptions = editor_subscriptions; + cx.notify(); + } + + cx.focus_self(window); + } +} + +fn log_contents(lines: &VecDeque) -> String { + let (a, b) = lines.as_slices(); + let a = a.iter().map(move |v| v.as_ref()); + let b = b.iter().map(move |v| v.as_ref()); + a.chain(b).fold(String::new(), |mut acc, el| { + acc.push_str(el); + acc.push('\n'); + acc + }) +} + +#[derive(Clone, PartialEq)] +pub(crate) struct DapMenuItem { + pub client_id: SessionId, + pub client_name: String, + pub has_adapter_logs: bool, + pub selected_entry: LogKind, +} + +const ADAPTER_LOGS: &str = "Adapter Logs"; +const RPC_MESSAGES: &str = "RPC Messages"; + +impl Render for DapLogView { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + self.editor.update(cx, |editor, cx| { + editor.render(window, cx).into_any_element() + }) + } +} + +actions!(debug, [OpenDebuggerAdapterLogs]); + +pub fn init(cx: &mut App) { + let log_store = cx.new(|cx| LogStore::new(cx)); + + cx.observe_new(move |workspace: &mut Workspace, window, cx| { + let Some(_window) = window else { + return; + }; + + let project = workspace.project(); + if project.read(cx).is_local() { + log_store.update(cx, |store, cx| { + store.add_project(project, cx); + }); + } + + let log_store = log_store.clone(); + workspace.register_action(move |workspace, _: &OpenDebuggerAdapterLogs, window, cx| { + let project = workspace.project().read(cx); + if project.is_local() { + workspace.add_item_to_active_pane( + Box::new(cx.new(|cx| { + DapLogView::new(workspace.project().clone(), log_store.clone(), window, cx) + })), + None, + true, + window, + cx, + ); + } + }); + }) + .detach(); +} + +impl Item for DapLogView { + type Event = EditorEvent; + + fn to_item_events(event: &Self::Event, f: impl FnMut(workspace::item::ItemEvent)) { + Editor::to_item_events(event, f) + } + + fn tab_content_text(&self, _window: &Window, _cx: &App) -> Option { + Some("DAP Logs".into()) + } + + fn telemetry_event_text(&self) -> Option<&'static str> { + None + } + + fn as_searchable(&self, handle: &Entity) -> Option> { + Some(Box::new(handle.clone())) + } +} + +impl SearchableItem for DapLogView { + type Match = ::Match; + + fn clear_matches(&mut self, window: &mut Window, cx: &mut Context) { + self.editor.update(cx, |e, cx| e.clear_matches(window, cx)) + } + + fn update_matches( + &mut self, + matches: &[Self::Match], + window: &mut Window, + cx: &mut Context, + ) { + self.editor + .update(cx, |e, cx| e.update_matches(matches, window, cx)) + } + + fn query_suggestion(&mut self, window: &mut Window, cx: &mut Context) -> String { + self.editor + .update(cx, |e, cx| e.query_suggestion(window, cx)) + } + + fn activate_match( + &mut self, + index: usize, + matches: &[Self::Match], + window: &mut Window, + cx: &mut Context, + ) { + self.editor + .update(cx, |e, cx| e.activate_match(index, matches, window, cx)) + } + + fn select_matches( + &mut self, + matches: &[Self::Match], + window: &mut Window, + cx: &mut Context, + ) { + self.editor + .update(cx, |e, cx| e.select_matches(matches, window, cx)) + } + + fn find_matches( + &mut self, + query: Arc, + window: &mut Window, + cx: &mut Context, + ) -> gpui::Task> { + self.editor + .update(cx, |e, cx| e.find_matches(query, window, cx)) + } + + fn replace( + &mut self, + _: &Self::Match, + _: &SearchQuery, + _window: &mut Window, + _: &mut Context, + ) { + // Since DAP Log is read-only, it doesn't make sense to support replace operation. + } + + fn supported_options(&self) -> workspace::searchable::SearchOptions { + workspace::searchable::SearchOptions { + case: true, + word: true, + regex: true, + find_in_results: true, + // DAP log is read-only. + replacement: false, + selection: false, + } + } + fn active_match_index( + &mut self, + direction: Direction, + matches: &[Self::Match], + window: &mut Window, + cx: &mut Context, + ) -> Option { + self.editor.update(cx, |e, cx| { + e.active_match_index(direction, matches, window, cx) + }) + } +} + +impl Focusable for DapLogView { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +pub enum Event { + NewLogEntry { + id: SessionId, + entry: String, + kind: LogKind, + }, +} + +impl EventEmitter for LogStore {} +impl EventEmitter for DapLogView {} +impl EventEmitter for DapLogView {} +impl EventEmitter for DapLogView {} diff --git a/crates/debugger_tools/src/debugger_tools.rs b/crates/debugger_tools/src/debugger_tools.rs new file mode 100644 index 0000000000..da7a43b53b --- /dev/null +++ b/crates/debugger_tools/src/debugger_tools.rs @@ -0,0 +1,8 @@ +mod dap_log; +pub use dap_log::*; + +use gpui::App; + +pub fn init(cx: &mut App) { + dap_log::init(cx); +} diff --git a/crates/debugger_ui/Cargo.toml b/crates/debugger_ui/Cargo.toml new file mode 100644 index 0000000000..d3e5169232 --- /dev/null +++ b/crates/debugger_ui/Cargo.toml @@ -0,0 +1,58 @@ +[package] +name = "debugger_ui" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[features] +test-support = [ + "dap/test-support", + "editor/test-support", + "gpui/test-support", + "project/test-support", + "util/test-support", + "workspace/test-support", +] + +[dependencies] +anyhow.workspace = true +client.workspace = true +collections.workspace = true +command_palette_hooks.workspace = true +dap.workspace = true +editor.workspace = true +feature_flags.workspace = true +futures.workspace = true +fuzzy.workspace = true +gpui.workspace = true +language.workspace = true +log.workspace = true +menu.workspace = true +picker.workspace = true +pretty_assertions.workspace = true +project.workspace = true +rpc.workspace = true +serde.workspace = true +serde_json.workspace = true +settings.workspace = true +sysinfo.workspace = true +task.workspace = true +terminal_view.workspace = true +theme.workspace = true +ui.workspace = true +util.workspace = true +workspace.workspace = true + +[dev-dependencies] +dap = { workspace = true, features = ["test-support"] } +editor = { workspace = true, features = ["test-support"] } +env_logger.workspace = true +gpui = { workspace = true, features = ["test-support"] } +project = { workspace = true, features = ["test-support"] } +unindent.workspace = true +util = { workspace = true, features = ["test-support"] } +workspace = { workspace = true, features = ["test-support"] } diff --git a/crates/debugger_ui/LICENSE-GPL b/crates/debugger_ui/LICENSE-GPL new file mode 120000 index 0000000000..89e542f750 --- /dev/null +++ b/crates/debugger_ui/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/debugger_ui/src/attach_modal.rs b/crates/debugger_ui/src/attach_modal.rs new file mode 100644 index 0000000000..b39c59f4cf --- /dev/null +++ b/crates/debugger_ui/src/attach_modal.rs @@ -0,0 +1,293 @@ +use dap::DebugRequestType; +use fuzzy::{StringMatch, StringMatchCandidate}; +use gpui::Subscription; +use gpui::{DismissEvent, Entity, EventEmitter, Focusable, Render}; +use picker::{Picker, PickerDelegate}; +use project::debugger::attach_processes; + +use std::sync::Arc; +use sysinfo::System; +use ui::{prelude::*, Context, Tooltip}; +use ui::{ListItem, ListItemSpacing}; +use util::debug_panic; +use workspace::ModalView; + +#[derive(Debug, Clone)] +struct Candidate { + pid: u32, + name: String, + command: Vec, +} + +pub(crate) struct AttachModalDelegate { + selected_index: usize, + matches: Vec, + placeholder_text: Arc, + project: Entity, + debug_config: task::DebugAdapterConfig, + candidates: Option>, +} + +impl AttachModalDelegate { + pub fn new(project: Entity, debug_config: task::DebugAdapterConfig) -> Self { + Self { + project, + debug_config, + candidates: None, + selected_index: 0, + matches: Vec::default(), + placeholder_text: Arc::from("Select the process you want to attach the debugger to"), + } + } +} + +pub struct AttachModal { + _subscription: Subscription, + pub(crate) picker: Entity>, +} + +impl AttachModal { + pub fn new( + project: Entity, + debug_config: task::DebugAdapterConfig, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let picker = cx.new(|cx| { + Picker::uniform_list(AttachModalDelegate::new(project, debug_config), window, cx) + }); + Self { + _subscription: cx.subscribe(&picker, |_, _, _, cx| { + cx.emit(DismissEvent); + }), + picker, + } + } +} + +impl Render for AttachModal { + fn render(&mut self, _window: &mut Window, _: &mut Context) -> impl ui::IntoElement { + v_flex() + .key_context("AttachModal") + .w(rems(34.)) + .child(self.picker.clone()) + } +} + +impl EventEmitter for AttachModal {} + +impl Focusable for AttachModal { + fn focus_handle(&self, cx: &App) -> gpui::FocusHandle { + self.picker.read(cx).focus_handle(cx) + } +} + +impl ModalView for AttachModal {} + +impl PickerDelegate for AttachModalDelegate { + type ListItem = ListItem; + + fn match_count(&self) -> usize { + self.matches.len() + } + + fn selected_index(&self) -> usize { + self.selected_index + } + + fn set_selected_index( + &mut self, + ix: usize, + _window: &mut Window, + _: &mut Context>, + ) { + self.selected_index = ix; + } + + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> std::sync::Arc { + self.placeholder_text.clone() + } + + fn update_matches( + &mut self, + query: String, + _window: &mut Window, + cx: &mut Context>, + ) -> gpui::Task<()> { + cx.spawn(|this, mut cx| async move { + let Some(processes) = this + .update(&mut cx, |this, _| { + if let Some(processes) = this.delegate.candidates.clone() { + processes + } else { + let system = System::new_all(); + + let processes = + attach_processes(&this.delegate.debug_config.kind, &system.processes()); + let candidates = processes + .into_iter() + .map(|(pid, process)| Candidate { + pid: pid.as_u32(), + name: process.name().to_string_lossy().into_owned(), + command: process + .cmd() + .iter() + .map(|s| s.to_string_lossy().to_string()) + .collect::>(), + }) + .collect::>(); + + let _ = this.delegate.candidates.insert(candidates.clone()); + + candidates + } + }) + .ok() + else { + return; + }; + + let matches = fuzzy::match_strings( + &processes + .iter() + .enumerate() + .map(|(id, candidate)| { + StringMatchCandidate::new( + id, + format!( + "{} {} {}", + candidate.command.join(" "), + candidate.pid, + candidate.name + ) + .as_str(), + ) + }) + .collect::>(), + &query, + true, + 100, + &Default::default(), + cx.background_executor().clone(), + ) + .await; + + this.update(&mut cx, |this, _| { + let delegate = &mut this.delegate; + + delegate.matches = matches; + delegate.candidates = Some(processes); + + if delegate.matches.is_empty() { + delegate.selected_index = 0; + } else { + delegate.selected_index = + delegate.selected_index.min(delegate.matches.len() - 1); + } + }) + .ok(); + }) + } + + fn confirm(&mut self, _: bool, _window: &mut Window, cx: &mut Context>) { + let candidate = self + .matches + .get(self.selected_index()) + .and_then(|current_match| { + let ix = current_match.candidate_id; + self.candidates.as_ref().map(|candidates| &candidates[ix]) + }); + + let Some(candidate) = candidate else { + return cx.emit(DismissEvent); + }; + + match &mut self.debug_config.request { + DebugRequestType::Attach(config) => { + config.process_id = Some(candidate.pid); + } + DebugRequestType::Launch => { + debug_panic!("Debugger attach modal used on launch debug config"); + return; + } + } + + let config = self.debug_config.clone(); + self.project + .update(cx, |project, cx| project.start_debug_session(config, cx)) + .detach_and_log_err(cx); + + cx.emit(DismissEvent); + } + + fn dismissed(&mut self, _window: &mut Window, cx: &mut Context>) { + self.selected_index = 0; + self.candidates.take(); + + cx.emit(DismissEvent); + } + + fn render_match( + &self, + ix: usize, + selected: bool, + _window: &mut Window, + _: &mut Context>, + ) -> Option { + let candidates = self.candidates.as_ref()?; + let hit = &self.matches[ix]; + let candidate = &candidates.get(hit.candidate_id)?; + + Some( + ListItem::new(SharedString::from(format!("process-entry-{ix}"))) + .inset(true) + .spacing(ListItemSpacing::Sparse) + .toggle_state(selected) + .child( + v_flex() + .items_start() + .child(Label::new(format!("{} {}", candidate.name, candidate.pid))) + .child( + div() + .id(SharedString::from(format!("process-entry-{ix}-command"))) + .tooltip(Tooltip::text( + candidate + .command + .clone() + .into_iter() + .collect::>() + .join(" "), + )) + .child( + Label::new(format!( + "{} {}", + candidate.name, + candidate + .command + .clone() + .into_iter() + .skip(1) + .collect::>() + .join(" ") + )) + .size(LabelSize::Small) + .color(Color::Muted), + ), + ), + ), + ) + } +} + +#[allow(dead_code)] +#[cfg(any(test, feature = "test-support"))] +pub(crate) fn process_names(modal: &AttachModal, cx: &mut Context) -> Vec { + modal.picker.update(cx, |picker, _| { + picker + .delegate + .matches + .iter() + .map(|hit| hit.string.clone()) + .collect::>() + }) +} diff --git a/crates/debugger_ui/src/debugger_panel.rs b/crates/debugger_ui/src/debugger_panel.rs new file mode 100644 index 0000000000..5d43327548 --- /dev/null +++ b/crates/debugger_ui/src/debugger_panel.rs @@ -0,0 +1,536 @@ +use crate::session::DebugSession; +use anyhow::{anyhow, Result}; +use collections::HashMap; +use command_palette_hooks::CommandPaletteFilter; +use dap::{ + client::SessionId, debugger_settings::DebuggerSettings, ContinuedEvent, LoadedSourceEvent, + ModuleEvent, OutputEvent, StoppedEvent, ThreadEvent, +}; +use futures::{channel::mpsc, SinkExt as _}; +use gpui::{ + actions, Action, App, AsyncWindowContext, Context, Entity, EventEmitter, FocusHandle, + Focusable, Subscription, Task, WeakEntity, +}; +use project::{ + debugger::dap_store::{self, DapStore}, + terminals::TerminalKind, + Project, +}; +use rpc::proto::{self}; +use settings::Settings; +use std::{any::TypeId, path::PathBuf}; +use terminal_view::terminal_panel::TerminalPanel; +use ui::prelude::*; +use workspace::{ + dock::{DockPosition, Panel, PanelEvent}, + pane, Continue, Disconnect, Pane, Pause, Restart, StepBack, StepInto, StepOut, StepOver, Stop, + ToggleIgnoreBreakpoints, Workspace, +}; + +pub enum DebugPanelEvent { + Exited(SessionId), + Terminated(SessionId), + Stopped { + client_id: SessionId, + event: StoppedEvent, + go_to_stack_frame: bool, + }, + Thread((SessionId, ThreadEvent)), + Continued((SessionId, ContinuedEvent)), + Output((SessionId, OutputEvent)), + Module((SessionId, ModuleEvent)), + LoadedSource((SessionId, LoadedSourceEvent)), + ClientShutdown(SessionId), + CapabilitiesChanged(SessionId), +} + +actions!(debug_panel, [ToggleFocus]); +pub struct DebugPanel { + size: Pixels, + pane: Entity, + project: WeakEntity, + workspace: WeakEntity, + _subscriptions: Vec, +} + +impl DebugPanel { + pub fn new( + workspace: &Workspace, + window: &mut Window, + cx: &mut Context, + ) -> Entity { + cx.new(|cx| { + let project = workspace.project().clone(); + let dap_store = project.read(cx).dap_store(); + let weak_workspace = workspace.weak_handle(); + let pane = cx.new(|cx| { + let mut pane = Pane::new( + workspace.weak_handle(), + project.clone(), + Default::default(), + None, + gpui::NoAction.boxed_clone(), + window, + cx, + ); + pane.set_can_split(None); + pane.set_can_navigate(true, cx); + pane.display_nav_history_buttons(None); + pane.set_should_display_tab_bar(|_window, _cx| true); + pane.set_close_pane_if_empty(true, cx); + pane.set_render_tab_bar_buttons(cx, { + let project = project.clone(); + let weak_workspace = weak_workspace.clone(); + move |_, _, cx| { + let project = project.clone(); + let weak_workspace = weak_workspace.clone(); + ( + None, + Some( + h_flex() + .child( + IconButton::new("new-debug-session", IconName::Plus) + .icon_size(IconSize::Small) + .on_click(cx.listener(move |pane, _, window, cx| { + pane.add_item( + Box::new(DebugSession::inert( + project.clone(), + weak_workspace.clone(), + window, + cx, + )), + false, + false, + None, + window, + cx, + ); + })), + ) + .into_any_element(), + ), + ) + } + }); + pane.add_item( + Box::new(DebugSession::inert( + project.clone(), + weak_workspace.clone(), + window, + cx, + )), + false, + false, + None, + window, + cx, + ); + pane + }); + + let _subscriptions = vec![ + cx.observe(&pane, |_, _, cx| cx.notify()), + cx.subscribe_in(&pane, window, Self::handle_pane_event), + cx.subscribe_in(&dap_store, window, Self::handle_dap_store_event), + ]; + + let debug_panel = Self { + pane, + size: px(300.), + _subscriptions, + project: project.downgrade(), + workspace: workspace.weak_handle(), + }; + + debug_panel + }) + } + + pub fn load( + workspace: WeakEntity, + cx: AsyncWindowContext, + ) -> Task>> { + cx.spawn(|mut cx| async move { + workspace.update_in(&mut cx, |workspace, window, cx| { + let debug_panel = DebugPanel::new(workspace, window, cx); + + cx.observe(&debug_panel, |_, debug_panel, cx| { + let (has_active_session, supports_restart, support_step_back) = debug_panel + .update(cx, |this, cx| { + this.active_session(cx) + .map(|item| { + let running = item.read(cx).mode().as_running().cloned(); + + match running { + Some(running) => { + let caps = running.read(cx).capabilities(cx); + ( + true, + caps.supports_restart_request.unwrap_or_default(), + caps.supports_step_back.unwrap_or_default(), + ) + } + None => (false, false, false), + } + }) + .unwrap_or((false, false, false)) + }); + + let filter = CommandPaletteFilter::global_mut(cx); + let debugger_action_types = [ + TypeId::of::(), + TypeId::of::(), + TypeId::of::(), + TypeId::of::(), + TypeId::of::(), + TypeId::of::(), + TypeId::of::(), + TypeId::of::(), + ]; + + let step_back_action_type = [TypeId::of::()]; + let restart_action_type = [TypeId::of::()]; + + if has_active_session { + filter.show_action_types(debugger_action_types.iter()); + + if supports_restart { + filter.show_action_types(restart_action_type.iter()); + } else { + filter.hide_action_types(&restart_action_type); + } + + if support_step_back { + filter.show_action_types(step_back_action_type.iter()); + } else { + filter.hide_action_types(&step_back_action_type); + } + } else { + // show only the `debug: start` + filter.hide_action_types(&debugger_action_types); + filter.hide_action_types(&step_back_action_type); + filter.hide_action_types(&restart_action_type); + } + }) + .detach(); + + debug_panel + }) + }) + } + + pub fn active_session(&self, cx: &App) -> Option> { + self.pane + .read(cx) + .active_item() + .and_then(|panel| panel.downcast::()) + } + + pub fn debug_panel_items_by_client( + &self, + client_id: &SessionId, + cx: &Context, + ) -> Vec> { + self.pane + .read(cx) + .items() + .filter_map(|item| item.downcast::()) + .filter(|item| item.read(cx).session_id(cx) == Some(*client_id)) + .map(|item| item.clone()) + .collect() + } + + pub fn debug_panel_item_by_client( + &self, + client_id: SessionId, + cx: &mut Context, + ) -> Option> { + self.pane + .read(cx) + .items() + .filter_map(|item| item.downcast::()) + .find(|item| { + let item = item.read(cx); + + item.session_id(cx) == Some(client_id) + }) + } + + fn handle_dap_store_event( + &mut self, + dap_store: &Entity, + event: &dap_store::DapStoreEvent, + window: &mut Window, + cx: &mut Context, + ) { + match event { + dap_store::DapStoreEvent::DebugClientStarted(session_id) => { + let Some(session) = dap_store.read(cx).session_by_id(session_id) else { + return log::error!("Couldn't get session with id: {session_id:?} from DebugClientStarted event"); + }; + + let Some(project) = self.project.upgrade() else { + return log::error!("Debug Panel out lived it's weak reference to Project"); + }; + + if self.pane.read_with(cx, |pane, cx| { + pane.items_of_type::() + .any(|item| item.read(cx).session_id(cx) == Some(*session_id)) + }) { + // We already have an item for this session. + return; + } + let session_item = + DebugSession::running(project, self.workspace.clone(), session, window, cx); + + self.pane.update(cx, |pane, cx| { + pane.add_item(Box::new(session_item), true, true, None, window, cx); + window.focus(&pane.focus_handle(cx)); + cx.notify(); + }); + } + dap_store::DapStoreEvent::RunInTerminal { + title, + cwd, + command, + args, + envs, + sender, + .. + } => { + self.handle_run_in_terminal_request( + title.clone(), + cwd.clone(), + command.clone(), + args.clone(), + envs.clone(), + sender.clone(), + window, + cx, + ) + .detach_and_log_err(cx); + } + _ => {} + } + } + + fn handle_run_in_terminal_request( + &self, + title: Option, + cwd: PathBuf, + command: Option, + args: Vec, + envs: HashMap, + mut sender: mpsc::Sender>, + window: &mut Window, + cx: &mut App, + ) -> Task> { + let terminal_task = self.workspace.update(cx, |workspace, cx| { + let terminal_panel = workspace.panel::(cx).ok_or_else(|| { + anyhow!("RunInTerminal DAP request failed because TerminalPanel wasn't found") + }); + + let terminal_panel = match terminal_panel { + Ok(panel) => panel, + Err(err) => return Task::ready(Err(err)), + }; + + terminal_panel.update(cx, |terminal_panel, cx| { + let terminal_task = terminal_panel.add_terminal( + TerminalKind::Debug { + command, + args, + envs, + cwd, + title, + }, + task::RevealStrategy::Always, + window, + cx, + ); + + cx.spawn(|_, mut cx| async move { + let pid_task = async move { + let terminal = terminal_task.await?; + + terminal.read_with(&mut cx, |terminal, _| terminal.pty_info.pid()) + }; + + pid_task.await + }) + }) + }); + + cx.background_spawn(async move { + match terminal_task { + Ok(pid_task) => match pid_task.await { + Ok(Some(pid)) => sender.send(Ok(pid.as_u32())).await?, + Ok(None) => { + sender + .send(Err(anyhow!( + "Terminal was spawned but PID was not available" + ))) + .await? + } + Err(error) => sender.send(Err(anyhow!(error))).await?, + }, + Err(error) => sender.send(Err(anyhow!(error))).await?, + }; + + Ok(()) + }) + } + + fn handle_pane_event( + &mut self, + _: &Entity, + event: &pane::Event, + window: &mut Window, + cx: &mut Context, + ) { + match event { + pane::Event::Remove { .. } => cx.emit(PanelEvent::Close), + pane::Event::ZoomIn => cx.emit(PanelEvent::ZoomIn), + pane::Event::ZoomOut => cx.emit(PanelEvent::ZoomOut), + pane::Event::AddItem { item } => { + self.workspace + .update(cx, |workspace, cx| { + item.added_to_pane(workspace, self.pane.clone(), window, cx) + }) + .ok(); + } + pane::Event::RemovedItem { item } => { + if let Some(debug_session) = item.downcast::() { + debug_session.update(cx, |session, cx| { + session.shutdown(cx); + }) + } + } + pane::Event::ActivateItem { + local: _, + focus_changed, + } => { + if *focus_changed { + if let Some(debug_session) = self + .pane + .read(cx) + .active_item() + .and_then(|item| item.downcast::()) + { + if let Some(running) = debug_session + .read_with(cx, |session, _| session.mode().as_running().cloned()) + { + running.update(cx, |running, cx| { + running.go_to_selected_stack_frame(window, cx); + }); + } + } + } + } + + _ => {} + } + } +} + +impl EventEmitter for DebugPanel {} +impl EventEmitter for DebugPanel {} +impl EventEmitter for DebugPanel {} + +impl Focusable for DebugPanel { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.pane.focus_handle(cx) + } +} + +impl Panel for DebugPanel { + fn pane(&self) -> Option> { + Some(self.pane.clone()) + } + + fn persistent_name() -> &'static str { + "DebugPanel" + } + + fn position(&self, _window: &Window, _cx: &App) -> DockPosition { + DockPosition::Bottom + } + + fn position_is_valid(&self, position: DockPosition) -> bool { + position == DockPosition::Bottom + } + + fn set_position( + &mut self, + _position: DockPosition, + _window: &mut Window, + _cx: &mut Context, + ) { + } + + fn size(&self, _window: &Window, _cx: &App) -> Pixels { + self.size + } + + fn set_size(&mut self, size: Option, _window: &mut Window, _cx: &mut Context) { + self.size = size.unwrap(); + } + + fn remote_id() -> Option { + Some(proto::PanelId::DebugPanel) + } + + fn icon(&self, _window: &Window, _cx: &App) -> Option { + Some(IconName::Debug) + } + + fn icon_tooltip(&self, _window: &Window, cx: &App) -> Option<&'static str> { + if DebuggerSettings::get_global(cx).button { + Some("Debug Panel") + } else { + None + } + } + + fn toggle_action(&self) -> Box { + Box::new(ToggleFocus) + } + + fn activation_priority(&self) -> u32 { + 9 + } + fn set_active(&mut self, active: bool, window: &mut Window, cx: &mut Context) { + if active && self.pane.read(cx).items_len() == 0 { + let Some(project) = self.project.clone().upgrade() else { + return; + }; + // todo: We need to revisit it when we start adding stopped items to pane (as that'll cause us to add two items). + self.pane.update(cx, |this, cx| { + this.add_item( + Box::new(DebugSession::inert( + project, + self.workspace.clone(), + window, + cx, + )), + false, + false, + None, + window, + cx, + ); + }); + } + } +} + +impl Render for DebugPanel { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .key_context("DebugPanel") + .track_focus(&self.focus_handle(cx)) + .size_full() + .child(self.pane.clone()) + .into_any() + } +} diff --git a/crates/debugger_ui/src/lib.rs b/crates/debugger_ui/src/lib.rs new file mode 100644 index 0000000000..ea1b2cd5e2 --- /dev/null +++ b/crates/debugger_ui/src/lib.rs @@ -0,0 +1,122 @@ +use dap::debugger_settings::DebuggerSettings; +use debugger_panel::{DebugPanel, ToggleFocus}; +use feature_flags::{Debugger, FeatureFlagViewExt}; +use gpui::App; +use session::DebugSession; +use settings::Settings; +use workspace::{ + Pause, Restart, ShutdownDebugAdapters, StepBack, StepInto, StepOver, Stop, + ToggleIgnoreBreakpoints, Workspace, +}; + +pub mod attach_modal; +pub mod debugger_panel; +pub mod session; + +#[cfg(test)] +mod tests; + +pub fn init(cx: &mut App) { + DebuggerSettings::register(cx); + workspace::FollowableViewRegistry::register::(cx); + + cx.observe_new(|_: &mut Workspace, window, cx| { + let Some(window) = window else { + return; + }; + + cx.when_flag_enabled::(window, |workspace, _, _| { + workspace + .register_action(|workspace, _: &ToggleFocus, window, cx| { + workspace.toggle_panel_focus::(window, cx); + }) + .register_action(|workspace, _: &Pause, _, cx| { + let debug_panel = workspace.panel::(cx).unwrap(); + + if let Some(active_item) = debug_panel.read_with(cx, |panel, cx| { + panel + .active_session(cx) + .and_then(|session| session.read(cx).mode().as_running().cloned()) + }) { + active_item.update(cx, |item, cx| item.pause_thread(cx)) + } + }) + .register_action(|workspace, _: &Restart, _, cx| { + let debug_panel = workspace.panel::(cx).unwrap(); + + if let Some(active_item) = debug_panel.read_with(cx, |panel, cx| { + panel + .active_session(cx) + .and_then(|session| session.read(cx).mode().as_running().cloned()) + }) { + active_item.update(cx, |item, cx| item.restart_session(cx)) + } + }) + .register_action(|workspace, _: &StepInto, _, cx| { + let debug_panel = workspace.panel::(cx).unwrap(); + + if let Some(active_item) = debug_panel.read_with(cx, |panel, cx| { + panel + .active_session(cx) + .and_then(|session| session.read(cx).mode().as_running().cloned()) + }) { + active_item.update(cx, |item, cx| item.step_in(cx)) + } + }) + .register_action(|workspace, _: &StepOver, _, cx| { + let debug_panel = workspace.panel::(cx).unwrap(); + + if let Some(active_item) = debug_panel.read_with(cx, |panel, cx| { + panel + .active_session(cx) + .and_then(|session| session.read(cx).mode().as_running().cloned()) + }) { + active_item.update(cx, |item, cx| item.step_over(cx)) + } + }) + .register_action(|workspace, _: &StepBack, _, cx| { + let debug_panel = workspace.panel::(cx).unwrap(); + + if let Some(active_item) = debug_panel.read_with(cx, |panel, cx| { + panel + .active_session(cx) + .and_then(|session| session.read(cx).mode().as_running().cloned()) + }) { + active_item.update(cx, |item, cx| item.step_back(cx)) + } + }) + .register_action(|workspace, _: &Stop, _, cx| { + let debug_panel = workspace.panel::(cx).unwrap(); + + if let Some(active_item) = debug_panel.read_with(cx, |panel, cx| { + panel + .active_session(cx) + .and_then(|session| session.read(cx).mode().as_running().cloned()) + }) { + active_item.update(cx, |item, cx| item.stop_thread(cx)) + } + }) + .register_action(|workspace, _: &ToggleIgnoreBreakpoints, _, cx| { + let debug_panel = workspace.panel::(cx).unwrap(); + + if let Some(active_item) = debug_panel.read_with(cx, |panel, cx| { + panel + .active_session(cx) + .and_then(|session| session.read(cx).mode().as_running().cloned()) + }) { + active_item.update(cx, |item, cx| item.toggle_ignore_breakpoints(cx)) + } + }) + .register_action( + |workspace: &mut Workspace, _: &ShutdownDebugAdapters, _window, cx| { + workspace.project().update(cx, |project, cx| { + project.dap_store().update(cx, |store, cx| { + store.shutdown_sessions(cx).detach(); + }) + }) + }, + ); + }) + }) + .detach(); +} diff --git a/crates/debugger_ui/src/session.rs b/crates/debugger_ui/src/session.rs new file mode 100644 index 0000000000..c28aff90f8 --- /dev/null +++ b/crates/debugger_ui/src/session.rs @@ -0,0 +1,313 @@ +mod failed; +mod inert; +pub mod running; +mod starting; + +use std::time::Duration; + +use dap::client::SessionId; +use failed::FailedState; +use gpui::{ + percentage, Animation, AnimationExt, AnyElement, App, Entity, EventEmitter, FocusHandle, + Focusable, Subscription, Task, Transformation, WeakEntity, +}; +use inert::{InertEvent, InertState}; +use project::debugger::{dap_store::DapStore, session::Session}; +use project::worktree_store::WorktreeStore; +use project::Project; +use rpc::proto::{self, PeerId}; +use running::RunningState; +use starting::{StartingEvent, StartingState}; +use ui::prelude::*; +use workspace::{ + item::{self, Item}, + FollowableItem, ViewId, Workspace, +}; + +pub(crate) enum DebugSessionState { + Inert(Entity), + Starting(Entity), + Failed(Entity), + Running(Entity), +} + +impl DebugSessionState { + pub(crate) fn as_running(&self) -> Option<&Entity> { + match &self { + DebugSessionState::Running(entity) => Some(entity), + _ => None, + } + } +} + +pub struct DebugSession { + remote_id: Option, + mode: DebugSessionState, + dap_store: WeakEntity, + worktree_store: WeakEntity, + workspace: WeakEntity, + _subscriptions: [Subscription; 1], +} + +#[derive(Debug)] +pub enum DebugPanelItemEvent { + Close, + Stopped { go_to_stack_frame: bool }, +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum ThreadItem { + Console, + LoadedSource, + Modules, + Variables, +} + +impl DebugSession { + pub(super) fn inert( + project: Entity, + workspace: WeakEntity, + window: &mut Window, + cx: &mut App, + ) -> Entity { + let default_cwd = project + .read(cx) + .worktrees(cx) + .next() + .and_then(|tree| tree.read(cx).abs_path().to_str().map(|str| str.to_string())) + .unwrap_or_default(); + + let inert = cx.new(|cx| InertState::new(workspace.clone(), &default_cwd, window, cx)); + + let project = project.read(cx); + let dap_store = project.dap_store().downgrade(); + let worktree_store = project.worktree_store().downgrade(); + cx.new(|cx| { + let _subscriptions = [cx.subscribe_in(&inert, window, Self::on_inert_event)]; + Self { + remote_id: None, + mode: DebugSessionState::Inert(inert), + dap_store, + worktree_store, + workspace, + _subscriptions, + } + }) + } + + pub(crate) fn running( + project: Entity, + workspace: WeakEntity, + session: Entity, + window: &mut Window, + cx: &mut App, + ) -> Entity { + let mode = cx.new(|cx| RunningState::new(session.clone(), workspace.clone(), window, cx)); + + cx.new(|cx| Self { + _subscriptions: [cx.subscribe(&mode, |_, _, _, cx| { + cx.notify(); + })], + remote_id: None, + mode: DebugSessionState::Running(mode), + dap_store: project.read(cx).dap_store().downgrade(), + worktree_store: project.read(cx).worktree_store().downgrade(), + workspace, + }) + } + + pub(crate) fn session_id(&self, cx: &App) -> Option { + match &self.mode { + DebugSessionState::Inert(_) => None, + DebugSessionState::Starting(entity) => Some(entity.read(cx).session_id), + DebugSessionState::Failed(_) => None, + DebugSessionState::Running(entity) => Some(entity.read(cx).session_id()), + } + } + + pub(crate) fn shutdown(&mut self, cx: &mut Context) { + match &self.mode { + DebugSessionState::Inert(_) => {} + DebugSessionState::Starting(_entity) => {} // todo(debugger): we need to shutdown the starting process in this case (or recreate it on a breakpoint being hit) + DebugSessionState::Failed(_) => {} + DebugSessionState::Running(state) => state.update(cx, |state, cx| state.shutdown(cx)), + } + } + + pub(crate) fn mode(&self) -> &DebugSessionState { + &self.mode + } + + fn on_inert_event( + &mut self, + _: &Entity, + event: &InertEvent, + window: &mut Window, + cx: &mut Context<'_, Self>, + ) { + let dap_store = self.dap_store.clone(); + let InertEvent::Spawned { config } = event; + let config = config.clone(); + let worktree = self + .worktree_store + .update(cx, |this, _| this.worktrees().next()) + .ok() + .flatten() + .expect("worktree-less project"); + let Ok((new_session_id, task)) = dap_store.update(cx, |store, cx| { + store.new_session(config, &worktree, None, cx) + }) else { + return; + }; + let starting = cx.new(|cx| StartingState::new(new_session_id, task, cx)); + + self._subscriptions = [cx.subscribe_in(&starting, window, Self::on_starting_event)]; + self.mode = DebugSessionState::Starting(starting); + } + + fn on_starting_event( + &mut self, + _: &Entity, + event: &StartingEvent, + window: &mut Window, + cx: &mut Context<'_, Self>, + ) { + if let StartingEvent::Finished(session) = event { + let mode = + cx.new(|cx| RunningState::new(session.clone(), self.workspace.clone(), window, cx)); + self.mode = DebugSessionState::Running(mode); + } else if let StartingEvent::Failed = event { + self.mode = DebugSessionState::Failed(cx.new(FailedState::new)); + }; + cx.notify(); + } +} +impl EventEmitter for DebugSession {} + +impl Focusable for DebugSession { + fn focus_handle(&self, cx: &App) -> FocusHandle { + match &self.mode { + DebugSessionState::Inert(inert_state) => inert_state.focus_handle(cx), + DebugSessionState::Starting(starting_state) => starting_state.focus_handle(cx), + DebugSessionState::Failed(failed_state) => failed_state.focus_handle(cx), + DebugSessionState::Running(running_state) => running_state.focus_handle(cx), + } + } +} + +impl Item for DebugSession { + type Event = DebugPanelItemEvent; + fn tab_content(&self, _: item::TabContentParams, _: &Window, cx: &App) -> AnyElement { + let (label, color) = match &self.mode { + DebugSessionState::Inert(_) => ("New Session", Color::Default), + DebugSessionState::Starting(_) => ("Starting", Color::Default), + DebugSessionState::Failed(_) => ("Failed", Color::Error), + DebugSessionState::Running(state) => ( + state + .read_with(cx, |state, cx| state.thread_status(cx)) + .map(|status| status.label()) + .unwrap_or("Running"), + Color::Default, + ), + }; + + let is_starting = matches!(self.mode, DebugSessionState::Starting(_)); + + h_flex() + .gap_1() + .children(is_starting.then(|| { + Icon::new(IconName::ArrowCircle).with_animation( + "starting-debug-session", + Animation::new(Duration::from_secs(2)).repeat(), + |this, delta| this.transform(Transformation::rotate(percentage(delta))), + ) + })) + .child(Label::new(label).color(color)) + .into_any_element() + } +} + +impl FollowableItem for DebugSession { + fn remote_id(&self) -> Option { + self.remote_id + } + + fn to_state_proto(&self, _window: &Window, _cx: &App) -> Option { + None + } + + fn from_state_proto( + _workspace: Entity, + _remote_id: ViewId, + _state: &mut Option, + _window: &mut Window, + _cx: &mut App, + ) -> Option>>> { + None + } + + fn add_event_to_update_proto( + &self, + _event: &Self::Event, + _update: &mut Option, + _window: &Window, + _cx: &App, + ) -> bool { + // update.get_or_insert_with(|| proto::update_view::Variant::DebugPanel(Default::default())); + + true + } + + fn apply_update_proto( + &mut self, + _project: &Entity, + _message: proto::update_view::Variant, + _window: &mut Window, + _cx: &mut Context, + ) -> gpui::Task> { + Task::ready(Ok(())) + } + + fn set_leader_peer_id( + &mut self, + _leader_peer_id: Option, + _window: &mut Window, + _cx: &mut Context, + ) { + } + + fn to_follow_event(_event: &Self::Event) -> Option { + None + } + + fn dedup(&self, existing: &Self, _window: &Window, cx: &App) -> Option { + if existing.session_id(cx) == self.session_id(cx) { + Some(item::Dedup::KeepExisting) + } else { + None + } + } + + fn is_project_item(&self, _window: &Window, _cx: &App) -> bool { + true + } +} + +impl Render for DebugSession { + fn render(&mut self, window: &mut Window, cx: &mut Context<'_, Self>) -> impl IntoElement { + match &self.mode { + DebugSessionState::Inert(inert_state) => { + inert_state.update(cx, |this, cx| this.render(window, cx).into_any_element()) + } + DebugSessionState::Starting(starting_state) => { + starting_state.update(cx, |this, cx| this.render(window, cx).into_any_element()) + } + DebugSessionState::Failed(failed_state) => { + failed_state.update(cx, |this, cx| this.render(window, cx).into_any_element()) + } + DebugSessionState::Running(running_state) => { + running_state.update(cx, |this, cx| this.render(window, cx).into_any_element()) + } + } + } +} diff --git a/crates/debugger_ui/src/session/failed.rs b/crates/debugger_ui/src/session/failed.rs new file mode 100644 index 0000000000..38f84df661 --- /dev/null +++ b/crates/debugger_ui/src/session/failed.rs @@ -0,0 +1,30 @@ +use gpui::{FocusHandle, Focusable}; +use ui::{ + h_flex, Color, Context, IntoElement, Label, LabelCommon, ParentElement, Render, Styled, Window, +}; + +pub(crate) struct FailedState { + focus_handle: FocusHandle, +} +impl FailedState { + pub(super) fn new(cx: &mut Context) -> Self { + Self { + focus_handle: cx.focus_handle(), + } + } +} + +impl Focusable for FailedState { + fn focus_handle(&self, _: &ui::App) -> FocusHandle { + self.focus_handle.clone() + } +} +impl Render for FailedState { + fn render(&mut self, _: &mut Window, _: &mut Context<'_, Self>) -> impl IntoElement { + h_flex() + .size_full() + .items_center() + .justify_center() + .child(Label::new("Failed to spawn debugging session").color(Color::Error)) + } +} diff --git a/crates/debugger_ui/src/session/inert.rs b/crates/debugger_ui/src/session/inert.rs new file mode 100644 index 0000000000..829f4c5060 --- /dev/null +++ b/crates/debugger_ui/src/session/inert.rs @@ -0,0 +1,219 @@ +use std::path::PathBuf; + +use dap::{DebugAdapterConfig, DebugAdapterKind, DebugRequestType}; +use editor::{Editor, EditorElement, EditorStyle}; +use gpui::{App, AppContext, Entity, EventEmitter, FocusHandle, Focusable, TextStyle, WeakEntity}; +use settings::Settings as _; +use task::TCPHost; +use theme::ThemeSettings; +use ui::{ + h_flex, relative, v_flex, ActiveTheme as _, Button, ButtonCommon, ButtonStyle, Clickable, + Context, ContextMenu, Disableable, DropdownMenu, InteractiveElement, IntoElement, + ParentElement, Render, SharedString, Styled, Window, +}; +use workspace::Workspace; + +use crate::attach_modal::AttachModal; + +pub(crate) struct InertState { + focus_handle: FocusHandle, + selected_debugger: Option, + program_editor: Entity, + cwd_editor: Entity, + workspace: WeakEntity, +} + +impl InertState { + pub(super) fn new( + workspace: WeakEntity, + default_cwd: &str, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let program_editor = cx.new(|cx| { + let mut editor = Editor::single_line(window, cx); + editor.set_placeholder_text("Program path", cx); + editor + }); + let cwd_editor = cx.new(|cx| { + let mut editor = Editor::single_line(window, cx); + editor.insert(default_cwd, window, cx); + editor.set_placeholder_text("Working directory", cx); + editor + }); + Self { + workspace, + cwd_editor, + program_editor, + selected_debugger: None, + focus_handle: cx.focus_handle(), + } + } +} +impl Focusable for InertState { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +pub(crate) enum InertEvent { + Spawned { config: DebugAdapterConfig }, +} + +impl EventEmitter for InertState {} + +static SELECT_DEBUGGER_LABEL: SharedString = SharedString::new_static("Select Debugger"); + +impl Render for InertState { + fn render( + &mut self, + window: &mut ui::Window, + cx: &mut ui::Context<'_, Self>, + ) -> impl ui::IntoElement { + let weak = cx.weak_entity(); + let disable_buttons = self.selected_debugger.is_none(); + v_flex() + .track_focus(&self.focus_handle) + .size_full() + .gap_1() + .p_2() + + .child( + v_flex().gap_1() + .child( + h_flex() + .w_full() + .gap_2() + + .child(Self::render_editor(&self.program_editor, cx)) + .child( + h_flex().child(DropdownMenu::new( + "dap-adapter-picker", + self.selected_debugger + .as_ref() + .unwrap_or_else(|| &SELECT_DEBUGGER_LABEL) + .clone(), + ContextMenu::build(window, cx, move |this, _, _| { + let setter_for_name = |name: &'static str| { + let weak = weak.clone(); + move |_: &mut Window, cx: &mut App| { + let name = name; + (&weak) + .update(cx, move |this, _| { + this.selected_debugger = Some(name.into()); + }) + .ok(); + } + }; + this.entry("GDB", None, setter_for_name("GDB")) + .entry("Delve", None, setter_for_name("Delve")) + .entry("LLDB", None, setter_for_name("LLDB")) + .entry("PHP", None, setter_for_name("PHP")) + .entry("JavaScript", None, setter_for_name("JavaScript")) + .entry("Debugpy", None, setter_for_name("Debugpy")) + }), + )), + ) + ) + .child( + h_flex().gap_2().child( + Self::render_editor(&self.cwd_editor, cx), + ).child(h_flex() + .gap_4() + .pl_2() + .child( + Button::new("launch-dap", "Launch") + .style(ButtonStyle::Filled) + .disabled(disable_buttons) + .on_click(cx.listener(|this, _, _, cx| { + let program = this.program_editor.read(cx).text(cx); + let cwd = PathBuf::from(this.cwd_editor.read(cx).text(cx)); + let kind = kind_for_label(this.selected_debugger.as_deref().unwrap_or_else(|| unimplemented!("Automatic selection of a debugger based on users project"))); + cx.emit(InertEvent::Spawned { + config: DebugAdapterConfig { + label: "hard coded".into(), + kind, + request: DebugRequestType::Launch, + program: Some(program), + cwd: Some(cwd), + initialize_args: None, + supports_attach: false, + }, + }); + })), + ) + .child(Button::new("attach-dap", "Attach") + .style(ButtonStyle::Filled) + .disabled(disable_buttons) + .on_click(cx.listener(|this, _, window, cx| this.attach(window, cx))) + )) + ) + ) + } +} + +fn kind_for_label(label: &str) -> DebugAdapterKind { + match label { + "LLDB" => DebugAdapterKind::Lldb, + "Debugpy" => DebugAdapterKind::Python(TCPHost::default()), + "JavaScript" => DebugAdapterKind::Javascript(TCPHost::default()), + "PHP" => DebugAdapterKind::Php(TCPHost::default()), + "Delve" => DebugAdapterKind::Go(TCPHost::default()), + _ => { + unimplemented!() + } // Maybe we should set a toast notification here + } +} +impl InertState { + fn render_editor(editor: &Entity, cx: &Context) -> impl IntoElement { + let settings = ThemeSettings::get_global(cx); + let text_style = TextStyle { + color: cx.theme().colors().text, + font_family: settings.buffer_font.family.clone(), + font_features: settings.buffer_font.features.clone(), + font_size: settings.buffer_font_size(cx).into(), + font_weight: settings.buffer_font.weight, + line_height: relative(settings.buffer_line_height.value()), + ..Default::default() + }; + + EditorElement::new( + editor, + EditorStyle { + background: cx.theme().colors().editor_background, + local_player: cx.theme().players().local(), + text: text_style, + ..Default::default() + }, + ) + } + + fn attach(&self, window: &mut Window, cx: &mut Context) { + let process_id = self.program_editor.read(cx).text(cx).parse::().ok(); + let cwd = PathBuf::from(self.cwd_editor.read(cx).text(cx)); + let kind = kind_for_label(self.selected_debugger.as_deref().unwrap_or_else(|| { + unimplemented!("Automatic selection of a debugger based on users project") + })); + + let config = DebugAdapterConfig { + label: "hard coded attach".into(), + kind, + request: DebugRequestType::Attach(task::AttachConfig { process_id }), + program: None, + cwd: Some(cwd), + initialize_args: None, + supports_attach: true, + }; + + if process_id.is_some() { + cx.emit(InertEvent::Spawned { config }); + } else { + let _ = self.workspace.update(cx, |workspace, cx| { + let project = workspace.project().clone(); + workspace.toggle_modal(window, cx, |window, cx| { + AttachModal::new(project, config, window, cx) + }); + }); + } + } +} diff --git a/crates/debugger_ui/src/session/running.rs b/crates/debugger_ui/src/session/running.rs new file mode 100644 index 0000000000..8cb3687554 --- /dev/null +++ b/crates/debugger_ui/src/session/running.rs @@ -0,0 +1,686 @@ +mod console; +mod loaded_source_list; +mod module_list; +pub mod stack_frame_list; +pub mod variable_list; + +use super::{DebugPanelItemEvent, ThreadItem}; +use console::Console; +use dap::{client::SessionId, debugger_settings::DebuggerSettings, Capabilities, Thread}; +use gpui::{AppContext, Entity, EventEmitter, FocusHandle, Focusable, Subscription, WeakEntity}; +use loaded_source_list::LoadedSourceList; +use module_list::ModuleList; +use project::debugger::session::{Session, SessionEvent, ThreadId, ThreadStatus}; +use rpc::proto::ViewId; +use settings::Settings; +use stack_frame_list::StackFrameList; +use ui::{ + div, h_flex, v_flex, ActiveTheme, AnyElement, App, Button, ButtonCommon, Clickable, Context, + ContextMenu, Disableable, DropdownMenu, FluentBuilder, IconButton, IconName, IconSize, + Indicator, InteractiveElement, IntoElement, ParentElement, Render, SharedString, + StatefulInteractiveElement, Styled, Tooltip, Window, +}; +use util::ResultExt; +use variable_list::VariableList; +use workspace::Workspace; + +pub struct RunningState { + session: Entity, + thread_id: Option, + console: Entity, + focus_handle: FocusHandle, + _remote_id: Option, + show_console_indicator: bool, + module_list: Entity, + active_thread_item: ThreadItem, + workspace: WeakEntity, + session_id: SessionId, + variable_list: Entity, + _subscriptions: Vec, + stack_frame_list: Entity, + loaded_source_list: Entity, +} + +impl Render for RunningState { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let threads = self.session.update(cx, |this, cx| this.threads(cx)); + self.select_current_thread(&threads, cx); + + let thread_status = self + .thread_id + .map(|thread_id| self.session.read(cx).thread_status(thread_id)) + .unwrap_or(ThreadStatus::Exited); + + let selected_thread_name = threads + .iter() + .find(|(thread, _)| self.thread_id.map(|id| id.0) == Some(thread.id)) + .map(|(thread, _)| thread.name.clone()) + .unwrap_or("Threads".to_owned()); + + self.variable_list.update(cx, |this, cx| { + this.disabled(thread_status != ThreadStatus::Stopped, cx); + }); + + let is_terminated = self.session.read(cx).is_terminated(); + let active_thread_item = &self.active_thread_item; + + let has_no_threads = threads.is_empty(); + let capabilities = self.capabilities(cx); + let state = cx.entity(); + h_flex() + .when(is_terminated, |this| this.bg(gpui::red())) + .key_context("DebugPanelItem") + .track_focus(&self.focus_handle(cx)) + .size_full() + .items_start() + .child( + v_flex() + .size_full() + .items_start() + .child( + h_flex() + .w_full() + .border_b_1() + .border_color(cx.theme().colors().border_variant) + .justify_between() + .child( + h_flex() + .p_1() + .w_full() + .gap_2() + .map(|this| { + if thread_status == ThreadStatus::Running { + this.child( + IconButton::new( + "debug-pause", + IconName::DebugPause, + ) + .icon_size(IconSize::Small) + .on_click(cx.listener(|this, _, _window, cx| { + this.pause_thread(cx); + })) + .tooltip(move |window, cx| { + Tooltip::text("Pause program")(window, cx) + }), + ) + } else { + this.child( + IconButton::new( + "debug-continue", + IconName::DebugContinue, + ) + .icon_size(IconSize::Small) + .on_click(cx.listener(|this, _, _window, cx| { + this.continue_thread(cx) + })) + .disabled(thread_status != ThreadStatus::Stopped) + .tooltip(move |window, cx| { + Tooltip::text("Continue program")(window, cx) + }), + ) + } + }) + .when( + capabilities.supports_step_back.unwrap_or(false), + |this| { + this.child( + IconButton::new( + "debug-step-back", + IconName::DebugStepBack, + ) + .icon_size(IconSize::Small) + .on_click(cx.listener(|this, _, _window, cx| { + this.step_back(cx); + })) + .disabled(thread_status != ThreadStatus::Stopped) + .tooltip(move |window, cx| { + Tooltip::text("Step back")(window, cx) + }), + ) + }, + ) + .child( + IconButton::new("debug-step-over", IconName::DebugStepOver) + .icon_size(IconSize::Small) + .on_click(cx.listener(|this, _, _window, cx| { + this.step_over(cx); + })) + .disabled(thread_status != ThreadStatus::Stopped) + .tooltip(move |window, cx| { + Tooltip::text("Step over")(window, cx) + }), + ) + .child( + IconButton::new("debug-step-in", IconName::DebugStepInto) + .icon_size(IconSize::Small) + .on_click(cx.listener(|this, _, _window, cx| { + this.step_in(cx); + })) + .disabled(thread_status != ThreadStatus::Stopped) + .tooltip(move |window, cx| { + Tooltip::text("Step in")(window, cx) + }), + ) + .child( + IconButton::new("debug-step-out", IconName::DebugStepOut) + .icon_size(IconSize::Small) + .on_click(cx.listener(|this, _, _window, cx| { + this.step_out(cx); + })) + .disabled(thread_status != ThreadStatus::Stopped) + .tooltip(move |window, cx| { + Tooltip::text("Step out")(window, cx) + }), + ) + .child( + IconButton::new("debug-restart", IconName::DebugRestart) + .icon_size(IconSize::Small) + .on_click(cx.listener(|this, _, _window, cx| { + this.restart_session(cx); + })) + .disabled( + !capabilities + .supports_restart_request + .unwrap_or_default(), + ) + .tooltip(move |window, cx| { + Tooltip::text("Restart")(window, cx) + }), + ) + .child( + IconButton::new("debug-stop", IconName::DebugStop) + .icon_size(IconSize::Small) + .on_click(cx.listener(|this, _, _window, cx| { + this.stop_thread(cx); + })) + .disabled( + thread_status != ThreadStatus::Stopped + && thread_status != ThreadStatus::Running, + ) + .tooltip({ + let label = if capabilities + .supports_terminate_threads_request + .unwrap_or_default() + { + "Terminate Thread" + } else { + "Terminate all Threads" + }; + move |window, cx| Tooltip::text(label)(window, cx) + }), + ) + .child( + IconButton::new( + "debug-disconnect", + IconName::DebugDisconnect, + ) + .icon_size(IconSize::Small) + .on_click(cx.listener(|this, _, _window, cx| { + this.disconnect_client(cx); + })) + .disabled( + thread_status == ThreadStatus::Exited + || thread_status == ThreadStatus::Ended, + ) + .tooltip( + move |window, cx| { + Tooltip::text("Disconnect")(window, cx) + }, + ), + ) + .child( + IconButton::new( + "debug-ignore-breakpoints", + if self.session.read(cx).breakpoints_enabled() { + IconName::DebugBreakpoint + } else { + IconName::DebugIgnoreBreakpoints + }, + ) + .icon_size(IconSize::Small) + .on_click(cx.listener(|this, _, _window, cx| { + this.toggle_ignore_breakpoints(cx); + })) + .disabled( + thread_status == ThreadStatus::Exited + || thread_status == ThreadStatus::Ended, + ) + .tooltip( + move |window, cx| { + Tooltip::text("Ignore breakpoints")(window, cx) + }, + ), + ), + ) + //.child(h_flex()) + .child( + h_flex().p_1().mx_2().w_3_4().justify_end().child( + DropdownMenu::new( + ("thread-list", self.session_id.0), + selected_thread_name, + ContextMenu::build(window, cx, move |mut this, _, _| { + for (thread, _) in threads { + let state = state.clone(); + let thread_id = thread.id; + this = + this.entry(thread.name, None, move |_, cx| { + state.update(cx, |state, cx| { + state.select_thread( + ThreadId(thread_id), + cx, + ); + }); + }); + } + this + }), + ) + .disabled( + has_no_threads || thread_status != ThreadStatus::Stopped, + ), + ), + ), + ) + .child( + h_flex() + .size_full() + .items_start() + .p_1() + .gap_4() + .child(self.stack_frame_list.clone()), + ), + ) + .child( + v_flex() + .border_l_1() + .border_color(cx.theme().colors().border_variant) + .size_full() + .items_start() + .child( + h_flex() + .border_b_1() + .w_full() + .border_color(cx.theme().colors().border_variant) + .child(self.render_entry_button( + &SharedString::from("Variables"), + ThreadItem::Variables, + cx, + )) + .when( + capabilities.supports_modules_request.unwrap_or_default(), + |this| { + this.child(self.render_entry_button( + &SharedString::from("Modules"), + ThreadItem::Modules, + cx, + )) + }, + ) + .when( + capabilities + .supports_loaded_sources_request + .unwrap_or_default(), + |this| { + this.child(self.render_entry_button( + &SharedString::from("Loaded Sources"), + ThreadItem::LoadedSource, + cx, + )) + }, + ) + .child(self.render_entry_button( + &SharedString::from("Console"), + ThreadItem::Console, + cx, + )), + ) + .when(*active_thread_item == ThreadItem::Variables, |this| { + this.child(self.variable_list.clone()) + }) + .when(*active_thread_item == ThreadItem::Modules, |this| { + this.size_full().child(self.module_list.clone()) + }) + .when(*active_thread_item == ThreadItem::LoadedSource, |this| { + this.size_full().child(self.loaded_source_list.clone()) + }) + .when(*active_thread_item == ThreadItem::Console, |this| { + this.child(self.console.clone()) + }), + ) + } +} + +impl RunningState { + pub fn new( + session: Entity, + workspace: WeakEntity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let focus_handle = cx.focus_handle(); + let session_id = session.read(cx).session_id(); + let weak_state = cx.weak_entity(); + let stack_frame_list = cx.new(|cx| { + StackFrameList::new(workspace.clone(), session.clone(), weak_state, window, cx) + }); + + let variable_list = + cx.new(|cx| VariableList::new(session.clone(), stack_frame_list.clone(), window, cx)); + + let module_list = cx.new(|cx| ModuleList::new(session.clone(), workspace.clone(), cx)); + + let loaded_source_list = cx.new(|cx| LoadedSourceList::new(session.clone(), cx)); + + let console = cx.new(|cx| { + Console::new( + session.clone(), + stack_frame_list.clone(), + variable_list.clone(), + window, + cx, + ) + }); + + let _subscriptions = vec![ + cx.observe(&module_list, |_, _, cx| cx.notify()), + cx.subscribe_in(&session, window, |this, _, event, window, cx| { + match event { + SessionEvent::Stopped(thread_id) => { + this.workspace + .update(cx, |workspace, cx| { + workspace.open_panel::(window, cx); + }) + .log_err(); + + if let Some(thread_id) = thread_id { + this.select_thread(*thread_id, cx); + } + } + SessionEvent::Threads => { + let threads = this.session.update(cx, |this, cx| this.threads(cx)); + this.select_current_thread(&threads, cx); + } + _ => {} + } + cx.notify() + }), + ]; + + Self { + session, + console, + workspace, + module_list, + focus_handle, + variable_list, + _subscriptions, + thread_id: None, + _remote_id: None, + stack_frame_list, + loaded_source_list, + session_id, + show_console_indicator: false, + active_thread_item: ThreadItem::Variables, + } + } + + pub(crate) fn go_to_selected_stack_frame(&self, window: &Window, cx: &mut Context) { + if self.thread_id.is_some() { + self.stack_frame_list + .update(cx, |list, cx| list.go_to_selected_stack_frame(window, cx)); + } + } + + pub fn session(&self) -> &Entity { + &self.session + } + + pub fn session_id(&self) -> SessionId { + self.session_id + } + + #[cfg(any(test, feature = "test-support"))] + pub fn set_thread_item(&mut self, thread_item: ThreadItem, cx: &mut Context) { + self.active_thread_item = thread_item; + cx.notify() + } + + #[cfg(any(test, feature = "test-support"))] + pub fn stack_frame_list(&self) -> &Entity { + &self.stack_frame_list + } + + #[cfg(any(test, feature = "test-support"))] + pub fn console(&self) -> &Entity { + &self.console + } + + #[cfg(any(test, feature = "test-support"))] + pub fn module_list(&self) -> &Entity { + &self.module_list + } + + #[cfg(any(test, feature = "test-support"))] + pub fn variable_list(&self) -> &Entity { + &self.variable_list + } + + #[cfg(any(test, feature = "test-support"))] + pub fn are_breakpoints_ignored(&self, cx: &App) -> bool { + self.session.read(cx).ignore_breakpoints() + } + + pub fn capabilities(&self, cx: &App) -> Capabilities { + self.session().read(cx).capabilities().clone() + } + + pub fn select_current_thread( + &mut self, + threads: &Vec<(Thread, ThreadStatus)>, + cx: &mut Context, + ) { + let selected_thread = self + .thread_id + .and_then(|thread_id| threads.iter().find(|(thread, _)| thread.id == thread_id.0)) + .or_else(|| threads.first()); + + let Some((selected_thread, _)) = selected_thread else { + return; + }; + + if Some(ThreadId(selected_thread.id)) != self.thread_id { + self.select_thread(ThreadId(selected_thread.id), cx); + } + } + + #[cfg(any(test, feature = "test-support"))] + pub fn selected_thread_id(&self) -> Option { + self.thread_id + } + + pub fn thread_status(&self, cx: &App) -> Option { + self.thread_id + .map(|id| self.session().read(cx).thread_status(id)) + } + + fn select_thread(&mut self, thread_id: ThreadId, cx: &mut Context) { + if self.thread_id.is_some_and(|id| id == thread_id) { + return; + } + + self.thread_id = Some(thread_id); + + self.stack_frame_list + .update(cx, |list, cx| list.refresh(cx)); + cx.notify(); + } + + fn render_entry_button( + &self, + label: &SharedString, + thread_item: ThreadItem, + cx: &mut Context, + ) -> AnyElement { + let has_indicator = + matches!(thread_item, ThreadItem::Console) && self.show_console_indicator; + + div() + .id(label.clone()) + .px_2() + .py_1() + .cursor_pointer() + .border_b_2() + .when(self.active_thread_item == thread_item, |this| { + this.border_color(cx.theme().colors().border) + }) + .child( + h_flex() + .child(Button::new(label.clone(), label.clone())) + .when(has_indicator, |this| this.child(Indicator::dot())), + ) + .on_click(cx.listener(move |this, _, _window, cx| { + this.active_thread_item = thread_item; + + if matches!(this.active_thread_item, ThreadItem::Console) { + this.show_console_indicator = false; + } + + cx.notify(); + })) + .into_any_element() + } + + pub fn continue_thread(&mut self, cx: &mut Context) { + let Some(thread_id) = self.thread_id else { + return; + }; + + self.session().update(cx, |state, cx| { + state.continue_thread(thread_id, cx); + }); + } + + pub fn step_over(&mut self, cx: &mut Context) { + let Some(thread_id) = self.thread_id else { + return; + }; + + let granularity = DebuggerSettings::get_global(cx).stepping_granularity; + + self.session().update(cx, |state, cx| { + state.step_over(thread_id, granularity, cx); + }); + } + + pub fn step_in(&mut self, cx: &mut Context) { + let Some(thread_id) = self.thread_id else { + return; + }; + + let granularity = DebuggerSettings::get_global(cx).stepping_granularity; + + self.session().update(cx, |state, cx| { + state.step_in(thread_id, granularity, cx); + }); + } + + pub fn step_out(&mut self, cx: &mut Context) { + let Some(thread_id) = self.thread_id else { + return; + }; + + let granularity = DebuggerSettings::get_global(cx).stepping_granularity; + + self.session().update(cx, |state, cx| { + state.step_out(thread_id, granularity, cx); + }); + } + + pub fn step_back(&mut self, cx: &mut Context) { + let Some(thread_id) = self.thread_id else { + return; + }; + + let granularity = DebuggerSettings::get_global(cx).stepping_granularity; + + self.session().update(cx, |state, cx| { + state.step_back(thread_id, granularity, cx); + }); + } + + pub fn restart_session(&self, cx: &mut Context) { + self.session().update(cx, |state, cx| { + state.restart(None, cx); + }); + } + + pub fn pause_thread(&self, cx: &mut Context) { + let Some(thread_id) = self.thread_id else { + return; + }; + + self.session().update(cx, |state, cx| { + state.pause_thread(thread_id, cx); + }); + } + + pub(crate) fn shutdown(&mut self, cx: &mut Context) { + self.workspace + .update(cx, |workspace, cx| { + workspace + .project() + .read(cx) + .breakpoint_store() + .update(cx, |store, cx| { + store.remove_active_position(Some(self.session_id), cx) + }) + }) + .log_err(); + + self.session.update(cx, |session, cx| { + session.shutdown(cx).detach(); + }) + } + + pub fn stop_thread(&self, cx: &mut Context) { + let Some(thread_id) = self.thread_id else { + return; + }; + + self.workspace + .update(cx, |workspace, cx| { + workspace + .project() + .read(cx) + .breakpoint_store() + .update(cx, |store, cx| { + store.remove_active_position(Some(self.session_id), cx) + }) + }) + .log_err(); + + self.session().update(cx, |state, cx| { + state.terminate_threads(Some(vec![thread_id; 1]), cx); + }); + } + + pub fn disconnect_client(&self, cx: &mut Context) { + self.session().update(cx, |state, cx| { + state.disconnect_client(cx); + }); + } + + pub fn toggle_ignore_breakpoints(&mut self, cx: &mut Context) { + self.session.update(cx, |session, cx| { + session.toggle_ignore_breakpoints(cx).detach(); + }); + } +} + +impl EventEmitter for RunningState {} + +impl Focusable for RunningState { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus_handle.clone() + } +} diff --git a/crates/debugger_ui/src/session/running/console.rs b/crates/debugger_ui/src/session/running/console.rs new file mode 100644 index 0000000000..6fc59bd49c --- /dev/null +++ b/crates/debugger_ui/src/session/running/console.rs @@ -0,0 +1,419 @@ +use super::{ + stack_frame_list::{StackFrameList, StackFrameListEvent}, + variable_list::VariableList, +}; +use anyhow::Result; +use collections::HashMap; +use dap::OutputEvent; +use editor::{CompletionProvider, Editor, EditorElement, EditorStyle}; +use fuzzy::StringMatchCandidate; +use gpui::{Context, Entity, Render, Subscription, Task, TextStyle, WeakEntity}; +use language::{Buffer, CodeLabel}; +use menu::Confirm; +use project::{ + debugger::session::{CompletionsQuery, OutputToken, Session}, + Completion, +}; +use settings::Settings; +use std::{cell::RefCell, rc::Rc, usize}; +use theme::ThemeSettings; +use ui::prelude::*; + +pub struct Console { + console: Entity, + query_bar: Entity, + session: Entity, + _subscriptions: Vec, + variable_list: Entity, + stack_frame_list: Entity, + last_token: OutputToken, + update_output_task: Task<()>, +} + +impl Console { + pub fn new( + session: Entity, + stack_frame_list: Entity, + variable_list: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let console = cx.new(|cx| { + let mut editor = Editor::multi_line(window, cx); + editor.move_to_end(&editor::actions::MoveToEnd, window, cx); + editor.set_read_only(true); + editor.set_show_gutter(true, cx); + editor.set_show_runnables(false, cx); + editor.set_show_breakpoints(false, cx); + editor.set_show_code_actions(false, cx); + editor.set_show_line_numbers(false, cx); + editor.set_show_git_diff_gutter(false, cx); + editor.set_autoindent(false); + editor.set_input_enabled(false); + editor.set_use_autoclose(false); + editor.set_show_wrap_guides(false, cx); + editor.set_show_indent_guides(false, cx); + editor.set_show_edit_predictions(Some(false), window, cx); + editor + }); + + let this = cx.weak_entity(); + let query_bar = cx.new(|cx| { + let mut editor = Editor::single_line(window, cx); + editor.set_placeholder_text("Evaluate an expression", cx); + editor.set_use_autoclose(false); + editor.set_show_gutter(false, cx); + editor.set_show_wrap_guides(false, cx); + editor.set_show_indent_guides(false, cx); + editor.set_completion_provider(Some(Box::new(ConsoleQueryBarCompletionProvider(this)))); + + editor + }); + + let _subscriptions = + vec![cx.subscribe(&stack_frame_list, Self::handle_stack_frame_list_events)]; + + Self { + session, + console, + query_bar, + variable_list, + _subscriptions, + stack_frame_list, + update_output_task: Task::ready(()), + last_token: OutputToken(0), + } + } + + #[cfg(any(test, feature = "test-support"))] + pub fn editor(&self) -> &Entity { + &self.console + } + + #[cfg(any(test, feature = "test-support"))] + pub fn query_bar(&self) -> &Entity { + &self.query_bar + } + + fn is_local(&self, cx: &Context) -> bool { + self.session.read(cx).is_local() + } + + fn handle_stack_frame_list_events( + &mut self, + _: Entity, + event: &StackFrameListEvent, + cx: &mut Context, + ) { + match event { + StackFrameListEvent::SelectedStackFrameChanged(_) => cx.notify(), + } + } + + pub fn add_messages<'a>( + &mut self, + events: impl Iterator, + window: &mut Window, + cx: &mut App, + ) { + self.console.update(cx, |console, cx| { + let mut to_insert = String::default(); + for event in events { + use std::fmt::Write; + + _ = write!(to_insert, "{}\n", event.output.trim_end()); + } + + console.set_read_only(false); + console.move_to_end(&editor::actions::MoveToEnd, window, cx); + console.insert(&to_insert, window, cx); + console.set_read_only(true); + + cx.notify(); + }); + } + + pub fn evaluate(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context) { + let expression = self.query_bar.update(cx, |editor, cx| { + let expression = editor.text(cx); + + editor.clear(window, cx); + + expression + }); + + self.session.update(cx, |state, cx| { + state.evaluate( + expression, + Some(dap::EvaluateArgumentsContext::Variables), + self.stack_frame_list.read(cx).current_stack_frame_id(), + None, + cx, + ); + }); + } + + fn render_console(&self, cx: &Context) -> impl IntoElement { + let settings = ThemeSettings::get_global(cx); + let text_style = TextStyle { + color: if self.console.read(cx).read_only(cx) { + cx.theme().colors().text_disabled + } else { + cx.theme().colors().text + }, + font_family: settings.buffer_font.family.clone(), + font_features: settings.buffer_font.features.clone(), + font_size: settings.buffer_font_size(cx).into(), + font_weight: settings.buffer_font.weight, + line_height: relative(settings.buffer_line_height.value()), + ..Default::default() + }; + + EditorElement::new( + &self.console, + EditorStyle { + background: cx.theme().colors().editor_background, + local_player: cx.theme().players().local(), + text: text_style, + ..Default::default() + }, + ) + } + + fn render_query_bar(&self, cx: &Context) -> impl IntoElement { + let settings = ThemeSettings::get_global(cx); + let text_style = TextStyle { + color: if self.console.read(cx).read_only(cx) { + cx.theme().colors().text_disabled + } else { + cx.theme().colors().text + }, + font_family: settings.ui_font.family.clone(), + font_features: settings.ui_font.features.clone(), + font_fallbacks: settings.ui_font.fallbacks.clone(), + font_size: TextSize::Editor.rems(cx).into(), + font_weight: settings.ui_font.weight, + line_height: relative(1.3), + ..Default::default() + }; + + EditorElement::new( + &self.query_bar, + EditorStyle { + background: cx.theme().colors().editor_background, + local_player: cx.theme().players().local(), + text: text_style, + ..Default::default() + }, + ) + } +} + +impl Render for Console { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let session = self.session.clone(); + let token = self.last_token; + self.update_output_task = cx.spawn_in(window, move |this, mut cx| async move { + _ = session.update_in(&mut cx, move |session, window, cx| { + let (output, last_processed_token) = session.output(token); + + _ = this.update(cx, |this, cx| { + if last_processed_token == this.last_token { + return; + } + this.add_messages(output, window, cx); + + this.last_token = last_processed_token; + }); + }); + }); + + v_flex() + .key_context("DebugConsole") + .on_action(cx.listener(Self::evaluate)) + .size_full() + .child(self.render_console(cx)) + .when(self.is_local(cx), |this| { + this.child(self.render_query_bar(cx)) + .pt(DynamicSpacing::Base04.rems(cx)) + }) + .border_2() + } +} + +struct ConsoleQueryBarCompletionProvider(WeakEntity); + +impl CompletionProvider for ConsoleQueryBarCompletionProvider { + fn completions( + &self, + buffer: &Entity, + buffer_position: language::Anchor, + _trigger: editor::CompletionContext, + _window: &mut Window, + cx: &mut Context, + ) -> Task>>> { + let Some(console) = self.0.upgrade() else { + return Task::ready(Ok(None)); + }; + + let support_completions = console + .read(cx) + .session + .read(cx) + .capabilities() + .supports_completions_request + .unwrap_or_default(); + + if support_completions { + self.client_completions(&console, buffer, buffer_position, cx) + } else { + self.variable_list_completions(&console, buffer, buffer_position, cx) + } + } + + fn resolve_completions( + &self, + _buffer: Entity, + _completion_indices: Vec, + _completions: Rc>>, + _cx: &mut Context, + ) -> gpui::Task> { + Task::ready(Ok(false)) + } + + fn apply_additional_edits_for_completion( + &self, + _buffer: Entity, + _completions: Rc>>, + _completion_index: usize, + _push_to_history: bool, + _cx: &mut Context, + ) -> gpui::Task>> { + Task::ready(Ok(None)) + } + + fn is_completion_trigger( + &self, + _buffer: &Entity, + _position: language::Anchor, + _text: &str, + _trigger_in_words: bool, + _cx: &mut Context, + ) -> bool { + true + } +} + +impl ConsoleQueryBarCompletionProvider { + fn variable_list_completions( + &self, + console: &Entity, + buffer: &Entity, + buffer_position: language::Anchor, + cx: &mut Context, + ) -> Task>>> { + let (variables, string_matches) = console.update(cx, |console, cx| { + let mut variables = HashMap::default(); + let mut string_matches = Vec::default(); + + for variable in console.variable_list.update(cx, |variable_list, cx| { + variable_list.completion_variables(cx) + }) { + if let Some(evaluate_name) = &variable.evaluate_name { + variables.insert(evaluate_name.clone(), variable.value.clone()); + string_matches.push(StringMatchCandidate { + id: 0, + string: evaluate_name.clone(), + char_bag: evaluate_name.chars().collect(), + }); + } + + variables.insert(variable.name.clone(), variable.value.clone()); + + string_matches.push(StringMatchCandidate { + id: 0, + string: variable.name.clone(), + char_bag: variable.name.chars().collect(), + }); + } + + (variables, string_matches) + }); + + let query = buffer.read(cx).text(); + + cx.spawn(|_, cx| async move { + let matches = fuzzy::match_strings( + &string_matches, + &query, + true, + 10, + &Default::default(), + cx.background_executor().clone(), + ) + .await; + + Ok(Some( + matches + .iter() + .filter_map(|string_match| { + let variable_value = variables.get(&string_match.string)?; + + Some(project::Completion { + old_range: buffer_position..buffer_position, + new_text: string_match.string.clone(), + label: CodeLabel { + filter_range: 0..string_match.string.len(), + text: format!("{} {}", string_match.string.clone(), variable_value), + runs: Vec::new(), + }, + documentation: None, + confirm: None, + source: project::CompletionSource::Custom, + }) + }) + .collect(), + )) + }) + } + + fn client_completions( + &self, + console: &Entity, + buffer: &Entity, + buffer_position: language::Anchor, + cx: &mut Context, + ) -> Task>>> { + let completion_task = console.update(cx, |console, cx| { + console.session.update(cx, |state, cx| { + let frame_id = console.stack_frame_list.read(cx).current_stack_frame_id(); + + state.completions( + CompletionsQuery::new(buffer.read(cx), buffer_position, frame_id), + cx, + ) + }) + }); + + cx.background_executor().spawn(async move { + Ok(Some( + completion_task + .await? + .iter() + .map(|completion| project::Completion { + old_range: buffer_position..buffer_position, // TODO(debugger): change this + new_text: completion.text.clone().unwrap_or(completion.label.clone()), + label: CodeLabel { + filter_range: 0..completion.label.len(), + text: completion.label.clone(), + runs: Vec::new(), + }, + documentation: None, + confirm: None, + source: project::CompletionSource::Custom, + }) + .collect(), + )) + }) + } +} diff --git a/crates/debugger_ui/src/session/running/loaded_source_list.rs b/crates/debugger_ui/src/session/running/loaded_source_list.rs new file mode 100644 index 0000000000..4de0e40e82 --- /dev/null +++ b/crates/debugger_ui/src/session/running/loaded_source_list.rs @@ -0,0 +1,103 @@ +use gpui::{list, AnyElement, Empty, Entity, FocusHandle, Focusable, ListState, Subscription}; +use project::debugger::session::{Session, SessionEvent}; +use ui::prelude::*; +use util::maybe; + +pub struct LoadedSourceList { + list: ListState, + invalidate: bool, + focus_handle: FocusHandle, + _subscription: Subscription, + session: Entity, +} + +impl LoadedSourceList { + pub fn new(session: Entity, cx: &mut Context) -> Self { + let weak_entity = cx.weak_entity(); + let focus_handle = cx.focus_handle(); + + let list = ListState::new( + 0, + gpui::ListAlignment::Top, + px(1000.), + move |ix, _window, cx| { + weak_entity + .upgrade() + .map(|loaded_sources| { + loaded_sources.update(cx, |this, cx| this.render_entry(ix, cx)) + }) + .unwrap_or(div().into_any()) + }, + ); + + let _subscription = cx.subscribe(&session, |this, _, event, cx| match event { + SessionEvent::Stopped(_) | SessionEvent::LoadedSources => { + this.invalidate = true; + cx.notify(); + } + _ => {} + }); + + Self { + list, + session, + focus_handle, + _subscription, + invalidate: true, + } + } + + fn render_entry(&mut self, ix: usize, cx: &mut Context) -> AnyElement { + let Some(source) = maybe!({ + self.session + .update(cx, |state, cx| state.loaded_sources(cx).get(ix).cloned()) + }) else { + return Empty.into_any(); + }; + + v_flex() + .rounded_md() + .w_full() + .group("") + .p_1() + .hover(|s| s.bg(cx.theme().colors().element_hover)) + .child( + h_flex() + .gap_0p5() + .text_ui_sm(cx) + .when_some(source.name.clone(), |this, name| this.child(name)), + ) + .child( + h_flex() + .text_ui_xs(cx) + .text_color(cx.theme().colors().text_muted) + .when_some(source.path.clone(), |this, path| this.child(path)), + ) + .into_any() + } +} + +impl Focusable for LoadedSourceList { + fn focus_handle(&self, _: &gpui::App) -> gpui::FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for LoadedSourceList { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + if self.invalidate { + let len = self + .session + .update(cx, |session, cx| session.loaded_sources(cx).len()); + self.list.reset(len); + self.invalidate = false; + cx.notify(); + } + + div() + .track_focus(&self.focus_handle) + .size_full() + .p_1() + .child(list(self.list.clone()).size_full()) + } +} diff --git a/crates/debugger_ui/src/session/running/module_list.rs b/crates/debugger_ui/src/session/running/module_list.rs new file mode 100644 index 0000000000..e0e8f717a2 --- /dev/null +++ b/crates/debugger_ui/src/session/running/module_list.rs @@ -0,0 +1,183 @@ +use anyhow::anyhow; +use gpui::{ + list, AnyElement, Empty, Entity, FocusHandle, Focusable, ListState, Subscription, WeakEntity, +}; +use project::{ + debugger::session::{Session, SessionEvent}, + ProjectItem as _, ProjectPath, +}; +use std::{path::Path, sync::Arc}; +use ui::prelude::*; +use util::maybe; +use workspace::Workspace; + +pub struct ModuleList { + list: ListState, + invalidate: bool, + session: Entity, + workspace: WeakEntity, + focus_handle: FocusHandle, + _subscription: Subscription, +} + +impl ModuleList { + pub fn new( + session: Entity, + workspace: WeakEntity, + cx: &mut Context, + ) -> Self { + let weak_entity = cx.weak_entity(); + let focus_handle = cx.focus_handle(); + + let list = ListState::new( + 0, + gpui::ListAlignment::Top, + px(1000.), + move |ix, _window, cx| { + weak_entity + .upgrade() + .map(|module_list| module_list.update(cx, |this, cx| this.render_entry(ix, cx))) + .unwrap_or(div().into_any()) + }, + ); + + let _subscription = cx.subscribe(&session, |this, _, event, cx| match event { + SessionEvent::Stopped(_) | SessionEvent::Modules => { + this.invalidate = true; + cx.notify(); + } + _ => {} + }); + + Self { + list, + session, + workspace, + focus_handle, + _subscription, + invalidate: true, + } + } + + fn open_module(&mut self, path: Arc, window: &mut Window, cx: &mut Context) { + cx.spawn_in(window, move |this, mut cx| async move { + let (worktree, relative_path) = this + .update(&mut cx, |this, cx| { + this.workspace.update(cx, |workspace, cx| { + workspace.project().update(cx, |this, cx| { + this.find_or_create_worktree(&path, false, cx) + }) + }) + })?? + .await?; + + let buffer = this + .update(&mut cx, |this, cx| { + this.workspace.update(cx, |this, cx| { + this.project().update(cx, |this, cx| { + let worktree_id = worktree.read(cx).id(); + this.open_buffer( + ProjectPath { + worktree_id, + path: relative_path.into(), + }, + cx, + ) + }) + }) + })?? + .await?; + + this.update_in(&mut cx, |this, window, cx| { + this.workspace.update(cx, |workspace, cx| { + let project_path = buffer.read(cx).project_path(cx).ok_or_else(|| { + anyhow!("Could not select a stack frame for unnamed buffer") + })?; + anyhow::Ok(workspace.open_path_preview( + project_path, + None, + false, + true, + true, + window, + cx, + )) + }) + })??? + .await?; + + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + + fn render_entry(&mut self, ix: usize, cx: &mut Context) -> AnyElement { + let Some(module) = maybe!({ + self.session + .update(cx, |state, cx| state.modules(cx).get(ix).cloned()) + }) else { + return Empty.into_any(); + }; + + v_flex() + .rounded_md() + .w_full() + .group("") + .id(("module-list", ix)) + .when(module.path.is_some(), |this| { + this.on_click({ + let path = module.path.as_deref().map(|path| Arc::::from(Path::new(path))); + cx.listener(move |this, _, window, cx| { + if let Some(path) = path.as_ref() { + this.open_module(path.clone(), window, cx); + } else { + log::error!("Wasn't able to find module path, but was still able to click on module list entry"); + } + }) + }) + }) + .p_1() + .hover(|s| s.bg(cx.theme().colors().element_hover)) + .child(h_flex().gap_0p5().text_ui_sm(cx).child(module.name.clone())) + .child( + h_flex() + .text_ui_xs(cx) + .text_color(cx.theme().colors().text_muted) + .when_some(module.path.clone(), |this, path| this.child(path)), + ) + .into_any() + } +} + +#[cfg(any(test, feature = "test-support"))] +impl ModuleList { + pub fn modules(&self, cx: &mut Context) -> Vec { + self.session + .update(cx, |session, cx| session.modules(cx).to_vec()) + } +} + +impl Focusable for ModuleList { + fn focus_handle(&self, _: &gpui::App) -> gpui::FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for ModuleList { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + if self.invalidate { + let len = self + .session + .update(cx, |session, cx| session.modules(cx).len()); + self.list.reset(len); + self.invalidate = false; + cx.notify(); + } + + div() + .track_focus(&self.focus_handle) + .size_full() + .p_1() + .child(list(self.list.clone()).size_full()) + } +} diff --git a/crates/debugger_ui/src/session/running/stack_frame_list.rs b/crates/debugger_ui/src/session/running/stack_frame_list.rs new file mode 100644 index 0000000000..728f3e3de2 --- /dev/null +++ b/crates/debugger_ui/src/session/running/stack_frame_list.rs @@ -0,0 +1,519 @@ +use std::path::Path; +use std::sync::Arc; + +use anyhow::{anyhow, Result}; +use dap::StackFrameId; +use gpui::{ + list, AnyElement, Entity, EventEmitter, FocusHandle, Focusable, ListState, Subscription, Task, + WeakEntity, +}; + +use language::PointUtf16; +use project::debugger::session::{Session, SessionEvent, StackFrame}; +use project::{ProjectItem, ProjectPath}; +use ui::{prelude::*, Tooltip}; +use util::ResultExt; +use workspace::Workspace; + +use super::RunningState; + +#[derive(Debug)] +pub enum StackFrameListEvent { + SelectedStackFrameChanged(StackFrameId), +} + +pub struct StackFrameList { + list: ListState, + focus_handle: FocusHandle, + _subscription: Subscription, + session: Entity, + state: WeakEntity, + invalidate: bool, + entries: Vec, + workspace: WeakEntity, + current_stack_frame_id: Option, +} + +#[allow(clippy::large_enum_variant)] +#[derive(Debug, PartialEq, Eq)] +pub enum StackFrameEntry { + Normal(dap::StackFrame), + Collapsed(Vec), +} + +impl StackFrameList { + pub fn new( + workspace: WeakEntity, + session: Entity, + state: WeakEntity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let weak_entity = cx.weak_entity(); + let focus_handle = cx.focus_handle(); + + let list = ListState::new( + 0, + gpui::ListAlignment::Top, + px(1000.), + move |ix, _window, cx| { + weak_entity + .upgrade() + .map(|stack_frame_list| { + stack_frame_list.update(cx, |this, cx| this.render_entry(ix, cx)) + }) + .unwrap_or(div().into_any()) + }, + ); + + let _subscription = + cx.subscribe_in(&session, window, |this, _, event, _, cx| match event { + SessionEvent::Stopped(_) | SessionEvent::StackTrace | SessionEvent::Threads => { + this.refresh(cx); + } + _ => {} + }); + + Self { + list, + session, + workspace, + focus_handle, + state, + _subscription, + invalidate: true, + entries: Default::default(), + current_stack_frame_id: None, + } + } + + #[cfg(any(test, feature = "test-support"))] + pub fn entries(&self) -> &Vec { + &self.entries + } + + #[cfg(any(test, feature = "test-support"))] + pub fn flatten_entries(&self) -> Vec { + self.entries + .iter() + .flat_map(|frame| match frame { + StackFrameEntry::Normal(frame) => vec![frame.clone()], + StackFrameEntry::Collapsed(frames) => frames.clone(), + }) + .collect::>() + } + + fn stack_frames(&self, cx: &mut App) -> Vec { + self.state + .read_with(cx, |state, _| state.thread_id) + .log_err() + .flatten() + .map(|thread_id| { + self.session + .update(cx, |this, cx| this.stack_frames(thread_id, cx)) + }) + .unwrap_or_default() + } + + #[cfg(any(test, feature = "test-support"))] + pub fn dap_stack_frames(&self, cx: &mut App) -> Vec { + self.stack_frames(cx) + .into_iter() + .map(|stack_frame| stack_frame.dap.clone()) + .collect() + } + + pub fn _get_main_stack_frame_id(&self, cx: &mut Context) -> u64 { + self.stack_frames(cx) + .first() + .map(|stack_frame| stack_frame.dap.id) + .unwrap_or(0) + } + + pub fn current_stack_frame_id(&self) -> Option { + self.current_stack_frame_id + } + + pub(super) fn refresh(&mut self, cx: &mut Context) { + self.invalidate = true; + self.entries.clear(); + cx.notify(); + } + + pub fn build_entries( + &mut self, + select_first_stack_frame: bool, + window: &mut Window, + cx: &mut Context, + ) { + let mut entries = Vec::new(); + let mut collapsed_entries = Vec::new(); + let mut current_stack_frame = None; + + let stack_frames = self.stack_frames(cx); + for stack_frame in &stack_frames { + match stack_frame.dap.presentation_hint { + Some(dap::StackFramePresentationHint::Deemphasize) => { + collapsed_entries.push(stack_frame.dap.clone()); + } + _ => { + let collapsed_entries = std::mem::take(&mut collapsed_entries); + if !collapsed_entries.is_empty() { + entries.push(StackFrameEntry::Collapsed(collapsed_entries.clone())); + } + + current_stack_frame.get_or_insert(&stack_frame.dap); + entries.push(StackFrameEntry::Normal(stack_frame.dap.clone())); + } + } + } + + let collapsed_entries = std::mem::take(&mut collapsed_entries); + if !collapsed_entries.is_empty() { + entries.push(StackFrameEntry::Collapsed(collapsed_entries.clone())); + } + + std::mem::swap(&mut self.entries, &mut entries); + self.list.reset(self.entries.len()); + + if let Some(current_stack_frame) = current_stack_frame.filter(|_| select_first_stack_frame) + { + self.select_stack_frame(current_stack_frame, true, window, cx) + .detach_and_log_err(cx); + } + + cx.notify(); + } + + pub fn go_to_selected_stack_frame(&mut self, window: &Window, cx: &mut Context) { + if let Some(current_stack_frame_id) = self.current_stack_frame_id { + let frame = self + .entries + .iter() + .find_map(|entry| match entry { + StackFrameEntry::Normal(dap) => { + if dap.id == current_stack_frame_id { + Some(dap) + } else { + None + } + } + StackFrameEntry::Collapsed(daps) => { + daps.iter().find(|dap| dap.id == current_stack_frame_id) + } + }) + .cloned(); + + if let Some(frame) = frame.as_ref() { + self.select_stack_frame(frame, true, window, cx) + .detach_and_log_err(cx); + } + } + } + + pub fn select_stack_frame( + &mut self, + stack_frame: &dap::StackFrame, + go_to_stack_frame: bool, + window: &Window, + cx: &mut Context, + ) -> Task> { + self.current_stack_frame_id = Some(stack_frame.id); + + cx.emit(StackFrameListEvent::SelectedStackFrameChanged( + stack_frame.id, + )); + cx.notify(); + + if !go_to_stack_frame { + return Task::ready(Ok(())); + }; + + let row = (stack_frame.line.saturating_sub(1)) as u32; + + let Some(abs_path) = self.abs_path_from_stack_frame(&stack_frame) else { + return Task::ready(Err(anyhow!("Project path not found"))); + }; + + cx.spawn_in(window, move |this, mut cx| async move { + let (worktree, relative_path) = this + .update(&mut cx, |this, cx| { + this.workspace.update(cx, |workspace, cx| { + workspace.project().update(cx, |this, cx| { + this.find_or_create_worktree(&abs_path, false, cx) + }) + }) + })?? + .await?; + let buffer = this + .update(&mut cx, |this, cx| { + this.workspace.update(cx, |this, cx| { + this.project().update(cx, |this, cx| { + let worktree_id = worktree.read(cx).id(); + this.open_buffer( + ProjectPath { + worktree_id, + path: relative_path.into(), + }, + cx, + ) + }) + }) + })?? + .await?; + let position = buffer.update(&mut cx, |this, _| { + this.snapshot().anchor_after(PointUtf16::new(row, 0)) + })?; + this.update_in(&mut cx, |this, window, cx| { + this.workspace.update(cx, |workspace, cx| { + let project_path = buffer.read(cx).project_path(cx).ok_or_else(|| { + anyhow!("Could not select a stack frame for unnamed buffer") + })?; + anyhow::Ok(workspace.open_path_preview( + project_path, + None, + false, + true, + true, + window, + cx, + )) + }) + })??? + .await?; + + this.update(&mut cx, |this, cx| { + this.workspace.update(cx, |workspace, cx| { + let breakpoint_store = workspace.project().read(cx).breakpoint_store(); + + breakpoint_store.update(cx, |store, cx| { + store.set_active_position( + (this.session.read(cx).session_id(), abs_path, position), + cx, + ); + }) + }) + })? + }) + } + + fn abs_path_from_stack_frame(&self, stack_frame: &dap::StackFrame) -> Option> { + stack_frame.source.as_ref().and_then(|s| { + s.path + .as_deref() + .map(|path| Arc::::from(Path::new(path))) + }) + } + + pub fn restart_stack_frame(&mut self, stack_frame_id: u64, cx: &mut Context) { + self.session.update(cx, |state, cx| { + state.restart_stack_frame(stack_frame_id, cx) + }); + } + + fn render_normal_entry( + &self, + stack_frame: &dap::StackFrame, + cx: &mut Context, + ) -> AnyElement { + let source = stack_frame.source.clone(); + let is_selected_frame = Some(stack_frame.id) == self.current_stack_frame_id; + + let formatted_path = format!( + "{}:{}", + source.clone().and_then(|s| s.name).unwrap_or_default(), + stack_frame.line, + ); + + let supports_frame_restart = self + .session + .read(cx) + .capabilities() + .supports_restart_frame + .unwrap_or_default(); + + let origin = stack_frame + .source + .to_owned() + .and_then(|source| source.origin); + + h_flex() + .rounded_md() + .justify_between() + .w_full() + .group("") + .id(("stack-frame", stack_frame.id)) + .tooltip({ + let formatted_path = formatted_path.clone(); + move |_window, app| { + app.new(|_| { + let mut tooltip = Tooltip::new(formatted_path.clone()); + + if let Some(origin) = &origin { + tooltip = tooltip.meta(origin); + } + + tooltip + }) + .into() + } + }) + .p_1() + .when(is_selected_frame, |this| { + this.bg(cx.theme().colors().element_hover) + }) + .on_click(cx.listener({ + let stack_frame = stack_frame.clone(); + move |this, _, window, cx| { + this.select_stack_frame(&stack_frame, true, window, cx) + .detach_and_log_err(cx); + } + })) + .hover(|style| style.bg(cx.theme().colors().element_hover).cursor_pointer()) + .child( + v_flex() + .child( + h_flex() + .gap_0p5() + .text_ui_sm(cx) + .truncate() + .child(stack_frame.name.clone()) + .child(formatted_path), + ) + .child( + h_flex() + .text_ui_xs(cx) + .truncate() + .text_color(cx.theme().colors().text_muted) + .when_some(source.and_then(|s| s.path), |this, path| this.child(path)), + ), + ) + .when( + supports_frame_restart && stack_frame.can_restart.unwrap_or(true), + |this| { + this.child( + h_flex() + .id(("restart-stack-frame", stack_frame.id)) + .visible_on_hover("") + .absolute() + .right_2() + .overflow_hidden() + .rounded_md() + .border_1() + .border_color(cx.theme().colors().element_selected) + .bg(cx.theme().colors().element_background) + .hover(|style| { + style + .bg(cx.theme().colors().ghost_element_hover) + .cursor_pointer() + }) + .child( + IconButton::new( + ("restart-stack-frame", stack_frame.id), + IconName::DebugRestart, + ) + .icon_size(IconSize::Small) + .on_click(cx.listener({ + let stack_frame_id = stack_frame.id; + move |this, _, _window, cx| { + this.restart_stack_frame(stack_frame_id, cx); + } + })) + .tooltip(move |window, cx| { + Tooltip::text("Restart Stack Frame")(window, cx) + }), + ), + ) + }, + ) + .into_any() + } + + pub fn expand_collapsed_entry( + &mut self, + ix: usize, + stack_frames: &Vec, + cx: &mut Context, + ) { + self.entries.splice( + ix..ix + 1, + stack_frames + .iter() + .map(|frame| StackFrameEntry::Normal(frame.clone())), + ); + self.list.reset(self.entries.len()); + cx.notify(); + } + + fn render_collapsed_entry( + &self, + ix: usize, + stack_frames: &Vec, + cx: &mut Context, + ) -> AnyElement { + let first_stack_frame = &stack_frames[0]; + + h_flex() + .rounded_md() + .justify_between() + .w_full() + .group("") + .id(("stack-frame", first_stack_frame.id)) + .p_1() + .on_click(cx.listener({ + let stack_frames = stack_frames.clone(); + move |this, _, _window, cx| { + this.expand_collapsed_entry(ix, &stack_frames, cx); + } + })) + .hover(|style| style.bg(cx.theme().colors().element_hover).cursor_pointer()) + .child( + v_flex() + .text_ui_sm(cx) + .truncate() + .text_color(cx.theme().colors().text_muted) + .child(format!( + "Show {} more{}", + stack_frames.len(), + first_stack_frame + .source + .as_ref() + .and_then(|source| source.origin.as_ref()) + .map_or(String::new(), |origin| format!(": {}", origin)) + )), + ) + .into_any() + } + + fn render_entry(&self, ix: usize, cx: &mut Context) -> AnyElement { + match &self.entries[ix] { + StackFrameEntry::Normal(stack_frame) => self.render_normal_entry(stack_frame, cx), + StackFrameEntry::Collapsed(stack_frames) => { + self.render_collapsed_entry(ix, stack_frames, cx) + } + } + } +} + +impl Render for StackFrameList { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + if self.invalidate { + self.build_entries(self.entries.is_empty(), window, cx); + self.invalidate = false; + cx.notify(); + } + + div() + .size_full() + .p_1() + .child(list(self.list.clone()).size_full()) + } +} + +impl Focusable for StackFrameList { + fn focus_handle(&self, _: &gpui::App) -> gpui::FocusHandle { + self.focus_handle.clone() + } +} + +impl EventEmitter for StackFrameList {} diff --git a/crates/debugger_ui/src/session/running/variable_list.rs b/crates/debugger_ui/src/session/running/variable_list.rs new file mode 100644 index 0000000000..b931073bfd --- /dev/null +++ b/crates/debugger_ui/src/session/running/variable_list.rs @@ -0,0 +1,946 @@ +use super::stack_frame_list::{StackFrameList, StackFrameListEvent}; +use dap::{ScopePresentationHint, StackFrameId, VariablePresentationHintKind, VariableReference}; +use editor::Editor; +use gpui::{ + actions, anchored, deferred, uniform_list, AnyElement, ClickEvent, ClipboardItem, Context, + DismissEvent, Entity, FocusHandle, Focusable, Hsla, MouseButton, MouseDownEvent, Point, + Stateful, Subscription, TextStyleRefinement, UniformListScrollHandle, +}; +use menu::{SelectFirst, SelectLast, SelectNext, SelectPrevious}; +use project::debugger::session::{Session, SessionEvent}; +use std::{collections::HashMap, ops::Range, sync::Arc}; +use ui::{prelude::*, ContextMenu, ListItem, Scrollbar, ScrollbarState}; +use util::{debug_panic, maybe}; + +actions!(variable_list, [ExpandSelectedEntry, CollapseSelectedEntry]); + +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub(crate) struct EntryState { + depth: usize, + is_expanded: bool, + parent_reference: VariableReference, +} + +#[derive(Debug, PartialEq, Eq, Hash, Clone)] +pub(crate) struct EntryPath { + pub leaf_name: Option, + pub indices: Arc<[SharedString]>, +} + +impl EntryPath { + fn for_scope(scope_name: impl Into) -> Self { + Self { + leaf_name: Some(scope_name.into()), + indices: Arc::new([]), + } + } + + fn with_name(&self, name: SharedString) -> Self { + Self { + leaf_name: Some(name), + indices: self.indices.clone(), + } + } + + /// Create a new child of this variable path + fn with_child(&self, name: SharedString) -> Self { + Self { + leaf_name: None, + indices: self + .indices + .iter() + .cloned() + .chain(std::iter::once(name)) + .collect(), + } + } +} + +#[derive(Debug, Clone, PartialEq)] +enum EntryKind { + Variable(dap::Variable), + Scope(dap::Scope), +} + +impl EntryKind { + fn as_variable(&self) -> Option<&dap::Variable> { + match self { + EntryKind::Variable(dap) => Some(dap), + _ => None, + } + } + + fn as_scope(&self) -> Option<&dap::Scope> { + match self { + EntryKind::Scope(dap) => Some(dap), + _ => None, + } + } + + #[allow(dead_code)] + fn name(&self) -> &str { + match self { + EntryKind::Variable(dap) => &dap.name, + EntryKind::Scope(dap) => &dap.name, + } + } +} + +#[derive(Debug, Clone, PartialEq)] +struct ListEntry { + dap_kind: EntryKind, + path: EntryPath, +} + +impl ListEntry { + fn as_variable(&self) -> Option<&dap::Variable> { + self.dap_kind.as_variable() + } + + fn as_scope(&self) -> Option<&dap::Scope> { + self.dap_kind.as_scope() + } + + fn item_id(&self) -> ElementId { + use std::fmt::Write; + let mut id = match &self.dap_kind { + EntryKind::Variable(dap) => format!("variable-{}", dap.name), + EntryKind::Scope(dap) => format!("scope-{}", dap.name), + }; + for name in self.path.indices.iter() { + _ = write!(id, "-{}", name); + } + SharedString::from(id).into() + } + + fn item_value_id(&self) -> ElementId { + use std::fmt::Write; + let mut id = match &self.dap_kind { + EntryKind::Variable(dap) => format!("variable-{}", dap.name), + EntryKind::Scope(dap) => format!("scope-{}", dap.name), + }; + for name in self.path.indices.iter() { + _ = write!(id, "-{}", name); + } + _ = write!(id, "-value"); + SharedString::from(id).into() + } +} + +pub struct VariableList { + entries: Vec, + entry_states: HashMap, + selected_stack_frame_id: Option, + list_handle: UniformListScrollHandle, + scrollbar_state: ScrollbarState, + session: Entity, + selection: Option, + open_context_menu: Option<(Entity, Point, Subscription)>, + focus_handle: FocusHandle, + edited_path: Option<(EntryPath, Entity)>, + disabled: bool, + _subscriptions: Vec, +} + +impl VariableList { + pub fn new( + session: Entity, + stack_frame_list: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let focus_handle = cx.focus_handle(); + + let _subscriptions = vec![ + cx.subscribe(&stack_frame_list, Self::handle_stack_frame_list_events), + cx.subscribe(&session, |this, _, event, _| match event { + SessionEvent::Stopped(_) => { + this.selection.take(); + this.edited_path.take(); + this.selected_stack_frame_id.take(); + } + _ => {} + }), + cx.on_focus_out(&focus_handle, window, |this, _, _, cx| { + this.edited_path.take(); + cx.notify(); + }), + ]; + + let list_state = UniformListScrollHandle::default(); + + Self { + scrollbar_state: ScrollbarState::new(list_state.clone()), + list_handle: list_state, + session, + focus_handle, + _subscriptions, + selected_stack_frame_id: None, + selection: None, + open_context_menu: None, + disabled: false, + edited_path: None, + entries: Default::default(), + entry_states: Default::default(), + } + } + + pub(super) fn disabled(&mut self, disabled: bool, cx: &mut Context) { + let old_disabled = std::mem::take(&mut self.disabled); + self.disabled = disabled; + if old_disabled != disabled { + cx.notify(); + } + } + + fn build_entries(&mut self, cx: &mut Context) { + let Some(stack_frame_id) = self.selected_stack_frame_id else { + return; + }; + + let mut entries = vec![]; + let scopes: Vec<_> = self.session.update(cx, |session, cx| { + session.scopes(stack_frame_id, cx).iter().cloned().collect() + }); + + let mut contains_local_scope = false; + + let mut stack = scopes + .into_iter() + .rev() + .filter(|scope| { + if scope + .presentation_hint + .as_ref() + .map(|hint| *hint == ScopePresentationHint::Locals) + .unwrap_or(scope.name.to_lowercase().starts_with("local")) + { + contains_local_scope = true; + } + + self.session.update(cx, |session, cx| { + session.variables(scope.variables_reference, cx).len() > 0 + }) + }) + .map(|scope| { + ( + scope.variables_reference, + scope.variables_reference, + EntryPath::for_scope(&scope.name), + EntryKind::Scope(scope), + ) + }) + .collect::>(); + + let scopes_count = stack.len(); + + while let Some((container_reference, variables_reference, mut path, dap_kind)) = stack.pop() + { + match &dap_kind { + EntryKind::Variable(dap) => path = path.with_name(dap.name.clone().into()), + EntryKind::Scope(dap) => path = path.with_child(dap.name.clone().into()), + } + + let var_state = self + .entry_states + .entry(path.clone()) + .and_modify(|state| { + state.parent_reference = container_reference; + }) + .or_insert(EntryState { + depth: path.indices.len(), + is_expanded: dap_kind.as_scope().is_some_and(|scope| { + (scopes_count == 1 && !contains_local_scope) + || scope + .presentation_hint + .as_ref() + .map(|hint| *hint == ScopePresentationHint::Locals) + .unwrap_or(scope.name.to_lowercase().starts_with("local")) + }), + parent_reference: container_reference, + }); + + entries.push(ListEntry { + dap_kind, + path: path.clone(), + }); + + if var_state.is_expanded { + let children = self + .session + .update(cx, |session, cx| session.variables(variables_reference, cx)); + stack.extend(children.into_iter().rev().map(|child| { + ( + variables_reference, + child.variables_reference, + path.with_child(child.name.clone().into()), + EntryKind::Variable(child), + ) + })); + } + } + + self.entries = entries; + cx.notify(); + } + + fn handle_stack_frame_list_events( + &mut self, + _: Entity, + event: &StackFrameListEvent, + cx: &mut Context, + ) { + match event { + StackFrameListEvent::SelectedStackFrameChanged(stack_frame_id) => { + self.selected_stack_frame_id = Some(*stack_frame_id); + cx.notify(); + } + } + } + + pub fn completion_variables(&self, _cx: &mut Context) -> Vec { + self.entries + .iter() + .filter_map(|entry| match &entry.dap_kind { + EntryKind::Variable(dap) => Some(dap.clone()), + EntryKind::Scope(_) => None, + }) + .collect() + } + + fn render_entries( + &mut self, + ix: Range, + window: &mut Window, + cx: &mut Context, + ) -> Vec { + ix.into_iter() + .filter_map(|ix| { + let (entry, state) = self + .entries + .get(ix) + .and_then(|entry| Some(entry).zip(self.entry_states.get(&entry.path)))?; + + match &entry.dap_kind { + EntryKind::Variable(_) => Some(self.render_variable(entry, *state, window, cx)), + EntryKind::Scope(_) => Some(self.render_scope(entry, *state, cx)), + } + }) + .collect() + } + + pub(crate) fn toggle_entry(&mut self, var_path: &EntryPath, cx: &mut Context) { + let Some(entry) = self.entry_states.get_mut(var_path) else { + log::error!("Could not find variable list entry state to toggle"); + return; + }; + + entry.is_expanded = !entry.is_expanded; + cx.notify(); + } + + fn select_first(&mut self, _: &SelectFirst, window: &mut Window, cx: &mut Context) { + self.cancel_variable_edit(&Default::default(), window, cx); + if let Some(variable) = self.entries.first() { + self.selection = Some(variable.path.clone()); + cx.notify(); + } + } + + fn select_last(&mut self, _: &SelectLast, window: &mut Window, cx: &mut Context) { + self.cancel_variable_edit(&Default::default(), window, cx); + if let Some(variable) = self.entries.last() { + self.selection = Some(variable.path.clone()); + cx.notify(); + } + } + + fn select_prev(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context) { + self.cancel_variable_edit(&Default::default(), window, cx); + if let Some(selection) = &self.selection { + if let Some(var_ix) = self.entries.iter().enumerate().find_map(|(ix, var)| { + if &var.path == selection { + Some(ix.saturating_sub(1)) + } else { + None + } + }) { + if let Some(new_selection) = self.entries.get(var_ix).map(|var| var.path.clone()) { + self.selection = Some(new_selection); + cx.notify(); + } else { + self.select_first(&SelectFirst, window, cx); + } + } + } else { + self.select_first(&SelectFirst, window, cx); + } + } + + fn select_next(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context) { + self.cancel_variable_edit(&Default::default(), window, cx); + if let Some(selection) = &self.selection { + if let Some(var_ix) = self.entries.iter().enumerate().find_map(|(ix, var)| { + if &var.path == selection { + Some(ix.saturating_add(1)) + } else { + None + } + }) { + if let Some(new_selection) = self.entries.get(var_ix).map(|var| var.path.clone()) { + self.selection = Some(new_selection); + cx.notify(); + } else { + self.select_first(&SelectFirst, window, cx); + } + } + } else { + self.select_first(&SelectFirst, window, cx); + } + } + + fn cancel_variable_edit( + &mut self, + _: &menu::Cancel, + window: &mut Window, + cx: &mut Context, + ) { + self.edited_path.take(); + self.focus_handle.focus(window); + cx.notify(); + } + + fn confirm_variable_edit( + &mut self, + _: &menu::Confirm, + _window: &mut Window, + cx: &mut Context, + ) { + let res = maybe!({ + let (var_path, editor) = self.edited_path.take()?; + let state = self.entry_states.get(&var_path)?; + let variables_reference = state.parent_reference; + let name = var_path.leaf_name?; + let value = editor.read(cx).text(cx); + + self.session.update(cx, |session, cx| { + session.set_variable_value(variables_reference, name.into(), value, cx) + }); + Some(()) + }); + + if res.is_none() { + log::error!("Couldn't confirm variable edit because variable doesn't have a leaf name or a parent reference id"); + } + } + + fn collapse_selected_entry( + &mut self, + _: &CollapseSelectedEntry, + _window: &mut Window, + cx: &mut Context, + ) { + if let Some(ref selected_entry) = self.selection { + let Some(entry_state) = self.entry_states.get_mut(selected_entry) else { + debug_panic!("Trying to toggle variable in variable list that has an no state"); + return; + }; + + entry_state.is_expanded = false; + cx.notify(); + } + } + + fn expand_selected_entry( + &mut self, + _: &ExpandSelectedEntry, + _window: &mut Window, + cx: &mut Context, + ) { + if let Some(ref selected_entry) = self.selection { + let Some(entry_state) = self.entry_states.get_mut(selected_entry) else { + debug_panic!("Trying to toggle variable in variable list that has an no state"); + return; + }; + + entry_state.is_expanded = true; + cx.notify(); + } + } + + fn deploy_variable_context_menu( + &mut self, + variable: ListEntry, + position: Point, + window: &mut Window, + cx: &mut Context, + ) { + let Some(dap_var) = variable.as_variable() else { + debug_panic!("Trying to open variable context menu on a scope"); + return; + }; + + let variable_value = dap_var.value.clone(); + let variable_name = dap_var.name.clone(); + let this = cx.entity().clone(); + + let context_menu = ContextMenu::build(window, cx, |menu, _, _| { + menu.entry("Copy name", None, move |_, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(variable_name.clone())) + }) + .entry("Copy value", None, { + let variable_value = variable_value.clone(); + move |_, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(variable_value.clone())) + } + }) + .entry("Set value", None, move |window, cx| { + this.update(cx, |variable_list, cx| { + let editor = Self::create_variable_editor(&variable_value, window, cx); + variable_list.edited_path = Some((variable.path.clone(), editor)); + + cx.notify(); + }); + }) + }); + + cx.focus_view(&context_menu, window); + let subscription = cx.subscribe_in( + &context_menu, + window, + |this, _, _: &DismissEvent, window, cx| { + if this.open_context_menu.as_ref().is_some_and(|context_menu| { + context_menu.0.focus_handle(cx).contains_focused(window, cx) + }) { + cx.focus_self(window); + } + this.open_context_menu.take(); + cx.notify(); + }, + ); + + self.open_context_menu = Some((context_menu, position, subscription)); + } + + #[track_caller] + #[cfg(any(test, feature = "test-support"))] + pub fn assert_visual_entries(&self, expected: Vec<&str>) { + const INDENT: &'static str = " "; + + let entries = &self.entries; + let mut visual_entries = Vec::with_capacity(entries.len()); + for entry in entries { + let state = self + .entry_states + .get(&entry.path) + .expect("If there's a variable entry there has to be a state that goes with it"); + + visual_entries.push(format!( + "{}{} {}{}", + INDENT.repeat(state.depth - 1), + if state.is_expanded { "v" } else { ">" }, + entry.dap_kind.name(), + if self.selection.as_ref() == Some(&entry.path) { + " <=== selected" + } else { + "" + } + )); + } + + pretty_assertions::assert_eq!(expected, visual_entries); + } + + #[track_caller] + #[cfg(any(test, feature = "test-support"))] + pub fn scopes(&self) -> Vec { + self.entries + .iter() + .filter_map(|entry| match &entry.dap_kind { + EntryKind::Scope(scope) => Some(scope), + _ => None, + }) + .cloned() + .collect() + } + + #[track_caller] + #[cfg(any(test, feature = "test-support"))] + pub fn variables_per_scope(&self) -> Vec<(dap::Scope, Vec)> { + let mut scopes: Vec<(dap::Scope, Vec<_>)> = Vec::new(); + let mut idx = 0; + + for entry in self.entries.iter() { + match &entry.dap_kind { + EntryKind::Variable(dap) => scopes[idx].1.push(dap.clone()), + EntryKind::Scope(scope) => { + if scopes.len() > 0 { + idx += 1; + } + + scopes.push((scope.clone(), Vec::new())); + } + } + } + + scopes + } + + #[track_caller] + #[cfg(any(test, feature = "test-support"))] + pub fn variables(&self) -> Vec { + self.entries + .iter() + .filter_map(|entry| match &entry.dap_kind { + EntryKind::Variable(variable) => Some(variable), + _ => None, + }) + .cloned() + .collect() + } + + fn create_variable_editor(default: &str, window: &mut Window, cx: &mut App) -> Entity { + let editor = cx.new(|cx| { + let mut editor = Editor::single_line(window, cx); + + let refinement = TextStyleRefinement { + font_size: Some( + TextSize::XSmall + .rems(cx) + .to_pixels(window.rem_size()) + .into(), + ), + ..Default::default() + }; + editor.set_text_style_refinement(refinement); + editor.set_text(default, window, cx); + editor.select_all(&editor::actions::SelectAll, window, cx); + editor + }); + editor.focus_handle(cx).focus(window); + editor + } + + fn render_scope( + &self, + entry: &ListEntry, + state: EntryState, + cx: &mut Context, + ) -> AnyElement { + let Some(scope) = entry.as_scope() else { + debug_panic!("Called render scope on non scope variable list entry variant"); + return div().into_any_element(); + }; + + let var_ref = scope.variables_reference; + let is_selected = self + .selection + .as_ref() + .is_some_and(|selection| selection == &entry.path); + + let colors = get_entry_color(cx); + let bg_hover_color = if !is_selected { + colors.hover + } else { + colors.default + }; + let border_color = if is_selected { + colors.marked_active + } else { + colors.default + }; + + div() + .id(var_ref as usize) + .group("variable_list_entry") + .border_1() + .border_r_2() + .border_color(border_color) + .flex() + .w_full() + .h_full() + .hover(|style| style.bg(bg_hover_color)) + .on_click(cx.listener({ + move |_this, _, _window, cx| { + cx.notify(); + } + })) + .child( + ListItem::new(SharedString::from(format!("scope-{}", var_ref))) + .selectable(false) + .indent_level(state.depth + 1) + .indent_step_size(px(20.)) + .always_show_disclosure_icon(true) + .toggle(state.is_expanded) + .on_toggle({ + let var_path = entry.path.clone(); + cx.listener(move |this, _, _, cx| this.toggle_entry(&var_path, cx)) + }) + .child(div().text_ui(cx).w_full().child(scope.name.clone())), + ) + .into_any() + } + + fn render_variable( + &self, + variable: &ListEntry, + state: EntryState, + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let dap = match &variable.dap_kind { + EntryKind::Variable(dap) => dap, + EntryKind::Scope(_) => { + debug_panic!("Called render variable on variable list entry kind scope"); + return div().into_any_element(); + } + }; + + let syntax_color_for = |name| cx.theme().syntax().get(name).color; + let variable_name_color = match &dap + .presentation_hint + .as_ref() + .and_then(|hint| hint.kind.as_ref()) + .unwrap_or(&VariablePresentationHintKind::Unknown) + { + VariablePresentationHintKind::Class + | VariablePresentationHintKind::BaseClass + | VariablePresentationHintKind::InnerClass + | VariablePresentationHintKind::MostDerivedClass => syntax_color_for("type"), + VariablePresentationHintKind::Data => syntax_color_for("variable"), + VariablePresentationHintKind::Unknown | _ => syntax_color_for("variable"), + }; + let variable_color = syntax_color_for("variable.special"); + + let var_ref = dap.variables_reference; + let colors = get_entry_color(cx); + let is_selected = self + .selection + .as_ref() + .is_some_and(|selected_path| *selected_path == variable.path); + + let bg_hover_color = if !is_selected { + colors.hover + } else { + colors.default + }; + let border_color = if is_selected && self.focus_handle.contains_focused(window, cx) { + colors.marked_active + } else { + colors.default + }; + let path = variable.path.clone(); + div() + .id(variable.item_id()) + .group("variable_list_entry") + .border_1() + .border_r_2() + .border_color(border_color) + .h_4() + .size_full() + .hover(|style| style.bg(bg_hover_color)) + .on_click(cx.listener({ + move |this, _, _window, cx| { + this.selection = Some(path.clone()); + cx.notify(); + } + })) + .child( + ListItem::new(SharedString::from(format!( + "variable-item-{}-{}", + dap.name, state.depth + ))) + .disabled(self.disabled) + .selectable(false) + .indent_level(state.depth + 1_usize) + .indent_step_size(px(20.)) + .always_show_disclosure_icon(true) + .when(var_ref > 0, |list_item| { + list_item.toggle(state.is_expanded).on_toggle(cx.listener({ + let var_path = variable.path.clone(); + move |this, _, _, cx| { + this.session.update(cx, |session, cx| { + session.variables(var_ref, cx); + }); + + this.toggle_entry(&var_path, cx); + } + })) + }) + .on_secondary_mouse_down(cx.listener({ + let variable = variable.clone(); + move |this, event: &MouseDownEvent, window, cx| { + this.deploy_variable_context_menu( + variable.clone(), + event.position, + window, + cx, + ) + } + })) + .child( + h_flex() + .gap_1() + .text_ui_sm(cx) + .w_full() + .child( + Label::new(&dap.name).when_some(variable_name_color, |this, color| { + this.color(Color::from(color)) + }), + ) + .when(!dap.value.is_empty(), |this| { + this.child(div().w_full().id(variable.item_value_id()).map(|this| { + if let Some((_, editor)) = self + .edited_path + .as_ref() + .filter(|(path, _)| path == &variable.path) + { + this.child(div().size_full().px_2().child(editor.clone())) + } else { + this.text_color(cx.theme().colors().text_muted) + .when( + !self.disabled + && self + .session + .read(cx) + .capabilities() + .supports_set_variable + .unwrap_or_default(), + |this| { + let path = variable.path.clone(); + let variable_value = dap.value.clone(); + this.on_click(cx.listener( + move |this, click: &ClickEvent, window, cx| { + if click.down.click_count < 2 { + return; + } + let editor = Self::create_variable_editor( + &variable_value, + window, + cx, + ); + this.edited_path = + Some((path.clone(), editor)); + + cx.notify(); + }, + )) + }, + ) + .child( + Label::new(format!("= {}", &dap.value)) + .single_line() + .truncate() + .size(LabelSize::Small) + .when_some(variable_color, |this, color| { + this.color(Color::from(color)) + }), + ) + } + })) + }), + ), + ) + .into_any() + } + + fn render_vertical_scrollbar(&self, cx: &mut Context) -> Stateful
{ + div() + .occlude() + .id("variable-list-vertical-scrollbar") + .on_mouse_move(cx.listener(|_, _, _, cx| { + cx.notify(); + cx.stop_propagation() + })) + .on_hover(|_, _, cx| { + cx.stop_propagation(); + }) + .on_any_mouse_down(|_, _, cx| { + cx.stop_propagation(); + }) + .on_mouse_up( + MouseButton::Left, + cx.listener(|_, _, _, cx| { + cx.stop_propagation(); + }), + ) + .on_scroll_wheel(cx.listener(|_, _, _, cx| { + cx.notify(); + })) + .h_full() + .absolute() + .right_1() + .top_1() + .bottom_0() + .w(px(12.)) + .cursor_default() + .children(Scrollbar::vertical(self.scrollbar_state.clone())) + } +} + +impl Focusable for VariableList { + fn focus_handle(&self, _: &App) -> gpui::FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for VariableList { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + self.build_entries(cx); + + v_flex() + .key_context("VariableList") + .id("variable-list") + .group("variable-list") + .overflow_y_scroll() + .size_full() + .track_focus(&self.focus_handle(cx)) + .on_action(cx.listener(Self::select_first)) + .on_action(cx.listener(Self::select_last)) + .on_action(cx.listener(Self::select_prev)) + .on_action(cx.listener(Self::select_next)) + .on_action(cx.listener(Self::expand_selected_entry)) + .on_action(cx.listener(Self::collapse_selected_entry)) + .on_action(cx.listener(Self::cancel_variable_edit)) + .on_action(cx.listener(Self::confirm_variable_edit)) + // + .child( + uniform_list( + cx.entity().clone(), + "variable-list", + self.entries.len(), + move |this, range, window, cx| this.render_entries(range, window, cx), + ) + .track_scroll(self.list_handle.clone()) + .gap_1_5() + .size_full() + .flex_grow(), + ) + .children(self.open_context_menu.as_ref().map(|(menu, position, _)| { + deferred( + anchored() + .position(*position) + .anchor(gpui::Corner::TopLeft) + .child(menu.clone()), + ) + .with_priority(1) + })) + .child(self.render_vertical_scrollbar(cx)) + } +} + +struct EntryColors { + default: Hsla, + hover: Hsla, + marked_active: Hsla, +} + +fn get_entry_color(cx: &Context) -> EntryColors { + let colors = cx.theme().colors(); + + EntryColors { + default: colors.panel_background, + hover: colors.ghost_element_hover, + marked_active: colors.ghost_element_selected, + } +} diff --git a/crates/debugger_ui/src/session/starting.rs b/crates/debugger_ui/src/session/starting.rs new file mode 100644 index 0000000000..652e3dd7b2 --- /dev/null +++ b/crates/debugger_ui/src/session/starting.rs @@ -0,0 +1,80 @@ +use std::time::Duration; + +use anyhow::Result; + +use dap::client::SessionId; +use gpui::{ + percentage, Animation, AnimationExt, Entity, EventEmitter, FocusHandle, Focusable, Task, + Transformation, +}; +use project::debugger::session::Session; +use ui::{v_flex, Color, Context, Icon, IconName, IntoElement, ParentElement, Render, Styled}; + +pub(crate) struct StartingState { + focus_handle: FocusHandle, + pub(super) session_id: SessionId, + _notify_parent: Task<()>, +} + +pub(crate) enum StartingEvent { + Failed, + Finished(Entity), +} + +impl EventEmitter for StartingState {} + +impl StartingState { + pub(crate) fn new( + session_id: SessionId, + task: Task>>, + cx: &mut Context, + ) -> Self { + let _notify_parent = cx.spawn(move |this, mut cx| async move { + let entity = task.await; + + this.update(&mut cx, |_, cx| { + if let Ok(entity) = entity { + cx.emit(StartingEvent::Finished(entity)) + } else { + cx.emit(StartingEvent::Failed) + } + }) + .ok(); + }); + Self { + session_id, + focus_handle: cx.focus_handle(), + _notify_parent, + } + } +} + +impl Focusable for StartingState { + fn focus_handle(&self, _: &ui::App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for StartingState { + fn render( + &mut self, + _window: &mut ui::Window, + _cx: &mut ui::Context<'_, Self>, + ) -> impl ui::IntoElement { + v_flex() + .size_full() + .gap_1() + .items_center() + .child("Starting a debug adapter") + .child( + Icon::new(IconName::ArrowCircle) + .color(Color::Info) + .with_animation( + "arrow-circle", + Animation::new(Duration::from_secs(2)).repeat(), + |icon, delta| icon.transform(Transformation::rotate(percentage(delta))), + ) + .into_any_element(), + ) + } +} diff --git a/crates/debugger_ui/src/tests.rs b/crates/debugger_ui/src/tests.rs new file mode 100644 index 0000000000..2f1be7cd2e --- /dev/null +++ b/crates/debugger_ui/src/tests.rs @@ -0,0 +1,75 @@ +use gpui::{Entity, TestAppContext, WindowHandle}; +use project::Project; +use settings::SettingsStore; +use terminal_view::terminal_panel::TerminalPanel; +use workspace::Workspace; + +use crate::{debugger_panel::DebugPanel, session::DebugSession}; + +mod attach_modal; +mod console; +mod debugger_panel; +mod module_list; +mod stack_frame_list; +mod variable_list; + +pub fn init_test(cx: &mut gpui::TestAppContext) { + if std::env::var("RUST_LOG").is_ok() { + env_logger::try_init().ok(); + } + + cx.update(|cx| { + let settings = SettingsStore::test(cx); + cx.set_global(settings); + terminal_view::init(cx); + theme::init(theme::LoadThemes::JustBase, cx); + command_palette_hooks::init(cx); + language::init(cx); + workspace::init_settings(cx); + Project::init_settings(cx); + editor::init(cx); + crate::init(cx); + }); +} + +pub async fn init_test_workspace( + project: &Entity, + cx: &mut TestAppContext, +) -> WindowHandle { + let workspace_handle = + cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + let debugger_panel = workspace_handle + .update(cx, |_, window, cx| cx.spawn_in(window, DebugPanel::load)) + .unwrap() + .await + .expect("Failed to load debug panel"); + + let terminal_panel = workspace_handle + .update(cx, |_, window, cx| cx.spawn_in(window, TerminalPanel::load)) + .unwrap() + .await + .expect("Failed to load terminal panel"); + + workspace_handle + .update(cx, |workspace, window, cx| { + workspace.add_panel(debugger_panel, window, cx); + workspace.add_panel(terminal_panel, window, cx); + }) + .unwrap(); + workspace_handle +} + +pub fn active_debug_session_panel( + workspace: WindowHandle, + cx: &mut TestAppContext, +) -> Entity { + workspace + .update(cx, |workspace, _window, cx| { + let debug_panel = workspace.panel::(cx).unwrap(); + debug_panel + .update(cx, |this, cx| this.active_session(cx)) + .unwrap() + }) + .unwrap() +} diff --git a/crates/debugger_ui/src/tests/attach_modal.rs b/crates/debugger_ui/src/tests/attach_modal.rs new file mode 100644 index 0000000000..43812c7333 --- /dev/null +++ b/crates/debugger_ui/src/tests/attach_modal.rs @@ -0,0 +1,136 @@ +use crate::*; +use attach_modal::AttachModal; +use dap::client::SessionId; +use gpui::{BackgroundExecutor, TestAppContext, VisualTestContext}; +use menu::Confirm; +use project::{FakeFs, Project}; +use serde_json::json; +use task::AttachConfig; +use tests::{init_test, init_test_workspace}; + +#[gpui::test] +async fn test_direct_attach_to_process(executor: BackgroundExecutor, cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(executor.clone()); + + fs.insert_tree( + "/project", + json!({ + "main.rs": "First line\nSecond line\nThird line\nFourth line", + }), + ) + .await; + + let project = Project::test(fs, ["/project".as_ref()], cx).await; + let workspace = init_test_workspace(&project, cx).await; + let cx = &mut VisualTestContext::from_window(*workspace, cx); + + let task = project.update(cx, |project, cx| { + project.start_debug_session( + dap::test_config( + dap::DebugRequestType::Attach(AttachConfig { + process_id: Some(10), + }), + None, + None, + ), + cx, + ) + }); + + let session = task.await.unwrap(); + + cx.run_until_parked(); + + // assert we didn't show the attach modal + workspace + .update(cx, |workspace, _window, cx| { + assert!(workspace.active_modal::(cx).is_none()); + }) + .unwrap(); + + 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_show_attach_modal_and_select_process( + executor: BackgroundExecutor, + cx: &mut TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(executor.clone()); + + fs.insert_tree( + "/project", + json!({ + "main.rs": "First line\nSecond line\nThird line\nFourth line", + }), + ) + .await; + + let project = Project::test(fs, ["/project".as_ref()], cx).await; + let workspace = init_test_workspace(&project, cx).await; + let cx = &mut VisualTestContext::from_window(*workspace, cx); + + let attach_modal = workspace + .update(cx, |workspace, window, cx| { + workspace.toggle_modal(window, cx, |window, cx| { + AttachModal::new( + project.clone(), + dap::test_config( + dap::DebugRequestType::Attach(AttachConfig { process_id: None }), + None, + None, + ), + window, + cx, + ) + }); + + workspace.active_modal::(cx).unwrap() + }) + .unwrap(); + + cx.run_until_parked(); + + // assert we got the expected processes + workspace + .update(cx, |_, _, cx| { + let names = + attach_modal.update(cx, |modal, cx| attach_modal::process_names(&modal, cx)); + + // we filtered out all processes that are not the current process(zed itself) + assert_eq!(1, names.len()); + }) + .unwrap(); + + // select the only existing process + cx.dispatch_action(Confirm); + + cx.run_until_parked(); + + // assert attach modal was dismissed + workspace + .update(cx, |workspace, _window, cx| { + assert!(workspace.active_modal::(cx).is_none()); + }) + .unwrap(); + + let shutdown_session = project.update(cx, |project, cx| { + project.dap_store().update(cx, |dap_store, cx| { + let session = dap_store.session_by_id(SessionId(0)).unwrap(); + + dap_store.shutdown_session(session.read(cx).session_id(), cx) + }) + }); + + shutdown_session.await.unwrap(); +} diff --git a/crates/debugger_ui/src/tests/console.rs b/crates/debugger_ui/src/tests/console.rs new file mode 100644 index 0000000000..eae372f792 --- /dev/null +++ b/crates/debugger_ui/src/tests/console.rs @@ -0,0 +1,916 @@ +use crate::{tests::active_debug_session_panel, *}; +use dap::requests::StackTrace; +use gpui::{BackgroundExecutor, TestAppContext, VisualTestContext}; +use project::{FakeFs, Project}; +use serde_json::json; +use tests::{init_test, init_test_workspace}; + +#[gpui::test] +async fn test_handle_output_event(executor: BackgroundExecutor, cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(executor.clone()); + + fs.insert_tree( + "/project", + json!({ + "main.rs": "First line\nSecond line\nThird line\nFourth line", + }), + ) + .await; + + let project = Project::test(fs, ["/project".as_ref()], cx).await; + let workspace = init_test_workspace(&project, cx).await; + let cx = &mut VisualTestContext::from_window(*workspace, cx); + workspace + .update(cx, |workspace, window, cx| { + workspace.focus_panel::(window, cx); + }) + .unwrap(); + + let task = project.update(cx, |project, cx| { + project.start_debug_session( + dap::test_config(dap::DebugRequestType::Launch, None, None), + cx, + ) + }); + + let session = task.await.unwrap(); + let client = session.update(cx, |session, _| session.adapter_client().unwrap()); + + client + .on_request::(move |_, _| { + Ok(dap::StackTraceResponse { + stack_frames: Vec::default(), + total_frames: None, + }) + }) + .await; + + client + .fake_event(dap::messages::Events::Output(dap::OutputEvent { + category: None, + output: "First console output line before thread stopped!".to_string(), + data: None, + variables_reference: None, + source: None, + line: None, + column: None, + group: None, + location_reference: None, + })) + .await; + + client + .fake_event(dap::messages::Events::Output(dap::OutputEvent { + category: Some(dap::OutputEventCategory::Stdout), + output: "First output line before thread stopped!".to_string(), + data: None, + variables_reference: None, + source: None, + line: None, + column: None, + group: None, + location_reference: None, + })) + .await; + + client + .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { + reason: dap::StoppedEventReason::Pause, + 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(); + + let running_state = + active_debug_session_panel(workspace, cx).update_in(cx, |item, window, cx| { + cx.focus_self(window); + item.mode() + .as_running() + .expect("Session should be running by this point") + .clone() + }); + + running_state.update(cx, |state, cx| { + state.set_thread_item(session::ThreadItem::Console, cx); + cx.refresh_windows(); + }); + cx.run_until_parked(); + + // assert we have output from before the thread stopped + workspace + .update(cx, |workspace, _window, cx| { + let debug_panel = workspace.panel::(cx).unwrap(); + let active_debug_session_panel = debug_panel + .update(cx, |this, cx| this.active_session(cx)) + .unwrap(); + + assert_eq!( + "First console output line before thread stopped!\nFirst output line before thread stopped!\n", + active_debug_session_panel.read(cx).mode().as_running().unwrap().read(cx).console().read(cx).editor().read(cx).text(cx).as_str() + ); + }) + .unwrap(); + + client + .fake_event(dap::messages::Events::Output(dap::OutputEvent { + category: Some(dap::OutputEventCategory::Stdout), + output: "Second output line after thread stopped!".to_string(), + data: None, + variables_reference: None, + source: None, + line: None, + column: None, + group: None, + location_reference: None, + })) + .await; + + client + .fake_event(dap::messages::Events::Output(dap::OutputEvent { + category: Some(dap::OutputEventCategory::Console), + output: "Second console output line after thread stopped!".to_string(), + data: None, + variables_reference: None, + source: None, + line: None, + column: None, + group: None, + location_reference: None, + })) + .await; + + cx.run_until_parked(); + running_state.update(cx, |state, cx| { + state.set_thread_item(session::ThreadItem::Console, cx); + cx.refresh_windows(); + }); + cx.run_until_parked(); + + // assert we have output from before and after the thread stopped + workspace + .update(cx, |workspace, _window, cx| { + let debug_panel = workspace.panel::(cx).unwrap(); + let active_session_panel = debug_panel + .update(cx, |this, cx| this.active_session(cx)) + .unwrap(); + + assert_eq!( + "First console output line before thread stopped!\nFirst output line before thread stopped!\nSecond output line after thread stopped!\nSecond console output line after thread stopped!\n", + active_session_panel.read(cx).mode().as_running().unwrap().read(cx).console().read(cx).editor().read(cx).text(cx).as_str() + ); + }) + .unwrap(); + + 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_grouped_output(executor: BackgroundExecutor, cx: &mut TestAppContext) { +// init_test(cx); + +// let fs = FakeFs::new(executor.clone()); + +// fs.insert_tree( +// "/project", +// json!({ +// "main.rs": "First line\nSecond line\nThird line\nFourth line", +// }), +// ) +// .await; + +// let project = Project::test(fs, ["/project".as_ref()], cx).await; +// let workspace = init_test_workspace(&project, cx).await; +// let cx = &mut VisualTestContext::from_window(*workspace, cx); + +// let task = project.update(cx, |project, cx| { +// project.start_debug_session( +// dap::test_config(dap::DebugRequestType::Launch, None, None), +// cx, +// ) +// }); + +// let session = task.await.unwrap(); +// let client = session.update(cx, |session, _| session.adapter_client().unwrap()); + +// client +// .on_request::(move |_, _| { +// Ok(dap::StackTraceResponse { +// stack_frames: Vec::default(), +// total_frames: None, +// }) +// }) +// .await; + +// client +// .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { +// reason: dap::StoppedEventReason::Pause, +// description: None, +// thread_id: Some(1), +// preserve_focus_hint: None, +// text: None, +// all_threads_stopped: None, +// hit_breakpoint_ids: None, +// })) +// .await; + +// client +// .fake_event(dap::messages::Events::Output(dap::OutputEvent { +// category: None, +// output: "First line".to_string(), +// data: None, +// variables_reference: None, +// source: None, +// line: None, +// column: None, +// group: None, +// location_reference: None, +// })) +// .await; + +// client +// .fake_event(dap::messages::Events::Output(dap::OutputEvent { +// category: Some(dap::OutputEventCategory::Stdout), +// output: "First group".to_string(), +// data: None, +// variables_reference: None, +// source: None, +// line: None, +// column: None, +// group: Some(dap::OutputEventGroup::Start), +// location_reference: None, +// })) +// .await; + +// client +// .fake_event(dap::messages::Events::Output(dap::OutputEvent { +// category: Some(dap::OutputEventCategory::Stdout), +// output: "First item in group 1".to_string(), +// data: None, +// variables_reference: None, +// source: None, +// line: None, +// column: None, +// group: None, +// location_reference: None, +// })) +// .await; + +// client +// .fake_event(dap::messages::Events::Output(dap::OutputEvent { +// category: Some(dap::OutputEventCategory::Stdout), +// output: "Second item in group 1".to_string(), +// data: None, +// variables_reference: None, +// source: None, +// line: None, +// column: None, +// group: None, +// location_reference: None, +// })) +// .await; + +// client +// .fake_event(dap::messages::Events::Output(dap::OutputEvent { +// category: Some(dap::OutputEventCategory::Stdout), +// output: "Second group".to_string(), +// data: None, +// variables_reference: None, +// source: None, +// line: None, +// column: None, +// group: Some(dap::OutputEventGroup::Start), +// location_reference: None, +// })) +// .await; + +// client +// .fake_event(dap::messages::Events::Output(dap::OutputEvent { +// category: Some(dap::OutputEventCategory::Stdout), +// output: "First item in group 2".to_string(), +// data: None, +// variables_reference: None, +// source: None, +// line: None, +// column: None, +// group: None, +// location_reference: None, +// })) +// .await; + +// client +// .fake_event(dap::messages::Events::Output(dap::OutputEvent { +// category: Some(dap::OutputEventCategory::Stdout), +// output: "Second item in group 2".to_string(), +// data: None, +// variables_reference: None, +// source: None, +// line: None, +// column: None, +// group: None, +// location_reference: None, +// })) +// .await; + +// client +// .fake_event(dap::messages::Events::Output(dap::OutputEvent { +// category: Some(dap::OutputEventCategory::Stdout), +// output: "End group 2".to_string(), +// data: None, +// variables_reference: None, +// source: None, +// line: None, +// column: None, +// group: Some(dap::OutputEventGroup::End), +// location_reference: None, +// })) +// .await; + +// client +// .fake_event(dap::messages::Events::Output(dap::OutputEvent { +// category: Some(dap::OutputEventCategory::Stdout), +// output: "Third group".to_string(), +// data: None, +// variables_reference: None, +// source: None, +// line: None, +// column: None, +// group: Some(dap::OutputEventGroup::StartCollapsed), +// location_reference: None, +// })) +// .await; + +// client +// .fake_event(dap::messages::Events::Output(dap::OutputEvent { +// category: Some(dap::OutputEventCategory::Stdout), +// output: "First item in group 3".to_string(), +// data: None, +// variables_reference: None, +// source: None, +// line: None, +// column: None, +// group: None, +// location_reference: None, +// })) +// .await; + +// client +// .fake_event(dap::messages::Events::Output(dap::OutputEvent { +// category: Some(dap::OutputEventCategory::Stdout), +// output: "Second item in group 3".to_string(), +// data: None, +// variables_reference: None, +// source: None, +// line: None, +// column: None, +// group: None, +// location_reference: None, +// })) +// .await; + +// client +// .fake_event(dap::messages::Events::Output(dap::OutputEvent { +// category: Some(dap::OutputEventCategory::Stdout), +// output: "End group 3".to_string(), +// data: None, +// variables_reference: None, +// source: None, +// line: None, +// column: None, +// group: Some(dap::OutputEventGroup::End), +// location_reference: None, +// })) +// .await; + +// client +// .fake_event(dap::messages::Events::Output(dap::OutputEvent { +// category: Some(dap::OutputEventCategory::Stdout), +// output: "Third item in group 1".to_string(), +// data: None, +// variables_reference: None, +// source: None, +// line: None, +// column: None, +// group: None, +// location_reference: None, +// })) +// .await; + +// client +// .fake_event(dap::messages::Events::Output(dap::OutputEvent { +// category: Some(dap::OutputEventCategory::Stdout), +// output: "Second item".to_string(), +// data: None, +// variables_reference: None, +// source: None, +// line: None, +// column: None, +// group: Some(dap::OutputEventGroup::End), +// location_reference: None, +// })) +// .await; + +// cx.run_until_parked(); + +// active_debug_session_panel(workspace, cx).update(cx, |debug_panel_item, cx| { +// debug_panel_item +// .mode() +// .as_running() +// .unwrap() +// .update(cx, |running_state, cx| { +// running_state.console().update(cx, |console, cx| { +// console.editor().update(cx, |editor, cx| { +// pretty_assertions::assert_eq!( +// " +// First line +// First group +// First item in group 1 +// Second item in group 1 +// Second group +// First item in group 2 +// Second item in group 2 +// End group 2 +// ⋯ End group 3 +// Third item in group 1 +// Second item +// " +// .unindent(), +// editor.display_text(cx) +// ); +// }) +// }); +// }); +// }); + +// 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(); +// } + +// todo(debugger): enable this again +// #[gpui::test] +// async fn test_evaluate_expression(executor: BackgroundExecutor, cx: &mut TestAppContext) { +// init_test(cx); + +// const NEW_VALUE: &str = "{nested1: \"Nested 1 updated\", nested2: \"Nested 2 updated\"}"; + +// let called_evaluate = Arc::new(AtomicBool::new(false)); + +// let fs = FakeFs::new(executor.clone()); + +// let test_file_content = r#" +// const variable1 = { +// nested1: "Nested 1", +// nested2: "Nested 2", +// }; +// const variable2 = "Value 2"; +// const variable3 = "Value 3"; +// "# +// .unindent(); + +// fs.insert_tree( +// "/project", +// json!({ +// "src": { +// "test.js": test_file_content, +// } +// }), +// ) +// .await; + +// let project = Project::test(fs, ["/project".as_ref()], cx).await; +// let workspace = init_test_workspace(&project, cx).await; +// let cx = &mut VisualTestContext::from_window(*workspace, cx); + +// let task = project.update(cx, |project, cx| { +// project.start_debug_session(dap::test_config(None), cx) +// }); + +// let session = task.await.unwrap(); +// let client = session.update(cx, |session, _| session.adapter_client().unwrap()); + +// client +// .on_request::(move |_, _| { +// Ok(dap::ThreadsResponse { +// threads: vec![dap::Thread { +// id: 1, +// name: "Thread 1".into(), +// }], +// }) +// }) +// .await; + +// let stack_frames = vec![StackFrame { +// id: 1, +// name: "Stack Frame 1".into(), +// source: Some(dap::Source { +// name: Some("test.js".into()), +// path: Some("/project/src/test.js".into()), +// source_reference: None, +// presentation_hint: None, +// origin: None, +// sources: None, +// adapter_data: None, +// checksums: None, +// }), +// line: 3, +// column: 1, +// end_line: None, +// end_column: None, +// can_restart: None, +// instruction_pointer_reference: None, +// module_id: None, +// presentation_hint: None, +// }]; + +// client +// .on_request::({ +// let stack_frames = Arc::new(stack_frames.clone()); +// move |_, args| { +// assert_eq!(1, args.thread_id); + +// Ok(dap::StackTraceResponse { +// stack_frames: (*stack_frames).clone(), +// total_frames: None, +// }) +// } +// }) +// .await; + +// let scopes = vec![ +// Scope { +// name: "Scope 1".into(), +// presentation_hint: None, +// variables_reference: 2, +// named_variables: None, +// indexed_variables: None, +// expensive: false, +// source: None, +// line: None, +// column: None, +// end_line: None, +// end_column: None, +// }, +// Scope { +// name: "Scope 2".into(), +// presentation_hint: None, +// variables_reference: 4, +// named_variables: None, +// indexed_variables: None, +// expensive: false, +// source: None, +// line: None, +// column: None, +// end_line: None, +// end_column: None, +// }, +// ]; + +// client +// .on_request::({ +// let scopes = Arc::new(scopes.clone()); +// move |_, args| { +// assert_eq!(1, args.frame_id); + +// Ok(dap::ScopesResponse { +// scopes: (*scopes).clone(), +// }) +// } +// }) +// .await; + +// let scope1_variables = Arc::new(Mutex::new(vec![ +// Variable { +// name: "variable1".into(), +// value: "{nested1: \"Nested 1\", nested2: \"Nested 2\"}".into(), +// type_: None, +// presentation_hint: None, +// evaluate_name: None, +// variables_reference: 3, +// named_variables: None, +// indexed_variables: None, +// memory_reference: None, +// declaration_location_reference: None, +// value_location_reference: None, +// }, +// Variable { +// name: "variable2".into(), +// value: "Value 2".into(), +// type_: None, +// presentation_hint: None, +// evaluate_name: None, +// variables_reference: 0, +// named_variables: None, +// indexed_variables: None, +// memory_reference: None, +// declaration_location_reference: None, +// value_location_reference: None, +// }, +// ])); + +// let nested_variables = vec![ +// Variable { +// name: "nested1".into(), +// value: "Nested 1".into(), +// type_: None, +// presentation_hint: None, +// evaluate_name: None, +// variables_reference: 0, +// named_variables: None, +// indexed_variables: None, +// memory_reference: None, +// declaration_location_reference: None, +// value_location_reference: None, +// }, +// Variable { +// name: "nested2".into(), +// value: "Nested 2".into(), +// type_: None, +// presentation_hint: None, +// evaluate_name: None, +// variables_reference: 0, +// named_variables: None, +// indexed_variables: None, +// memory_reference: None, +// declaration_location_reference: None, +// value_location_reference: None, +// }, +// ]; + +// let scope2_variables = vec![Variable { +// name: "variable3".into(), +// value: "Value 3".into(), +// type_: None, +// presentation_hint: None, +// evaluate_name: None, +// variables_reference: 0, +// named_variables: None, +// indexed_variables: None, +// memory_reference: None, +// declaration_location_reference: None, +// value_location_reference: None, +// }]; + +// client +// .on_request::({ +// let scope1_variables = scope1_variables.clone(); +// let nested_variables = Arc::new(nested_variables.clone()); +// let scope2_variables = Arc::new(scope2_variables.clone()); +// move |_, args| match args.variables_reference { +// 4 => Ok(dap::VariablesResponse { +// variables: (*scope2_variables).clone(), +// }), +// 3 => Ok(dap::VariablesResponse { +// variables: (*nested_variables).clone(), +// }), +// 2 => Ok(dap::VariablesResponse { +// variables: scope1_variables.lock().unwrap().clone(), +// }), +// id => unreachable!("unexpected variables reference {id}"), +// } +// }) +// .await; + +// client +// .on_request::({ +// let called_evaluate = called_evaluate.clone(); +// let scope1_variables = scope1_variables.clone(); +// move |_, args| { +// called_evaluate.store(true, Ordering::SeqCst); + +// assert_eq!(format!("$variable1 = {}", NEW_VALUE), args.expression); +// assert_eq!(Some(1), args.frame_id); +// assert_eq!(Some(dap::EvaluateArgumentsContext::Variables), args.context); + +// scope1_variables.lock().unwrap()[0].value = NEW_VALUE.to_string(); + +// Ok(dap::EvaluateResponse { +// result: NEW_VALUE.into(), +// type_: None, +// presentation_hint: None, +// variables_reference: 0, +// named_variables: None, +// indexed_variables: None, +// memory_reference: None, +// value_location_reference: None, +// }) +// } +// }) +// .await; + +// client +// .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { +// reason: dap::StoppedEventReason::Pause, +// 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(); + +// // toggle nested variables for scope 1 +// active_debug_session_panel(workspace, cx).update(cx, |debug_panel_item, cx| { +// debug_panel_item +// .mode() +// .as_running() +// .unwrap() +// .update(cx, |running_state, cx| { +// running_state +// .variable_list() +// .update(cx, |variable_list, cx| { +// variable_list.toggle_variable( +// &VariablePath { +// indices: Arc::from([scopes[0].variables_reference]), +// }, +// cx, +// ); +// }); +// }); +// }); + +// cx.run_until_parked(); + +// active_debug_session_panel(workspace, cx).update_in(cx, |debug_panel_item, window, cx| { +// debug_panel_item +// .mode() +// .as_running() +// .unwrap() +// .update(cx, |running_state, cx| { +// running_state.console().update(cx, |console, item_cx| { +// console +// .query_bar() +// .update(item_cx, |query_bar, console_cx| { +// query_bar.set_text( +// format!("$variable1 = {}", NEW_VALUE), +// window, +// console_cx, +// ); +// }); + +// console.evaluate(&menu::Confirm, window, item_cx); +// }); +// }); +// }); + +// cx.run_until_parked(); + +// active_debug_session_panel(workspace, cx).update(cx, |debug_panel_item, cx| { +// assert_eq!( +// "", +// debug_panel_item +// .mode() +// .as_running() +// .unwrap() +// .read(cx) +// .console() +// .read(cx) +// .query_bar() +// .read(cx) +// .text(cx) +// .as_str() +// ); + +// assert_eq!( +// format!("{}\n", NEW_VALUE), +// debug_panel_item +// .mode() +// .as_running() +// .unwrap() +// .read(cx) +// .console() +// .read(cx) +// .editor() +// .read(cx) +// .text(cx) +// .as_str() +// ); + +// debug_panel_item +// .mode() +// .as_running() +// .unwrap() +// .update(cx, |running_state, cx| { +// running_state +// .variable_list() +// .update(cx, |variable_list, _| { +// let scope1_variables = scope1_variables.lock().unwrap().clone(); + +// // scope 1 +// // assert_eq!( +// // vec![ +// // VariableContainer { +// // container_reference: scopes[0].variables_reference, +// // variable: scope1_variables[0].clone(), +// // depth: 1, +// // }, +// // VariableContainer { +// // container_reference: scope1_variables[0].variables_reference, +// // variable: nested_variables[0].clone(), +// // depth: 2, +// // }, +// // VariableContainer { +// // container_reference: scope1_variables[0].variables_reference, +// // variable: nested_variables[1].clone(), +// // depth: 2, +// // }, +// // VariableContainer { +// // container_reference: scopes[0].variables_reference, +// // variable: scope1_variables[1].clone(), +// // depth: 1, +// // }, +// // ], +// // variable_list.variables_by_scope(1, 2).unwrap().variables() +// // ); + +// // scope 2 +// // assert_eq!( +// // vec![VariableContainer { +// // container_reference: scopes[1].variables_reference, +// // variable: scope2_variables[0].clone(), +// // depth: 1, +// // }], +// // variable_list.variables_by_scope(1, 4).unwrap().variables() +// // ); + +// variable_list.assert_visual_entries(vec![ +// "v Scope 1", +// " v variable1", +// " > nested1", +// " > nested2", +// " > variable2", +// ]); + +// // assert visual entries +// // assert_eq!( +// // vec![ +// // VariableListEntry::Scope(scopes[0].clone()), +// // VariableListEntry::Variable { +// // depth: 1, +// // scope: Arc::new(scopes[0].clone()), +// // has_children: true, +// // variable: Arc::new(scope1_variables[0].clone()), +// // container_reference: scopes[0].variables_reference, +// // }, +// // VariableListEntry::Variable { +// // depth: 2, +// // scope: Arc::new(scopes[0].clone()), +// // has_children: false, +// // variable: Arc::new(nested_variables[0].clone()), +// // container_reference: scope1_variables[0].variables_reference, +// // }, +// // VariableListEntry::Variable { +// // depth: 2, +// // scope: Arc::new(scopes[0].clone()), +// // has_children: false, +// // variable: Arc::new(nested_variables[1].clone()), +// // container_reference: scope1_variables[0].variables_reference, +// // }, +// // VariableListEntry::Variable { +// // depth: 1, +// // scope: Arc::new(scopes[0].clone()), +// // has_children: false, +// // variable: Arc::new(scope1_variables[1].clone()), +// // container_reference: scopes[0].variables_reference, +// // }, +// // VariableListEntry::Scope(scopes[1].clone()), +// // ], +// // variable_list.entries().get(&1).unwrap().clone() +// // ); +// }); +// }); +// }); + +// assert!( +// called_evaluate.load(std::sync::atomic::Ordering::SeqCst), +// "Expected evaluate request to be called" +// ); + +// 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(); +// } diff --git a/crates/debugger_ui/src/tests/debugger_panel.rs b/crates/debugger_ui/src/tests/debugger_panel.rs new file mode 100644 index 0000000000..d87705e763 --- /dev/null +++ b/crates/debugger_ui/src/tests/debugger_panel.rs @@ -0,0 +1,1084 @@ +use crate::*; +use dap::{ + client::SessionId, + requests::{ + Continue, Disconnect, Launch, Next, RunInTerminal, SetBreakpoints, StackTrace, + StartDebugging, StepBack, StepIn, StepOut, Threads, + }, + DebugRequestType, ErrorResponse, RunInTerminalRequestArguments, SourceBreakpoint, + StartDebuggingRequestArguments, StartDebuggingRequestArgumentsRequest, +}; +use editor::{ + actions::{self}, + Editor, EditorMode, MultiBuffer, +}; +use gpui::{BackgroundExecutor, TestAppContext, VisualTestContext}; +use project::{ + debugger::session::{ThreadId, ThreadStatus}, + FakeFs, Project, +}; +use serde_json::json; +use std::{ + path::Path, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, +}; +use terminal_view::{terminal_panel::TerminalPanel, TerminalView}; +use tests::{active_debug_session_panel, init_test, init_test_workspace}; +use util::path; +use workspace::{dock::Panel, Item}; + +#[gpui::test] +async fn test_basic_show_debug_panel(executor: BackgroundExecutor, cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(executor.clone()); + + fs.insert_tree( + "/project", + json!({ + "main.rs": "First line\nSecond line\nThird line\nFourth line", + }), + ) + .await; + + let project = Project::test(fs, ["/project".as_ref()], cx).await; + let workspace = init_test_workspace(&project, cx).await; + let cx = &mut VisualTestContext::from_window(*workspace, cx); + + let task = project.update(cx, |project, cx| { + project.start_debug_session(dap::test_config(DebugRequestType::Launch, None, None), cx) + }); + + let session = task.await.unwrap(); + let client = session.update(cx, |session, _| session.adapter_client().unwrap()); + + client + .on_request::(move |_, _| { + Ok(dap::ThreadsResponse { + threads: vec![dap::Thread { + id: 1, + name: "Thread 1".into(), + }], + }) + }) + .await; + + client + .on_request::(move |_, _| { + Ok(dap::StackTraceResponse { + stack_frames: Vec::default(), + total_frames: None, + }) + }) + .await; + + // assert we have a debug panel item before the session has stopped + workspace + .update(cx, |workspace, _window, cx| { + let debug_panel = workspace.panel::(cx).unwrap(); + let active_session = debug_panel.update(cx, |debug_panel, cx| { + debug_panel.active_session(cx).unwrap() + }); + + let running_state = active_session.update(cx, |active_session, _| { + active_session + .mode() + .as_running() + .expect("Session should be running by this point") + .clone() + }); + + debug_panel.update(cx, |this, cx| { + assert!(this.active_session(cx).is_some()); + // we have one active session and one inert item + assert_eq!(2, this.pane().unwrap().read(cx).items_len()); + assert!(running_state.read(cx).selected_thread_id().is_none()); + }); + }) + .unwrap(); + + client + .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { + reason: dap::StoppedEventReason::Pause, + 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(); + + workspace + .update(cx, |workspace, _window, cx| { + let debug_panel = workspace.panel::(cx).unwrap(); + let active_session = debug_panel + .update(cx, |this, cx| this.active_session(cx)) + .unwrap(); + + let running_state = active_session.update(cx, |active_session, _| { + active_session + .mode() + .as_running() + .expect("Session should be running by this point") + .clone() + }); + + // we have one active session and one inert item + assert_eq!( + 2, + debug_panel.update(cx, |this, cx| this.pane().unwrap().read(cx).items_len()) + ); + assert_eq!(client.id(), running_state.read(cx).session_id()); + assert_eq!( + ThreadId(1), + running_state.read(cx).selected_thread_id().unwrap() + ); + }) + .unwrap(); + + 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(); + + // assert we still have a debug panel item after the client shutdown + workspace + .update(cx, |workspace, _window, cx| { + let debug_panel = workspace.panel::(cx).unwrap(); + + let active_session = debug_panel + .update(cx, |this, cx| this.active_session(cx)) + .unwrap(); + + let running_state = active_session.update(cx, |active_session, _| { + active_session + .mode() + .as_running() + .expect("Session should be running by this point") + .clone() + }); + + debug_panel.update(cx, |this, cx| { + assert!(this.active_session(cx).is_some()); + assert_eq!(2, this.pane().unwrap().read(cx).items_len()); + assert_eq!( + ThreadId(1), + running_state.read(cx).selected_thread_id().unwrap() + ); + }); + }) + .unwrap(); +} + +#[gpui::test] +async fn test_we_can_only_have_one_panel_per_debug_session( + executor: BackgroundExecutor, + cx: &mut TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(executor.clone()); + + fs.insert_tree( + "/project", + json!({ + "main.rs": "First line\nSecond line\nThird line\nFourth line", + }), + ) + .await; + + let project = Project::test(fs, ["/project".as_ref()], cx).await; + let workspace = init_test_workspace(&project, cx).await; + let cx = &mut VisualTestContext::from_window(*workspace, cx); + + let task = project.update(cx, |project, cx| { + project.start_debug_session(dap::test_config(DebugRequestType::Launch, None, None), cx) + }); + + let session = task.await.unwrap(); + let client = session.update(cx, |session, _| session.adapter_client().unwrap()); + + client + .on_request::(move |_, _| { + Ok(dap::ThreadsResponse { + threads: vec![dap::Thread { + id: 1, + name: "Thread 1".into(), + }], + }) + }) + .await; + + client + .on_request::(move |_, _| { + Ok(dap::StackTraceResponse { + stack_frames: Vec::default(), + total_frames: None, + }) + }) + .await; + + // assert we have a debug panel item before the session has stopped + workspace + .update(cx, |workspace, _window, cx| { + let debug_panel = workspace.panel::(cx).unwrap(); + + debug_panel.update(cx, |this, cx| { + assert!(this.active_session(cx).is_some()); + // we have one active session and one inert item + assert_eq!(2, this.pane().unwrap().read(cx).items_len()); + }); + }) + .unwrap(); + + client + .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { + reason: dap::StoppedEventReason::Pause, + 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(); + + // assert we added a debug panel item + workspace + .update(cx, |workspace, _window, cx| { + let debug_panel = workspace.panel::(cx).unwrap(); + let active_session = debug_panel + .update(cx, |this, cx| this.active_session(cx)) + .unwrap(); + + let running_state = active_session.update(cx, |active_session, _| { + active_session + .mode() + .as_running() + .expect("Session should be running by this point") + .clone() + }); + + // we have one active session and one inert item + assert_eq!( + 2, + debug_panel.update(cx, |this, cx| this.pane().unwrap().read(cx).items_len()) + ); + assert_eq!(client.id(), active_session.read(cx).session_id(cx).unwrap()); + assert_eq!( + ThreadId(1), + running_state.read(cx).selected_thread_id().unwrap() + ); + }) + .unwrap(); + + client + .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { + reason: dap::StoppedEventReason::Pause, + description: None, + thread_id: Some(2), + preserve_focus_hint: None, + text: None, + all_threads_stopped: None, + hit_breakpoint_ids: None, + })) + .await; + + cx.run_until_parked(); + + workspace + .update(cx, |workspace, _window, cx| { + let debug_panel = workspace.panel::(cx).unwrap(); + let active_session = debug_panel + .update(cx, |this, cx| this.active_session(cx)) + .unwrap(); + + let running_state = active_session.update(cx, |active_session, _| { + active_session + .mode() + .as_running() + .expect("Session should be running by this point") + .clone() + }); + + // we have one active session and one inert item + assert_eq!( + 2, + debug_panel.update(cx, |this, cx| this.pane().unwrap().read(cx).items_len()) + ); + assert_eq!(client.id(), active_session.read(cx).session_id(cx).unwrap()); + assert_eq!( + ThreadId(1), + running_state.read(cx).selected_thread_id().unwrap() + ); + }) + .unwrap(); + + 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(); + + // assert we still have a debug panel item after the client shutdown + workspace + .update(cx, |workspace, _window, cx| { + let debug_panel = workspace.panel::(cx).unwrap(); + let active_session = debug_panel + .update(cx, |this, cx| this.active_session(cx)) + .unwrap(); + + let running_state = active_session.update(cx, |active_session, _| { + active_session + .mode() + .as_running() + .expect("Session should be running by this point") + .clone() + }); + + debug_panel.update(cx, |this, cx| { + assert!(this.active_session(cx).is_some()); + assert_eq!(2, this.pane().unwrap().read(cx).items_len()); + assert_eq!( + ThreadId(1), + running_state.read(cx).selected_thread_id().unwrap() + ); + }); + }) + .unwrap(); +} + +#[gpui::test] +async fn test_handle_successful_run_in_terminal_reverse_request( + executor: BackgroundExecutor, + cx: &mut TestAppContext, +) { + init_test(cx); + + let send_response = Arc::new(AtomicBool::new(false)); + + let fs = FakeFs::new(executor.clone()); + + fs.insert_tree( + "/project", + json!({ + "main.rs": "First line\nSecond line\nThird line\nFourth line", + }), + ) + .await; + + let project = Project::test(fs, ["/project".as_ref()], cx).await; + let workspace = init_test_workspace(&project, cx).await; + let cx = &mut VisualTestContext::from_window(*workspace, cx); + + let task = project.update(cx, |project, cx| { + project.start_debug_session(dap::test_config(DebugRequestType::Launch, None, None), cx) + }); + + let session = task.await.unwrap(); + let client = session.update(cx, |session, _| session.adapter_client().unwrap()); + + client + .on_response::({ + let send_response = send_response.clone(); + move |response| { + send_response.store(true, Ordering::SeqCst); + + assert!(response.success); + assert!(response.body.is_some()); + } + }) + .await; + + client + .fake_reverse_request::(RunInTerminalRequestArguments { + kind: None, + title: None, + cwd: std::env::temp_dir().to_string_lossy().to_string(), + args: vec![], + env: None, + args_can_be_interpreted_by_shell: None, + }) + .await; + + cx.run_until_parked(); + + assert!( + send_response.load(std::sync::atomic::Ordering::SeqCst), + "Expected to receive response from reverse request" + ); + + workspace + .update(cx, |workspace, _window, cx| { + let terminal_panel = workspace.panel::(cx).unwrap(); + + let panel = terminal_panel.read(cx).pane().unwrap().read(cx); + + assert_eq!(1, panel.items_len()); + assert!(panel + .active_item() + .unwrap() + .downcast::() + .unwrap() + .read(cx) + .terminal() + .read(cx) + .debug_terminal()); + }) + .unwrap(); + + 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(); +} + +// // covers that we always send a response back, if something when wrong, +// // while spawning the terminal +#[gpui::test] +async fn test_handle_error_run_in_terminal_reverse_request( + executor: BackgroundExecutor, + cx: &mut TestAppContext, +) { + init_test(cx); + + let send_response = Arc::new(AtomicBool::new(false)); + + let fs = FakeFs::new(executor.clone()); + + fs.insert_tree( + "/project", + json!({ + "main.rs": "First line\nSecond line\nThird line\nFourth line", + }), + ) + .await; + + let project = Project::test(fs, ["/project".as_ref()], cx).await; + let workspace = init_test_workspace(&project, cx).await; + let cx = &mut VisualTestContext::from_window(*workspace, cx); + + let task = project.update(cx, |project, cx| { + project.start_debug_session(dap::test_config(DebugRequestType::Launch, None, None), cx) + }); + + let session = task.await.unwrap(); + let client = session.update(cx, |session, _| session.adapter_client().unwrap()); + + client + .on_response::({ + let send_response = send_response.clone(); + move |response| { + send_response.store(true, Ordering::SeqCst); + + assert!(!response.success); + assert!(response.body.is_some()); + } + }) + .await; + + client + .fake_reverse_request::(RunInTerminalRequestArguments { + kind: None, + title: None, + cwd: "/non-existing/path".into(), // invalid/non-existing path will cause the terminal spawn to fail + args: vec![], + env: None, + args_can_be_interpreted_by_shell: None, + }) + .await; + + cx.run_until_parked(); + + assert!( + send_response.load(std::sync::atomic::Ordering::SeqCst), + "Expected to receive response from reverse request" + ); + + workspace + .update(cx, |workspace, _window, cx| { + let terminal_panel = workspace.panel::(cx).unwrap(); + + assert_eq!( + 0, + terminal_panel.read(cx).pane().unwrap().read(cx).items_len() + ); + }) + .unwrap(); + + 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_handle_start_debugging_reverse_request( + executor: BackgroundExecutor, + cx: &mut TestAppContext, +) { + init_test(cx); + + let send_response = Arc::new(AtomicBool::new(false)); + + let fs = FakeFs::new(executor.clone()); + + fs.insert_tree( + "/project", + json!({ + "main.rs": "First line\nSecond line\nThird line\nFourth line", + }), + ) + .await; + + let project = Project::test(fs, ["/project".as_ref()], cx).await; + let workspace = init_test_workspace(&project, cx).await; + let cx = &mut VisualTestContext::from_window(*workspace, cx); + + let task = project.update(cx, |project, cx| { + project.start_debug_session(dap::test_config(DebugRequestType::Launch, None, None), cx) + }); + + let session = task.await.unwrap(); + let client = session.update(cx, |session, _| session.adapter_client().unwrap()); + + client + .on_request::(move |_, _| { + Ok(dap::ThreadsResponse { + threads: vec![dap::Thread { + id: 1, + name: "Thread 1".into(), + }], + }) + }) + .await; + + client + .on_response::({ + let send_response = send_response.clone(); + move |response| { + send_response.store(true, Ordering::SeqCst); + + assert!(response.success); + assert!(response.body.is_some()); + } + }) + .await; + + client + .fake_reverse_request::(StartDebuggingRequestArguments { + configuration: json!({}), + request: StartDebuggingRequestArgumentsRequest::Launch, + }) + .await; + + cx.run_until_parked(); + + let child_session = project.update(cx, |project, cx| { + project + .dap_store() + .read(cx) + .session_by_id(SessionId(1)) + .unwrap() + }); + let child_client = child_session.update(cx, |session, _| session.adapter_client().unwrap()); + + client + .on_request::(move |_, _| { + Ok(dap::ThreadsResponse { + threads: vec![dap::Thread { + id: 1, + name: "Thread 1".into(), + }], + }) + }) + .await; + + child_client + .on_request::(move |_, _| Ok(())) + .await; + + child_client + .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { + reason: dap::StoppedEventReason::Pause, + description: None, + thread_id: Some(2), + preserve_focus_hint: None, + text: None, + all_threads_stopped: None, + hit_breakpoint_ids: None, + })) + .await; + + cx.run_until_parked(); + + assert!( + send_response.load(std::sync::atomic::Ordering::SeqCst), + "Expected to receive response from reverse request" + ); + + let shutdown_session = project.update(cx, |project, cx| { + project.dap_store().update(cx, |dap_store, cx| { + dap_store.shutdown_session(child_session.read(cx).session_id(), cx) + }) + }); + + shutdown_session.await.unwrap(); +} + +#[gpui::test] +async fn test_debug_panel_item_thread_status_reset_on_failure( + executor: BackgroundExecutor, + cx: &mut TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(executor.clone()); + + fs.insert_tree( + "/project", + json!({ + "main.rs": "First line\nSecond line\nThird line\nFourth line", + }), + ) + .await; + + let project = Project::test(fs, ["/project".as_ref()], cx).await; + let workspace = init_test_workspace(&project, cx).await; + let cx = &mut VisualTestContext::from_window(*workspace, cx); + + let task = project.update(cx, |project, cx| { + project.start_debug_session( + dap::test_config( + DebugRequestType::Launch, + None, + Some(dap::Capabilities { + supports_step_back: Some(true), + ..Default::default() + }), + ), + cx, + ) + }); + + let session = task.await.unwrap(); + let client = session.update(cx, |session, _| session.adapter_client().unwrap()); + const THREAD_ID_NUM: u64 = 1; + + client + .on_request::(move |_, _| { + Ok(dap::ThreadsResponse { + threads: vec![dap::Thread { + id: THREAD_ID_NUM, + name: "Thread 1".into(), + }], + }) + }) + .await; + + client.on_request::(move |_, _| Ok(())).await; + + client + .on_request::(move |_, _| { + Ok(dap::StackTraceResponse { + stack_frames: Vec::default(), + total_frames: None, + }) + }) + .await; + + client + .on_request::(move |_, _| { + Err(ErrorResponse { + error: Some(dap::Message { + id: 1, + format: "error".into(), + variables: None, + send_telemetry: None, + show_user: None, + url: None, + url_label: None, + }), + }) + }) + .await; + + client + .on_request::(move |_, _| { + Err(ErrorResponse { + error: Some(dap::Message { + id: 1, + format: "error".into(), + variables: None, + send_telemetry: None, + show_user: None, + url: None, + url_label: None, + }), + }) + }) + .await; + + client + .on_request::(move |_, _| { + Err(ErrorResponse { + error: Some(dap::Message { + id: 1, + format: "error".into(), + variables: None, + send_telemetry: None, + show_user: None, + url: None, + url_label: None, + }), + }) + }) + .await; + + client + .on_request::(move |_, _| { + Err(ErrorResponse { + error: Some(dap::Message { + id: 1, + format: "error".into(), + variables: None, + send_telemetry: None, + show_user: None, + url: None, + url_label: None, + }), + }) + }) + .await; + + client + .on_request::(move |_, _| { + Err(ErrorResponse { + error: Some(dap::Message { + id: 1, + format: "error".into(), + variables: None, + send_telemetry: None, + show_user: None, + url: None, + url_label: None, + }), + }) + }) + .await; + + client + .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { + reason: dap::StoppedEventReason::Pause, + description: None, + thread_id: Some(1), + preserve_focus_hint: None, + text: None, + all_threads_stopped: None, + hit_breakpoint_ids: None, + })) + .await; + + let running_state = active_debug_session_panel(workspace, cx).update_in(cx, |item, _, _| { + item.mode() + .as_running() + .expect("Session should be running by this point") + .clone() + }); + + cx.run_until_parked(); + let thread_id = ThreadId(1); + + for operation in &[ + "step_over", + "continue_thread", + "step_back", + "step_in", + "step_out", + ] { + running_state.update(cx, |running_state, cx| match *operation { + "step_over" => running_state.step_over(cx), + "continue_thread" => running_state.continue_thread(cx), + "step_back" => running_state.step_back(cx), + "step_in" => running_state.step_in(cx), + "step_out" => running_state.step_out(cx), + _ => unreachable!(), + }); + + // Check that we step the thread status to the correct intermediate state + running_state.update(cx, |running_state, cx| { + assert_eq!( + running_state + .thread_status(cx) + .expect("There should be an active thread selected"), + match *operation { + "continue_thread" => ThreadStatus::Running, + _ => ThreadStatus::Stepping, + }, + "Thread status was not set to correct intermediate state after {} request", + operation + ); + }); + + cx.run_until_parked(); + + running_state.update(cx, |running_state, cx| { + assert_eq!( + running_state + .thread_status(cx) + .expect("There should be an active thread selected"), + ThreadStatus::Stopped, + "Thread status not reset to Stopped after failed {}", + operation + ); + + // update state to running, so we can test it actually changes the status back to stopped + running_state + .session() + .update(cx, |session, cx| session.continue_thread(thread_id, cx)); + }); + } + + 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_send_breakpoints_when_editor_has_been_saved( + 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", + }), + ) + .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(); + + let task = project.update(cx, |project, cx| { + project.start_debug_session(dap::test_config(DebugRequestType::Launch, None, None), cx) + }); + + let session = task.await.unwrap(); + let client = session.update(cx, |session, _| session.adapter_client().unwrap()); + + let buffer = project + .update(cx, |project, cx| { + project.open_buffer((worktree_id, "main.rs"), cx) + }) + .await + .unwrap(); + + let (editor, cx) = cx.add_window_view(|window, cx| { + Editor::new( + EditorMode::Full, + MultiBuffer::build_from_buffer(buffer, cx), + Some(project.clone()), + window, + cx, + ) + }); + + client.on_request::(move |_, _| Ok(())).await; + + client + .on_request::(move |_, _| { + Ok(dap::StackTraceResponse { + stack_frames: Vec::default(), + total_frames: None, + }) + }) + .await; + + client + .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { + reason: dap::StoppedEventReason::Pause, + description: None, + thread_id: Some(1), + preserve_focus_hint: None, + text: None, + all_threads_stopped: None, + hit_breakpoint_ids: None, + })) + .await; + + let called_set_breakpoints = Arc::new(AtomicBool::new(false)); + client + .on_request::({ + let called_set_breakpoints = called_set_breakpoints.clone(); + move |_, args| { + assert_eq!(path!("/project/main.rs"), args.source.path.unwrap()); + assert_eq!( + vec![SourceBreakpoint { + line: 2, + column: None, + condition: None, + hit_condition: None, + log_message: None, + mode: None + }], + args.breakpoints.unwrap() + ); + assert!(!args.source_modified.unwrap()); + + called_set_breakpoints.store(true, Ordering::SeqCst); + + Ok(dap::SetBreakpointsResponse { + breakpoints: Vec::default(), + }) + } + }) + .await; + + editor.update_in(cx, |editor, window, cx| { + editor.move_down(&actions::MoveDown, window, cx); + editor.toggle_breakpoint(&actions::ToggleBreakpoint, window, cx); + }); + + cx.run_until_parked(); + + assert!( + called_set_breakpoints.load(std::sync::atomic::Ordering::SeqCst), + "SetBreakpoint request must be called" + ); + + let called_set_breakpoints = Arc::new(AtomicBool::new(false)); + client + .on_request::({ + let called_set_breakpoints = called_set_breakpoints.clone(); + move |_, args| { + assert_eq!(path!("/project/main.rs"), args.source.path.unwrap()); + assert_eq!( + vec![SourceBreakpoint { + line: 3, + column: None, + condition: None, + hit_condition: None, + log_message: None, + mode: None + }], + args.breakpoints.unwrap() + ); + assert!(args.source_modified.unwrap()); + + called_set_breakpoints.store(true, Ordering::SeqCst); + + Ok(dap::SetBreakpointsResponse { + breakpoints: Vec::default(), + }) + } + }) + .await; + + editor.update_in(cx, |editor, window, cx| { + editor.move_up(&actions::MoveUp, window, cx); + editor.insert("new text\n", window, cx); + }); + + editor + .update_in(cx, |editor, window, cx| { + editor.save(true, project.clone(), window, cx) + }) + .await + .unwrap(); + + cx.run_until_parked(); + + assert!( + called_set_breakpoints.load(std::sync::atomic::Ordering::SeqCst), + "SetBreakpoint request must be called after editor is saved" + ); + + 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_debug_session_is_shutdown_when_attach_and_launch_request_fails( + executor: BackgroundExecutor, + cx: &mut TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(executor.clone()); + + fs.insert_tree( + "/project", + json!({ + "main.rs": "First line\nSecond line\nThird line\nFourth line", + }), + ) + .await; + + let project = Project::test(fs, ["/project".as_ref()], cx).await; + let workspace = init_test_workspace(&project, cx).await; + let cx = &mut VisualTestContext::from_window(*workspace, cx); + + let task = project.update(cx, |project, cx| { + project.start_debug_session( + dap::test_config(DebugRequestType::Launch, Some(true), None), + cx, + ) + }); + + assert!( + task.await.is_err(), + "Session should failed to start if launch request fails" + ); + + cx.run_until_parked(); + + project.update(cx, |project, cx| { + assert!( + project.dap_store().read(cx).sessions().count() == 0, + "Session wouldn't exist if it was shutdown" + ); + }); +} diff --git a/crates/debugger_ui/src/tests/module_list.rs b/crates/debugger_ui/src/tests/module_list.rs new file mode 100644 index 0000000000..98574d4b9d --- /dev/null +++ b/crates/debugger_ui/src/tests/module_list.rs @@ -0,0 +1,262 @@ +use crate::{ + debugger_panel::DebugPanel, + session::ThreadItem, + tests::{active_debug_session_panel, init_test, init_test_workspace}, +}; +use dap::{ + requests::{Modules, StackTrace, Threads}, + DebugRequestType, StoppedEvent, +}; +use gpui::{BackgroundExecutor, TestAppContext, VisualTestContext}; +use project::{FakeFs, Project}; +use std::sync::{ + atomic::{AtomicBool, AtomicI32, Ordering}, + Arc, +}; + +#[gpui::test] +async fn test_module_list(executor: BackgroundExecutor, cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(executor.clone()); + + let project = Project::test(fs, ["/project".as_ref()], cx).await; + let workspace = init_test_workspace(&project, cx).await; + workspace + .update(cx, |workspace, window, cx| { + workspace.focus_panel::(window, cx); + }) + .unwrap(); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + + let task = project.update(cx, |project, cx| { + project.start_debug_session( + dap::test_config( + DebugRequestType::Launch, + None, + Some(dap::Capabilities { + supports_modules_request: Some(true), + ..Default::default() + }), + ), + cx, + ) + }); + + let session = task.await.unwrap(); + let client = session.update(cx, |session, _| session.adapter_client().unwrap()); + + client + .on_request::(move |_, args| { + assert!(args.thread_id == 1); + Ok(dap::StackTraceResponse { + stack_frames: Vec::default(), + total_frames: None, + }) + }) + .await; + + let called_modules = Arc::new(AtomicBool::new(false)); + let modules = vec![ + dap::Module { + id: dap::ModuleId::Number(1), + name: "First Module".into(), + address_range: None, + date_time_stamp: None, + path: None, + symbol_file_path: None, + symbol_status: None, + version: None, + is_optimized: None, + is_user_code: None, + }, + dap::Module { + id: dap::ModuleId::Number(2), + name: "Second Module".into(), + address_range: None, + date_time_stamp: None, + path: None, + symbol_file_path: None, + symbol_status: None, + version: None, + is_optimized: None, + is_user_code: None, + }, + ]; + + client + .on_request::(move |_, _| { + Ok(dap::ThreadsResponse { + threads: vec![dap::Thread { + id: 1, + name: "Thread 1".into(), + }], + }) + }) + .await; + + client + .on_request::({ + let called_modules = called_modules.clone(); + let modules_request_count = AtomicI32::new(0); + let modules = modules.clone(); + move |_, _| { + modules_request_count.fetch_add(1, Ordering::SeqCst); + assert_eq!( + 1, + modules_request_count.load(Ordering::SeqCst), + "This request should only be called once from the host" + ); + called_modules.store(true, Ordering::SeqCst); + + Ok(dap::ModulesResponse { + modules: modules.clone(), + total_modules: Some(2u64), + }) + } + }) + .await; + + client + .fake_event(dap::messages::Events::Stopped(StoppedEvent { + reason: dap::StoppedEventReason::Pause, + 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(); + + let running_state = + active_debug_session_panel(workspace, cx).update_in(cx, |item, window, cx| { + cx.focus_self(window); + item.mode() + .as_running() + .expect("Session should be running by this point") + .clone() + }); + + assert!( + !called_modules.load(std::sync::atomic::Ordering::SeqCst), + "Request Modules shouldn't be called before it's needed" + ); + + running_state.update(cx, |state, cx| { + state.set_thread_item(ThreadItem::Modules, cx); + cx.refresh_windows(); + }); + + cx.run_until_parked(); + + assert!( + called_modules.load(std::sync::atomic::Ordering::SeqCst), + "Request Modules should be called because a user clicked on the module list" + ); + + active_debug_session_panel(workspace, cx).update(cx, |_, cx| { + running_state.update(cx, |state, cx| { + state.set_thread_item(ThreadItem::Modules, cx) + }); + let actual_modules = running_state.update(cx, |state, cx| { + state.module_list().update(cx, |list, cx| list.modules(cx)) + }); + + assert_eq!(modules, actual_modules); + }); + + // Test all module events now + // New Module + // Changed + // Removed + + let new_module = dap::Module { + id: dap::ModuleId::Number(3), + name: "Third Module".into(), + address_range: None, + date_time_stamp: None, + path: None, + symbol_file_path: None, + symbol_status: None, + version: None, + is_optimized: None, + is_user_code: None, + }; + + client + .fake_event(dap::messages::Events::Module(dap::ModuleEvent { + reason: dap::ModuleEventReason::New, + module: new_module.clone(), + })) + .await; + + cx.run_until_parked(); + + active_debug_session_panel(workspace, cx).update(cx, |_, cx| { + let actual_modules = running_state.update(cx, |state, cx| { + state.module_list().update(cx, |list, cx| list.modules(cx)) + }); + assert_eq!(actual_modules.len(), 3); + assert!(actual_modules.contains(&new_module)); + }); + + let changed_module = dap::Module { + id: dap::ModuleId::Number(2), + name: "Modified Second Module".into(), + address_range: None, + date_time_stamp: None, + path: None, + symbol_file_path: None, + symbol_status: None, + version: None, + is_optimized: None, + is_user_code: None, + }; + + client + .fake_event(dap::messages::Events::Module(dap::ModuleEvent { + reason: dap::ModuleEventReason::Changed, + module: changed_module.clone(), + })) + .await; + + cx.run_until_parked(); + + active_debug_session_panel(workspace, cx).update(cx, |_, cx| { + let actual_modules = running_state.update(cx, |state, cx| { + state.module_list().update(cx, |list, cx| list.modules(cx)) + }); + + assert_eq!(actual_modules.len(), 3); + assert!(actual_modules.contains(&changed_module)); + }); + + client + .fake_event(dap::messages::Events::Module(dap::ModuleEvent { + reason: dap::ModuleEventReason::Removed, + module: changed_module.clone(), + })) + .await; + + cx.run_until_parked(); + + active_debug_session_panel(workspace, cx).update(cx, |_, cx| { + let actual_modules = running_state.update(cx, |state, cx| { + state.module_list().update(cx, |list, cx| list.modules(cx)) + }); + + assert_eq!(actual_modules.len(), 2); + assert!(!actual_modules.contains(&changed_module)); + }); + + 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(); +} diff --git a/crates/debugger_ui/src/tests/stack_frame_list.rs b/crates/debugger_ui/src/tests/stack_frame_list.rs new file mode 100644 index 0000000000..7edfa01b26 --- /dev/null +++ b/crates/debugger_ui/src/tests/stack_frame_list.rs @@ -0,0 +1,845 @@ +use crate::{ + debugger_panel::DebugPanel, + session::running::stack_frame_list::StackFrameEntry, + tests::{active_debug_session_panel, init_test, init_test_workspace}, +}; +use dap::{ + requests::{StackTrace, Threads}, + StackFrame, +}; +use editor::{Editor, ToPoint as _}; +use gpui::{BackgroundExecutor, TestAppContext, VisualTestContext}; +use project::{FakeFs, Project}; +use serde_json::json; +use std::sync::Arc; +use unindent::Unindent as _; +use util::path; + +#[gpui::test] +async fn test_fetch_initial_stack_frames_and_go_to_stack_frame( + executor: BackgroundExecutor, + cx: &mut TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(executor.clone()); + + let test_file_content = r#" + import { SOME_VALUE } './module.js'; + + console.log(SOME_VALUE); + "# + .unindent(); + + let module_file_content = r#" + export SOME_VALUE = 'some value'; + "# + .unindent(); + + fs.insert_tree( + path!("/project"), + json!({ + "src": { + "test.js": test_file_content, + "module.js": module_file_content, + } + }), + ) + .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 task = project.update(cx, |project, cx| { + project.start_debug_session( + dap::test_config(dap::DebugRequestType::Launch, None, None), + cx, + ) + }); + + let session = task.await.unwrap(); + let client = session.update(cx, |session, _| session.adapter_client().unwrap()); + + client + .on_request::(move |_, _| { + Ok(dap::ThreadsResponse { + threads: vec![dap::Thread { + id: 1, + name: "Thread 1".into(), + }], + }) + }) + .await; + + let stack_frames = vec![ + StackFrame { + id: 1, + name: "Stack Frame 1".into(), + source: Some(dap::Source { + name: Some("test.js".into()), + path: Some(path!("/project/src/test.js").into()), + source_reference: None, + presentation_hint: None, + origin: None, + sources: None, + adapter_data: None, + checksums: None, + }), + line: 3, + column: 1, + end_line: None, + end_column: None, + can_restart: None, + instruction_pointer_reference: None, + module_id: None, + presentation_hint: None, + }, + StackFrame { + id: 2, + name: "Stack Frame 2".into(), + source: Some(dap::Source { + name: Some("module.js".into()), + path: Some(path!("/project/src/module.js").into()), + source_reference: None, + presentation_hint: None, + origin: None, + sources: None, + adapter_data: None, + checksums: None, + }), + line: 1, + column: 1, + end_line: None, + end_column: None, + can_restart: None, + instruction_pointer_reference: None, + module_id: None, + presentation_hint: None, + }, + ]; + + client + .on_request::({ + let stack_frames = Arc::new(stack_frames.clone()); + move |_, args| { + assert_eq!(1, args.thread_id); + + Ok(dap::StackTraceResponse { + stack_frames: (*stack_frames).clone(), + total_frames: None, + }) + } + }) + .await; + + client + .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { + reason: dap::StoppedEventReason::Pause, + 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(); + + // trigger to load threads + active_debug_session_panel(workspace, cx).update(cx, |session, cx| { + session + .mode() + .as_running() + .unwrap() + .update(cx, |running_state, cx| { + running_state + .session() + .update(cx, |session, cx| session.threads(cx)); + }); + }); + + cx.run_until_parked(); + + // select first thread + active_debug_session_panel(workspace, cx).update_in(cx, |session, _, cx| { + session + .mode() + .as_running() + .unwrap() + .update(cx, |running_state, cx| { + running_state.select_current_thread( + &running_state + .session() + .update(cx, |session, cx| session.threads(cx)), + cx, + ); + }); + }); + + cx.run_until_parked(); + + active_debug_session_panel(workspace, cx).update(cx, |session, cx| { + let stack_frame_list = session + .mode() + .as_running() + .unwrap() + .update(cx, |state, _| state.stack_frame_list().clone()); + + stack_frame_list.update(cx, |stack_frame_list, cx| { + assert_eq!(Some(1), stack_frame_list.current_stack_frame_id()); + assert_eq!(stack_frames, stack_frame_list.dap_stack_frames(cx)); + }); + }); + + 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_select_stack_frame(executor: BackgroundExecutor, cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(executor.clone()); + + let test_file_content = r#" + import { SOME_VALUE } './module.js'; + + console.log(SOME_VALUE); + "# + .unindent(); + + let module_file_content = r#" + export SOME_VALUE = 'some value'; + "# + .unindent(); + + fs.insert_tree( + path!("/project"), + json!({ + "src": { + "test.js": test_file_content, + "module.js": module_file_content, + } + }), + ) + .await; + + let project = Project::test(fs, [path!("/project").as_ref()], cx).await; + let workspace = init_test_workspace(&project, cx).await; + let _ = workspace.update(cx, |workspace, window, cx| { + workspace.toggle_dock(workspace::dock::DockPosition::Bottom, window, cx); + }); + + let cx = &mut VisualTestContext::from_window(*workspace, cx); + + let task = project.update(cx, |project, cx| { + project.start_debug_session( + dap::test_config(dap::DebugRequestType::Launch, None, None), + cx, + ) + }); + + let session = task.await.unwrap(); + let client = session.update(cx, |session, _| session.adapter_client().unwrap()); + + client + .on_request::(move |_, _| { + Ok(dap::ThreadsResponse { + threads: vec![dap::Thread { + id: 1, + name: "Thread 1".into(), + }], + }) + }) + .await; + + let stack_frames = vec![ + StackFrame { + id: 1, + name: "Stack Frame 1".into(), + source: Some(dap::Source { + name: Some("test.js".into()), + path: Some(path!("/project/src/test.js").into()), + source_reference: None, + presentation_hint: None, + origin: None, + sources: None, + adapter_data: None, + checksums: None, + }), + line: 3, + column: 1, + end_line: None, + end_column: None, + can_restart: None, + instruction_pointer_reference: None, + module_id: None, + presentation_hint: None, + }, + StackFrame { + id: 2, + name: "Stack Frame 2".into(), + source: Some(dap::Source { + name: Some("module.js".into()), + path: Some(path!("/project/src/module.js").into()), + source_reference: None, + presentation_hint: None, + origin: None, + sources: None, + adapter_data: None, + checksums: None, + }), + line: 1, + column: 1, + end_line: None, + end_column: None, + can_restart: None, + instruction_pointer_reference: None, + module_id: None, + presentation_hint: None, + }, + ]; + + client + .on_request::({ + let stack_frames = Arc::new(stack_frames.clone()); + move |_, args| { + assert_eq!(1, args.thread_id); + + Ok(dap::StackTraceResponse { + stack_frames: (*stack_frames).clone(), + total_frames: None, + }) + } + }) + .await; + + client + .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { + reason: dap::StoppedEventReason::Pause, + 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(); + + // trigger threads to load + active_debug_session_panel(workspace, cx).update(cx, |session, cx| { + session + .mode() + .as_running() + .unwrap() + .update(cx, |running_state, cx| { + running_state + .session() + .update(cx, |session, cx| session.threads(cx)); + }); + }); + + cx.run_until_parked(); + + // select first thread + active_debug_session_panel(workspace, cx).update_in(cx, |session, _, cx| { + session + .mode() + .as_running() + .unwrap() + .update(cx, |running_state, cx| { + running_state.select_current_thread( + &running_state + .session() + .update(cx, |session, cx| session.threads(cx)), + cx, + ); + }); + }); + + cx.run_until_parked(); + + workspace + .update(cx, |workspace, window, cx| { + let editors = workspace.items_of_type::(cx).collect::>(); + assert_eq!(1, editors.len()); + + let project_path = editors[0] + .update(cx, |editor, cx| editor.project_path(cx)) + .unwrap(); + let expected = if cfg!(target_os = "windows") { + "src\\test.js" + } else { + "src/test.js" + }; + assert_eq!(expected, project_path.path.to_string_lossy()); + assert_eq!(test_file_content, editors[0].read(cx).text(cx)); + assert_eq!( + vec![2..3], + editors[0].update(cx, |editor, cx| { + let snapshot = editor.snapshot(window, cx); + + editor + .highlighted_rows::() + .map(|(range, _)| { + let start = range.start.to_point(&snapshot.buffer_snapshot); + let end = range.end.to_point(&snapshot.buffer_snapshot); + start.row..end.row + }) + .collect::>() + }) + ); + }) + .unwrap(); + + let stack_frame_list = workspace + .update(cx, |workspace, _window, cx| { + let debug_panel = workspace.panel::(cx).unwrap(); + let active_debug_panel_item = debug_panel + .update(cx, |this, cx| this.active_session(cx)) + .unwrap(); + + active_debug_panel_item + .read(cx) + .mode() + .as_running() + .unwrap() + .read(cx) + .stack_frame_list() + .clone() + }) + .unwrap(); + + stack_frame_list.update(cx, |stack_frame_list, cx| { + assert_eq!(Some(1), stack_frame_list.current_stack_frame_id()); + assert_eq!(stack_frames, stack_frame_list.dap_stack_frames(cx)); + }); + + // select second stack frame + stack_frame_list + .update_in(cx, |stack_frame_list, window, cx| { + stack_frame_list.select_stack_frame(&stack_frames[1], true, window, cx) + }) + .await + .unwrap(); + + cx.run_until_parked(); + + stack_frame_list.update(cx, |stack_frame_list, cx| { + assert_eq!(Some(2), stack_frame_list.current_stack_frame_id()); + assert_eq!(stack_frames, stack_frame_list.dap_stack_frames(cx)); + }); + + let _ = workspace.update(cx, |workspace, window, cx| { + let editors = workspace.items_of_type::(cx).collect::>(); + assert_eq!(1, editors.len()); + + let project_path = editors[0] + .update(cx, |editor, cx| editor.project_path(cx)) + .unwrap(); + let expected = if cfg!(target_os = "windows") { + "src\\module.js" + } else { + "src/module.js" + }; + assert_eq!(expected, project_path.path.to_string_lossy()); + assert_eq!(module_file_content, editors[0].read(cx).text(cx)); + assert_eq!( + vec![0..1], + editors[0].update(cx, |editor, cx| { + let snapshot = editor.snapshot(window, cx); + + editor + .highlighted_rows::() + .map(|(range, _)| { + let start = range.start.to_point(&snapshot.buffer_snapshot); + let end = range.end.to_point(&snapshot.buffer_snapshot); + start.row..end.row + }) + .collect::>() + }) + ); + }); + + 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_collapsed_entries(executor: BackgroundExecutor, cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(executor.clone()); + + let test_file_content = r#" + import { SOME_VALUE } './module.js'; + + console.log(SOME_VALUE); + "# + .unindent(); + + let module_file_content = r#" + export SOME_VALUE = 'some value'; + "# + .unindent(); + + fs.insert_tree( + path!("/project"), + json!({ + "src": { + "test.js": test_file_content, + "module.js": module_file_content, + } + }), + ) + .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 task = project.update(cx, |project, cx| { + project.start_debug_session( + dap::test_config(dap::DebugRequestType::Launch, None, None), + cx, + ) + }); + + let session = task.await.unwrap(); + let client = session.update(cx, |session, _| session.adapter_client().unwrap()); + + client + .on_request::(move |_, _| { + Ok(dap::ThreadsResponse { + threads: vec![dap::Thread { + id: 1, + name: "Thread 1".into(), + }], + }) + }) + .await; + + let stack_frames = vec![ + StackFrame { + id: 1, + name: "Stack Frame 1".into(), + source: Some(dap::Source { + name: Some("test.js".into()), + path: Some(path!("/project/src/test.js").into()), + source_reference: None, + presentation_hint: None, + origin: None, + sources: None, + adapter_data: None, + checksums: None, + }), + line: 3, + column: 1, + end_line: None, + end_column: None, + can_restart: None, + instruction_pointer_reference: None, + module_id: None, + presentation_hint: None, + }, + StackFrame { + id: 2, + name: "Stack Frame 2".into(), + source: Some(dap::Source { + name: Some("module.js".into()), + path: Some(path!("/project/src/module.js").into()), + source_reference: None, + presentation_hint: None, + origin: Some("ignored".into()), + sources: None, + adapter_data: None, + checksums: None, + }), + line: 1, + column: 1, + end_line: None, + end_column: None, + can_restart: None, + instruction_pointer_reference: None, + module_id: None, + presentation_hint: Some(dap::StackFramePresentationHint::Deemphasize), + }, + StackFrame { + id: 3, + name: "Stack Frame 3".into(), + source: Some(dap::Source { + name: Some("module.js".into()), + path: Some(path!("/project/src/module.js").into()), + source_reference: None, + presentation_hint: None, + origin: Some("ignored".into()), + sources: None, + adapter_data: None, + checksums: None, + }), + line: 1, + column: 1, + end_line: None, + end_column: None, + can_restart: None, + instruction_pointer_reference: None, + module_id: None, + presentation_hint: Some(dap::StackFramePresentationHint::Deemphasize), + }, + StackFrame { + id: 4, + name: "Stack Frame 4".into(), + source: Some(dap::Source { + name: Some("module.js".into()), + path: Some(path!("/project/src/module.js").into()), + source_reference: None, + presentation_hint: None, + origin: None, + sources: None, + adapter_data: None, + checksums: None, + }), + line: 1, + column: 1, + end_line: None, + end_column: None, + can_restart: None, + instruction_pointer_reference: None, + module_id: None, + presentation_hint: None, + }, + StackFrame { + id: 5, + name: "Stack Frame 5".into(), + source: Some(dap::Source { + name: Some("module.js".into()), + path: Some(path!("/project/src/module.js").into()), + source_reference: None, + presentation_hint: None, + origin: None, + sources: None, + adapter_data: None, + checksums: None, + }), + line: 1, + column: 1, + end_line: None, + end_column: None, + can_restart: None, + instruction_pointer_reference: None, + module_id: None, + presentation_hint: Some(dap::StackFramePresentationHint::Deemphasize), + }, + StackFrame { + id: 6, + name: "Stack Frame 6".into(), + source: Some(dap::Source { + name: Some("module.js".into()), + path: Some(path!("/project/src/module.js").into()), + source_reference: None, + presentation_hint: None, + origin: None, + sources: None, + adapter_data: None, + checksums: None, + }), + line: 1, + column: 1, + end_line: None, + end_column: None, + can_restart: None, + instruction_pointer_reference: None, + module_id: None, + presentation_hint: Some(dap::StackFramePresentationHint::Deemphasize), + }, + StackFrame { + id: 7, + name: "Stack Frame 7".into(), + source: Some(dap::Source { + name: Some("module.js".into()), + path: Some(path!("/project/src/module.js").into()), + source_reference: None, + presentation_hint: None, + origin: None, + sources: None, + adapter_data: None, + checksums: None, + }), + line: 1, + column: 1, + end_line: None, + end_column: None, + can_restart: None, + instruction_pointer_reference: None, + module_id: None, + presentation_hint: None, + }, + ]; + + client + .on_request::({ + let stack_frames = Arc::new(stack_frames.clone()); + move |_, args| { + assert_eq!(1, args.thread_id); + + Ok(dap::StackTraceResponse { + stack_frames: (*stack_frames).clone(), + total_frames: None, + }) + } + }) + .await; + + client + .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { + reason: dap::StoppedEventReason::Pause, + 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(); + + // trigger threads to load + active_debug_session_panel(workspace, cx).update(cx, |session, cx| { + session + .mode() + .as_running() + .unwrap() + .update(cx, |running_state, cx| { + running_state + .session() + .update(cx, |session, cx| session.threads(cx)); + }); + }); + + cx.run_until_parked(); + + // select first thread + active_debug_session_panel(workspace, cx).update_in(cx, |session, _, cx| { + session + .mode() + .as_running() + .unwrap() + .update(cx, |running_state, cx| { + running_state.select_current_thread( + &running_state + .session() + .update(cx, |session, cx| session.threads(cx)), + cx, + ); + }); + }); + + cx.run_until_parked(); + + // trigger stack frames to loaded + active_debug_session_panel(workspace, cx).update(cx, |debug_panel_item, cx| { + let stack_frame_list = debug_panel_item + .mode() + .as_running() + .unwrap() + .update(cx, |state, _| state.stack_frame_list().clone()); + + stack_frame_list.update(cx, |stack_frame_list, cx| { + stack_frame_list.dap_stack_frames(cx); + }); + }); + + cx.run_until_parked(); + + active_debug_session_panel(workspace, cx).update_in(cx, |debug_panel_item, window, cx| { + let stack_frame_list = debug_panel_item + .mode() + .as_running() + .unwrap() + .update(cx, |state, _| state.stack_frame_list().clone()); + + stack_frame_list.update(cx, |stack_frame_list, cx| { + stack_frame_list.build_entries(true, window, cx); + + assert_eq!( + &vec![ + StackFrameEntry::Normal(stack_frames[0].clone()), + StackFrameEntry::Collapsed(vec![ + stack_frames[1].clone(), + stack_frames[2].clone() + ]), + StackFrameEntry::Normal(stack_frames[3].clone()), + StackFrameEntry::Collapsed(vec![ + stack_frames[4].clone(), + stack_frames[5].clone() + ]), + StackFrameEntry::Normal(stack_frames[6].clone()), + ], + stack_frame_list.entries() + ); + + stack_frame_list.expand_collapsed_entry( + 1, + &vec![stack_frames[1].clone(), stack_frames[2].clone()], + cx, + ); + + assert_eq!( + &vec![ + StackFrameEntry::Normal(stack_frames[0].clone()), + StackFrameEntry::Normal(stack_frames[1].clone()), + StackFrameEntry::Normal(stack_frames[2].clone()), + StackFrameEntry::Normal(stack_frames[3].clone()), + StackFrameEntry::Collapsed(vec![ + stack_frames[4].clone(), + stack_frames[5].clone() + ]), + StackFrameEntry::Normal(stack_frames[6].clone()), + ], + stack_frame_list.entries() + ); + + stack_frame_list.expand_collapsed_entry( + 4, + &vec![stack_frames[4].clone(), stack_frames[5].clone()], + cx, + ); + + assert_eq!( + &vec![ + StackFrameEntry::Normal(stack_frames[0].clone()), + StackFrameEntry::Normal(stack_frames[1].clone()), + StackFrameEntry::Normal(stack_frames[2].clone()), + StackFrameEntry::Normal(stack_frames[3].clone()), + StackFrameEntry::Normal(stack_frames[4].clone()), + StackFrameEntry::Normal(stack_frames[5].clone()), + StackFrameEntry::Normal(stack_frames[6].clone()), + ], + stack_frame_list.entries() + ); + }); + }); + + 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(); +} diff --git a/crates/debugger_ui/src/tests/variable_list.rs b/crates/debugger_ui/src/tests/variable_list.rs new file mode 100644 index 0000000000..6c294447f0 --- /dev/null +++ b/crates/debugger_ui/src/tests/variable_list.rs @@ -0,0 +1,1759 @@ +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, +}; + +use crate::{ + session::running::variable_list::{CollapseSelectedEntry, ExpandSelectedEntry}, + tests::{active_debug_session_panel, init_test, init_test_workspace}, + DebugPanel, +}; +use collections::HashMap; +use dap::{ + requests::{Initialize, Launch, Scopes, StackTrace, Variables}, + Scope, StackFrame, Variable, +}; +use gpui::{BackgroundExecutor, TestAppContext, VisualTestContext}; +use menu::{SelectFirst, SelectNext, SelectPrevious}; +use project::{FakeFs, Project}; +use serde_json::json; +use unindent::Unindent as _; +use util::path; + +/// This only tests fetching one scope and 2 variables for a single stackframe +#[gpui::test] +async fn test_basic_fetch_initial_scope_and_variables( + executor: BackgroundExecutor, + cx: &mut TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(executor.clone()); + + let test_file_content = r#" + const variable1 = "Value 1"; + const variable2 = "Value 2"; + "# + .unindent(); + + fs.insert_tree( + path!("/project"), + json!({ + "src": { + "test.js": test_file_content, + } + }), + ) + .await; + + let project = Project::test(fs, [path!("/project").as_ref()], cx).await; + let workspace = init_test_workspace(&project, cx).await; + workspace + .update(cx, |workspace, window, cx| { + workspace.focus_panel::(window, cx); + }) + .unwrap(); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + + let task = project.update(cx, |project, cx| { + project.start_debug_session( + dap::test_config(dap::DebugRequestType::Launch, None, None), + cx, + ) + }); + + let session = task.await.unwrap(); + let client = session.update(cx, |session, _| session.adapter_client().unwrap()); + + client + .on_request::(move |_, _| { + Ok(dap::ThreadsResponse { + threads: vec![dap::Thread { + id: 1, + name: "Thread 1".into(), + }], + }) + }) + .await; + + let stack_frames = vec![StackFrame { + id: 1, + name: "Stack Frame 1".into(), + source: Some(dap::Source { + name: Some("test.js".into()), + path: Some(path!("/project/src/test.js").into()), + source_reference: None, + presentation_hint: None, + origin: None, + sources: None, + adapter_data: None, + checksums: None, + }), + line: 1, + column: 1, + end_line: None, + end_column: None, + can_restart: None, + instruction_pointer_reference: None, + module_id: None, + presentation_hint: None, + }]; + + client + .on_request::({ + let stack_frames = Arc::new(stack_frames.clone()); + move |_, args| { + assert_eq!(1, args.thread_id); + + Ok(dap::StackTraceResponse { + stack_frames: (*stack_frames).clone(), + total_frames: None, + }) + } + }) + .await; + + let scopes = vec![Scope { + name: "Scope 1".into(), + presentation_hint: None, + variables_reference: 2, + named_variables: None, + indexed_variables: None, + expensive: false, + source: None, + line: None, + column: None, + end_line: None, + end_column: None, + }]; + + client + .on_request::({ + let scopes = Arc::new(scopes.clone()); + move |_, args| { + assert_eq!(1, args.frame_id); + + Ok(dap::ScopesResponse { + scopes: (*scopes).clone(), + }) + } + }) + .await; + + let variables = vec![ + Variable { + name: "variable1".into(), + value: "value 1".into(), + type_: None, + presentation_hint: None, + evaluate_name: None, + variables_reference: 0, + named_variables: None, + indexed_variables: None, + memory_reference: None, + declaration_location_reference: None, + value_location_reference: None, + }, + Variable { + name: "variable2".into(), + value: "value 2".into(), + type_: None, + presentation_hint: None, + evaluate_name: None, + variables_reference: 0, + named_variables: None, + indexed_variables: None, + memory_reference: None, + declaration_location_reference: None, + value_location_reference: None, + }, + ]; + + client + .on_request::({ + let variables = Arc::new(variables.clone()); + move |_, args| { + assert_eq!(2, args.variables_reference); + + Ok(dap::VariablesResponse { + variables: (*variables).clone(), + }) + } + }) + .await; + + client + .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { + reason: dap::StoppedEventReason::Pause, + 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(); + + let running_state = + active_debug_session_panel(workspace, cx).update_in(cx, |item, window, cx| { + cx.focus_self(window); + item.mode() + .as_running() + .expect("Session should be running by this point") + .clone() + }); + + cx.run_until_parked(); + + running_state.update(cx, |running_state, cx| { + let (stack_frame_list, stack_frame_id) = + running_state.stack_frame_list().update(cx, |list, _| { + (list.flatten_entries(), list.current_stack_frame_id()) + }); + + assert_eq!(stack_frames, stack_frame_list); + assert_eq!(Some(1), stack_frame_id); + + running_state + .variable_list() + .update(cx, |variable_list, _| { + assert_eq!(1, variable_list.scopes().len()); + assert_eq!(scopes, variable_list.scopes()); + assert_eq!( + vec![variables[0].clone(), variables[1].clone(),], + variable_list.variables() + ); + + variable_list.assert_visual_entries(vec![ + "v Scope 1", + " > variable1", + " > variable2", + ]); + }); + }); + + 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(); +} + +/// This tests fetching multiple scopes and variables for them with a single stackframe +#[gpui::test] +async fn test_fetch_variables_for_multiple_scopes( + executor: BackgroundExecutor, + cx: &mut TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(executor.clone()); + + let test_file_content = r#" + const variable1 = { + nested1: "Nested 1", + nested2: "Nested 2", + }; + const variable2 = "Value 2"; + const variable3 = "Value 3"; + "# + .unindent(); + + fs.insert_tree( + path!("/project"), + json!({ + "src": { + "test.js": test_file_content, + } + }), + ) + .await; + + let project = Project::test(fs, [path!("/project").as_ref()], cx).await; + let workspace = init_test_workspace(&project, cx).await; + workspace + .update(cx, |workspace, window, cx| { + workspace.focus_panel::(window, cx); + }) + .unwrap(); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + + let task = project.update(cx, |project, cx| { + project.start_debug_session( + dap::test_config(dap::DebugRequestType::Launch, None, None), + cx, + ) + }); + + let session = task.await.unwrap(); + let client = session.update(cx, |session, _| session.adapter_client().unwrap()); + + client + .on_request::(move |_, _| { + Ok(dap::ThreadsResponse { + threads: vec![dap::Thread { + id: 1, + name: "Thread 1".into(), + }], + }) + }) + .await; + + client + .on_request::(move |_, _| { + Ok(dap::Capabilities { + supports_step_back: Some(false), + ..Default::default() + }) + }) + .await; + + client.on_request::(move |_, _| Ok(())).await; + + let stack_frames = vec![StackFrame { + id: 1, + name: "Stack Frame 1".into(), + source: Some(dap::Source { + name: Some("test.js".into()), + path: Some(path!("/project/src/test.js").into()), + source_reference: None, + presentation_hint: None, + origin: None, + sources: None, + adapter_data: None, + checksums: None, + }), + line: 1, + column: 1, + end_line: None, + end_column: None, + can_restart: None, + instruction_pointer_reference: None, + module_id: None, + presentation_hint: None, + }]; + + client + .on_request::({ + let stack_frames = Arc::new(stack_frames.clone()); + move |_, args| { + assert_eq!(1, args.thread_id); + + Ok(dap::StackTraceResponse { + stack_frames: (*stack_frames).clone(), + total_frames: None, + }) + } + }) + .await; + + let scopes = vec![ + Scope { + name: "Scope 1".into(), + presentation_hint: Some(dap::ScopePresentationHint::Locals), + variables_reference: 2, + named_variables: None, + indexed_variables: None, + expensive: false, + source: None, + line: None, + column: None, + end_line: None, + end_column: None, + }, + Scope { + name: "Scope 2".into(), + presentation_hint: None, + variables_reference: 3, + named_variables: None, + indexed_variables: None, + expensive: false, + source: None, + line: None, + column: None, + end_line: None, + end_column: None, + }, + ]; + + client + .on_request::({ + let scopes = Arc::new(scopes.clone()); + move |_, args| { + assert_eq!(1, args.frame_id); + + Ok(dap::ScopesResponse { + scopes: (*scopes).clone(), + }) + } + }) + .await; + + let mut variables = HashMap::default(); + variables.insert( + 2, + vec![ + Variable { + name: "variable1".into(), + value: "{nested1: \"Nested 1\", nested2: \"Nested 2\"}".into(), + type_: None, + presentation_hint: None, + evaluate_name: None, + variables_reference: 0, + named_variables: None, + indexed_variables: None, + memory_reference: None, + declaration_location_reference: None, + value_location_reference: None, + }, + Variable { + name: "variable2".into(), + value: "Value 2".into(), + type_: None, + presentation_hint: None, + evaluate_name: None, + variables_reference: 0, + named_variables: None, + indexed_variables: None, + memory_reference: None, + declaration_location_reference: None, + value_location_reference: None, + }, + ], + ); + variables.insert( + 3, + vec![Variable { + name: "variable3".into(), + value: "Value 3".into(), + type_: None, + presentation_hint: None, + evaluate_name: None, + variables_reference: 0, + named_variables: None, + indexed_variables: None, + memory_reference: None, + declaration_location_reference: None, + value_location_reference: None, + }], + ); + + client + .on_request::({ + let variables = Arc::new(variables.clone()); + move |_, args| { + Ok(dap::VariablesResponse { + variables: variables.get(&args.variables_reference).unwrap().clone(), + }) + } + }) + .await; + + client + .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { + reason: dap::StoppedEventReason::Pause, + 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(); + + let running_state = + active_debug_session_panel(workspace, cx).update_in(cx, |item, window, cx| { + cx.focus_self(window); + item.mode() + .as_running() + .expect("Session should be running by this point") + .clone() + }); + + cx.run_until_parked(); + + running_state.update(cx, |running_state, cx| { + let (stack_frame_list, stack_frame_id) = + running_state.stack_frame_list().update(cx, |list, _| { + (list.flatten_entries(), list.current_stack_frame_id()) + }); + + assert_eq!(Some(1), stack_frame_id); + assert_eq!(stack_frames, stack_frame_list); + + running_state + .variable_list() + .update(cx, |variable_list, _| { + assert_eq!(2, variable_list.scopes().len()); + assert_eq!(scopes, variable_list.scopes()); + let variables_by_scope = variable_list.variables_per_scope(); + + // scope 1 + assert_eq!( + vec![ + variables.get(&2).unwrap()[0].clone(), + variables.get(&2).unwrap()[1].clone(), + ], + variables_by_scope[0].1 + ); + + // scope 2 + let empty_vec: Vec = vec![]; + assert_eq!(empty_vec, variables_by_scope[1].1); + + variable_list.assert_visual_entries(vec![ + "v Scope 1", + " > variable1", + " > variable2", + "> Scope 2", + ]); + }); + }); + + 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(); +} + +// tests that toggling a variable will fetch its children and shows it +#[gpui::test] +async fn test_keyboard_navigation(executor: BackgroundExecutor, cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(executor.clone()); + + let test_file_content = r#" + const variable1 = { + nested1: "Nested 1", + nested2: "Nested 2", + }; + const variable2 = "Value 2"; + const variable3 = "Value 3"; + "# + .unindent(); + + fs.insert_tree( + path!("/project"), + json!({ + "src": { + "test.js": test_file_content, + } + }), + ) + .await; + + let project = Project::test(fs, [path!("/project").as_ref()], cx).await; + let workspace = init_test_workspace(&project, cx).await; + workspace + .update(cx, |workspace, window, cx| { + workspace.focus_panel::(window, cx); + }) + .unwrap(); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + + let task = project.update(cx, |project, cx| { + project.start_debug_session( + dap::test_config(dap::DebugRequestType::Launch, None, None), + cx, + ) + }); + + let session = task.await.unwrap(); + let client = session.update(cx, |session, _| session.adapter_client().unwrap()); + + client + .on_request::(move |_, _| { + Ok(dap::ThreadsResponse { + threads: vec![dap::Thread { + id: 1, + name: "Thread 1".into(), + }], + }) + }) + .await; + + client + .on_request::(move |_, _| { + Ok(dap::Capabilities { + supports_step_back: Some(false), + ..Default::default() + }) + }) + .await; + + client.on_request::(move |_, _| Ok(())).await; + + let stack_frames = vec![StackFrame { + id: 1, + name: "Stack Frame 1".into(), + source: Some(dap::Source { + name: Some("test.js".into()), + path: Some(path!("/project/src/test.js").into()), + source_reference: None, + presentation_hint: None, + origin: None, + sources: None, + adapter_data: None, + checksums: None, + }), + line: 1, + column: 1, + end_line: None, + end_column: None, + can_restart: None, + instruction_pointer_reference: None, + module_id: None, + presentation_hint: None, + }]; + + client + .on_request::({ + let stack_frames = Arc::new(stack_frames.clone()); + move |_, args| { + assert_eq!(1, args.thread_id); + + Ok(dap::StackTraceResponse { + stack_frames: (*stack_frames).clone(), + total_frames: None, + }) + } + }) + .await; + + let scopes = vec![ + Scope { + name: "Scope 1".into(), + presentation_hint: Some(dap::ScopePresentationHint::Locals), + variables_reference: 2, + named_variables: None, + indexed_variables: None, + expensive: false, + source: None, + line: None, + column: None, + end_line: None, + end_column: None, + }, + Scope { + name: "Scope 2".into(), + presentation_hint: None, + variables_reference: 4, + named_variables: None, + indexed_variables: None, + expensive: false, + source: None, + line: None, + column: None, + end_line: None, + end_column: None, + }, + ]; + + client + .on_request::({ + let scopes = Arc::new(scopes.clone()); + move |_, args| { + assert_eq!(1, args.frame_id); + + Ok(dap::ScopesResponse { + scopes: (*scopes).clone(), + }) + } + }) + .await; + + let scope1_variables = vec![ + Variable { + name: "variable1".into(), + value: "{nested1: \"Nested 1\", nested2: \"Nested 2\"}".into(), + type_: None, + presentation_hint: None, + evaluate_name: None, + variables_reference: 3, + named_variables: None, + indexed_variables: None, + memory_reference: None, + declaration_location_reference: None, + value_location_reference: None, + }, + Variable { + name: "variable2".into(), + value: "Value 2".into(), + type_: None, + presentation_hint: None, + evaluate_name: None, + variables_reference: 0, + named_variables: None, + indexed_variables: None, + memory_reference: None, + declaration_location_reference: None, + value_location_reference: None, + }, + ]; + + let nested_variables = vec![ + Variable { + name: "nested1".into(), + value: "Nested 1".into(), + type_: None, + presentation_hint: None, + evaluate_name: None, + variables_reference: 0, + named_variables: None, + indexed_variables: None, + memory_reference: None, + declaration_location_reference: None, + value_location_reference: None, + }, + Variable { + name: "nested2".into(), + value: "Nested 2".into(), + type_: None, + presentation_hint: None, + evaluate_name: None, + variables_reference: 0, + named_variables: None, + indexed_variables: None, + memory_reference: None, + declaration_location_reference: None, + value_location_reference: None, + }, + ]; + + let scope2_variables = vec![Variable { + name: "variable3".into(), + value: "Value 3".into(), + type_: None, + presentation_hint: None, + evaluate_name: None, + variables_reference: 0, + named_variables: None, + indexed_variables: None, + memory_reference: None, + declaration_location_reference: None, + value_location_reference: None, + }]; + + client + .on_request::({ + let scope1_variables = Arc::new(scope1_variables.clone()); + let nested_variables = Arc::new(nested_variables.clone()); + let scope2_variables = Arc::new(scope2_variables.clone()); + move |_, args| match args.variables_reference { + 4 => Ok(dap::VariablesResponse { + variables: (*scope2_variables).clone(), + }), + 3 => Ok(dap::VariablesResponse { + variables: (*nested_variables).clone(), + }), + 2 => Ok(dap::VariablesResponse { + variables: (*scope1_variables).clone(), + }), + id => unreachable!("unexpected variables reference {id}"), + } + }) + .await; + + client + .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { + reason: dap::StoppedEventReason::Pause, + 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(); + let running_state = + active_debug_session_panel(workspace, cx).update_in(cx, |item, window, cx| { + cx.focus_self(window); + let running = item + .mode() + .as_running() + .expect("Session should be running by this point") + .clone(); + + let variable_list = running.read_with(cx, |state, _| state.variable_list().clone()); + variable_list.update(cx, |_, cx| cx.focus_self(window)); + running + }); + + cx.dispatch_action(SelectFirst); + cx.run_until_parked(); + + running_state.update(cx, |running_state, cx| { + running_state + .variable_list() + .update(cx, |variable_list, _| { + variable_list.assert_visual_entries(vec![ + "v Scope 1 <=== selected", + " > variable1", + " > variable2", + "> Scope 2", + ]); + }); + }); + + cx.dispatch_action(SelectNext); + cx.run_until_parked(); + + running_state.update(cx, |running_state, cx| { + running_state + .variable_list() + .update(cx, |variable_list, _| { + variable_list.assert_visual_entries(vec![ + "v Scope 1", + " > variable1 <=== selected", + " > variable2", + "> Scope 2", + ]); + }); + }); + + // expand the nested variables of variable 1 + cx.dispatch_action(ExpandSelectedEntry); + cx.run_until_parked(); + running_state.update(cx, |running_state, cx| { + running_state + .variable_list() + .update(cx, |variable_list, _| { + variable_list.assert_visual_entries(vec![ + "v Scope 1", + " v variable1 <=== selected", + " > nested1", + " > nested2", + " > variable2", + "> Scope 2", + ]); + }); + }); + + // select the first nested variable of variable 1 + cx.dispatch_action(SelectNext); + cx.run_until_parked(); + running_state.update(cx, |debug_panel_item, cx| { + debug_panel_item + .variable_list() + .update(cx, |variable_list, _| { + variable_list.assert_visual_entries(vec![ + "v Scope 1", + " v variable1", + " > nested1 <=== selected", + " > nested2", + " > variable2", + "> Scope 2", + ]); + }); + }); + + // select the second nested variable of variable 1 + cx.dispatch_action(SelectNext); + cx.run_until_parked(); + running_state.update(cx, |debug_panel_item, cx| { + debug_panel_item + .variable_list() + .update(cx, |variable_list, _| { + variable_list.assert_visual_entries(vec![ + "v Scope 1", + " v variable1", + " > nested1", + " > nested2 <=== selected", + " > variable2", + "> Scope 2", + ]); + }); + }); + + // select variable 2 of scope 1 + cx.dispatch_action(SelectNext); + cx.run_until_parked(); + running_state.update(cx, |debug_panel_item, cx| { + debug_panel_item + .variable_list() + .update(cx, |variable_list, _| { + variable_list.assert_visual_entries(vec![ + "v Scope 1", + " v variable1", + " > nested1", + " > nested2", + " > variable2 <=== selected", + "> Scope 2", + ]); + }); + }); + + // select scope 2 + cx.dispatch_action(SelectNext); + cx.run_until_parked(); + running_state.update(cx, |debug_panel_item, cx| { + debug_panel_item + .variable_list() + .update(cx, |variable_list, _| { + variable_list.assert_visual_entries(vec![ + "v Scope 1", + " v variable1", + " > nested1", + " > nested2", + " > variable2", + "> Scope 2 <=== selected", + ]); + }); + }); + + // expand the nested variables of scope 2 + cx.dispatch_action(ExpandSelectedEntry); + cx.run_until_parked(); + running_state.update(cx, |debug_panel_item, cx| { + debug_panel_item + .variable_list() + .update(cx, |variable_list, _| { + variable_list.assert_visual_entries(vec![ + "v Scope 1", + " v variable1", + " > nested1", + " > nested2", + " > variable2", + "v Scope 2 <=== selected", + " > variable3", + ]); + }); + }); + + // select variable 3 of scope 2 + cx.dispatch_action(SelectNext); + cx.run_until_parked(); + running_state.update(cx, |debug_panel_item, cx| { + debug_panel_item + .variable_list() + .update(cx, |variable_list, _| { + variable_list.assert_visual_entries(vec![ + "v Scope 1", + " v variable1", + " > nested1", + " > nested2", + " > variable2", + "v Scope 2", + " > variable3 <=== selected", + ]); + }); + }); + + // select scope 2 + cx.dispatch_action(SelectPrevious); + cx.run_until_parked(); + running_state.update(cx, |debug_panel_item, cx| { + debug_panel_item + .variable_list() + .update(cx, |variable_list, _| { + variable_list.assert_visual_entries(vec![ + "v Scope 1", + " v variable1", + " > nested1", + " > nested2", + " > variable2", + "v Scope 2 <=== selected", + " > variable3", + ]); + }); + }); + + // collapse variables of scope 2 + cx.dispatch_action(CollapseSelectedEntry); + cx.run_until_parked(); + running_state.update(cx, |debug_panel_item, cx| { + debug_panel_item + .variable_list() + .update(cx, |variable_list, _| { + variable_list.assert_visual_entries(vec![ + "v Scope 1", + " v variable1", + " > nested1", + " > nested2", + " > variable2", + "> Scope 2 <=== selected", + ]); + }); + }); + + // select variable 2 of scope 1 + cx.dispatch_action(SelectPrevious); + cx.run_until_parked(); + running_state.update(cx, |debug_panel_item, cx| { + debug_panel_item + .variable_list() + .update(cx, |variable_list, _| { + variable_list.assert_visual_entries(vec![ + "v Scope 1", + " v variable1", + " > nested1", + " > nested2", + " > variable2 <=== selected", + "> Scope 2", + ]); + }); + }); + + // select nested2 of variable 1 + cx.dispatch_action(SelectPrevious); + cx.run_until_parked(); + running_state.update(cx, |debug_panel_item, cx| { + debug_panel_item + .variable_list() + .update(cx, |variable_list, _| { + variable_list.assert_visual_entries(vec![ + "v Scope 1", + " v variable1", + " > nested1", + " > nested2 <=== selected", + " > variable2", + "> Scope 2", + ]); + }); + }); + + // select nested1 of variable 1 + cx.dispatch_action(SelectPrevious); + cx.run_until_parked(); + running_state.update(cx, |debug_panel_item, cx| { + debug_panel_item + .variable_list() + .update(cx, |variable_list, _| { + variable_list.assert_visual_entries(vec![ + "v Scope 1", + " v variable1", + " > nested1 <=== selected", + " > nested2", + " > variable2", + "> Scope 2", + ]); + }); + }); + + // select variable 1 of scope 1 + cx.dispatch_action(SelectPrevious); + cx.run_until_parked(); + running_state.update(cx, |debug_panel_item, cx| { + debug_panel_item + .variable_list() + .update(cx, |variable_list, _| { + variable_list.assert_visual_entries(vec![ + "v Scope 1", + " v variable1 <=== selected", + " > nested1", + " > nested2", + " > variable2", + "> Scope 2", + ]); + }); + }); + + // collapse variables of variable 1 + cx.dispatch_action(CollapseSelectedEntry); + cx.run_until_parked(); + running_state.update(cx, |debug_panel_item, cx| { + debug_panel_item + .variable_list() + .update(cx, |variable_list, _| { + variable_list.assert_visual_entries(vec![ + "v Scope 1", + " > variable1 <=== selected", + " > variable2", + "> Scope 2", + ]); + }); + }); + + // select scope 1 + cx.dispatch_action(SelectPrevious); + cx.run_until_parked(); + running_state.update(cx, |running_state, cx| { + running_state + .variable_list() + .update(cx, |variable_list, _| { + variable_list.assert_visual_entries(vec![ + "v Scope 1 <=== selected", + " > variable1", + " > variable2", + "> Scope 2", + ]); + }); + }); + + // collapse variables of scope 1 + cx.dispatch_action(CollapseSelectedEntry); + cx.run_until_parked(); + running_state.update(cx, |debug_panel_item, cx| { + debug_panel_item + .variable_list() + .update(cx, |variable_list, _| { + variable_list.assert_visual_entries(vec!["> Scope 1 <=== selected", "> Scope 2"]); + }); + }); + + 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_variable_list_only_sends_requests_when_rendering( + executor: BackgroundExecutor, + cx: &mut TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(executor.clone()); + + let test_file_content = r#" + import { SOME_VALUE } './module.js'; + + console.log(SOME_VALUE); + "# + .unindent(); + + let module_file_content = r#" + export SOME_VALUE = 'some value'; + "# + .unindent(); + + fs.insert_tree( + path!("/project"), + json!({ + "src": { + "test.js": test_file_content, + "module.js": module_file_content, + } + }), + ) + .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 task = project.update(cx, |project, cx| { + project.start_debug_session( + dap::test_config(dap::DebugRequestType::Launch, None, None), + cx, + ) + }); + + let session = task.await.unwrap(); + let client = session.update(cx, |session, _| session.adapter_client().unwrap()); + + client + .on_request::(move |_, _| { + Ok(dap::ThreadsResponse { + threads: vec![dap::Thread { + id: 1, + name: "Thread 1".into(), + }], + }) + }) + .await; + + client + .on_request::(move |_, _| { + Ok(dap::Capabilities { + supports_step_back: Some(false), + ..Default::default() + }) + }) + .await; + + client.on_request::(move |_, _| Ok(())).await; + + let stack_frames = vec![ + StackFrame { + id: 1, + name: "Stack Frame 1".into(), + source: Some(dap::Source { + name: Some("test.js".into()), + path: Some(path!("/project/src/test.js").into()), + source_reference: None, + presentation_hint: None, + origin: None, + sources: None, + adapter_data: None, + checksums: None, + }), + line: 3, + column: 1, + end_line: None, + end_column: None, + can_restart: None, + instruction_pointer_reference: None, + module_id: None, + presentation_hint: None, + }, + StackFrame { + id: 2, + name: "Stack Frame 2".into(), + source: Some(dap::Source { + name: Some("module.js".into()), + path: Some(path!("/project/src/module.js").into()), + source_reference: None, + presentation_hint: None, + origin: None, + sources: None, + adapter_data: None, + checksums: None, + }), + line: 1, + column: 1, + end_line: None, + end_column: None, + can_restart: None, + instruction_pointer_reference: None, + module_id: None, + presentation_hint: None, + }, + ]; + + client + .on_request::({ + let stack_frames = Arc::new(stack_frames.clone()); + move |_, args| { + assert_eq!(1, args.thread_id); + + Ok(dap::StackTraceResponse { + stack_frames: (*stack_frames).clone(), + total_frames: None, + }) + } + }) + .await; + + let frame_1_scopes = vec![Scope { + name: "Frame 1 Scope 1".into(), + presentation_hint: None, + variables_reference: 2, + named_variables: None, + indexed_variables: None, + expensive: false, + source: None, + line: None, + column: None, + end_line: None, + end_column: None, + }]; + + let made_scopes_request = Arc::new(AtomicBool::new(false)); + + client + .on_request::({ + let frame_1_scopes = Arc::new(frame_1_scopes.clone()); + let made_scopes_request = made_scopes_request.clone(); + move |_, args| { + assert_eq!(1, args.frame_id); + assert!( + !made_scopes_request.load(Ordering::SeqCst), + "We should be caching the scope request" + ); + + made_scopes_request.store(true, Ordering::SeqCst); + + Ok(dap::ScopesResponse { + scopes: (*frame_1_scopes).clone(), + }) + } + }) + .await; + + let frame_1_variables = vec![ + Variable { + name: "variable1".into(), + value: "value 1".into(), + type_: None, + presentation_hint: None, + evaluate_name: None, + variables_reference: 0, + named_variables: None, + indexed_variables: None, + memory_reference: None, + declaration_location_reference: None, + value_location_reference: None, + }, + Variable { + name: "variable2".into(), + value: "value 2".into(), + type_: None, + presentation_hint: None, + evaluate_name: None, + variables_reference: 0, + named_variables: None, + indexed_variables: None, + memory_reference: None, + declaration_location_reference: None, + value_location_reference: None, + }, + ]; + + client + .on_request::({ + let frame_1_variables = Arc::new(frame_1_variables.clone()); + move |_, args| { + assert_eq!(2, args.variables_reference); + + Ok(dap::VariablesResponse { + variables: (*frame_1_variables).clone(), + }) + } + }) + .await; + + let running_state = active_debug_session_panel(workspace, cx).update_in(cx, |item, _, cx| { + let state = item + .mode() + .as_running() + .expect("Session should be running by this point") + .clone(); + + state.update(cx, |state, cx| { + state.set_thread_item(crate::session::ThreadItem::Modules, cx) + }); + state + }); + + client + .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { + reason: dap::StoppedEventReason::Pause, + 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(); + + // We shouldn't make any variable requests unless we're rendering the variable list + running_state.update_in(cx, |running_state, window, cx| { + let variable_list = running_state.variable_list().read(cx); + let empty: Vec = vec![]; + + assert_eq!(empty, variable_list.variables()); + assert!(!made_scopes_request.load(Ordering::SeqCst)); + + cx.focus_self(window); + running_state.set_thread_item(crate::session::ThreadItem::Variables, cx); + }); + + cx.run_until_parked(); + + running_state.update(cx, |running_state, cx| { + let (stack_frame_list, stack_frame_id) = + running_state.stack_frame_list().update(cx, |list, _| { + (list.flatten_entries(), list.current_stack_frame_id()) + }); + + assert_eq!(Some(1), stack_frame_id); + assert_eq!(stack_frames, stack_frame_list); + + let variable_list = running_state.variable_list().read(cx); + + assert_eq!(frame_1_variables, variable_list.variables()); + assert!(made_scopes_request.load(Ordering::SeqCst)); + }); + + 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_it_fetches_scopes_variables_when_you_select_a_stack_frame( + executor: BackgroundExecutor, + cx: &mut TestAppContext, +) { + init_test(cx); + + let fs = FakeFs::new(executor.clone()); + + let test_file_content = r#" + import { SOME_VALUE } './module.js'; + + console.log(SOME_VALUE); + "# + .unindent(); + + let module_file_content = r#" + export SOME_VALUE = 'some value'; + "# + .unindent(); + + fs.insert_tree( + path!("/project"), + json!({ + "src": { + "test.js": test_file_content, + "module.js": module_file_content, + } + }), + ) + .await; + + let project = Project::test(fs, [path!("/project").as_ref()], cx).await; + let workspace = init_test_workspace(&project, cx).await; + workspace + .update(cx, |workspace, window, cx| { + workspace.focus_panel::(window, cx); + }) + .unwrap(); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + + let task = project.update(cx, |project, cx| { + project.start_debug_session( + dap::test_config(dap::DebugRequestType::Launch, None, None), + cx, + ) + }); + + let session = task.await.unwrap(); + let client = session.update(cx, |session, _| session.adapter_client().unwrap()); + + client + .on_request::(move |_, _| { + Ok(dap::ThreadsResponse { + threads: vec![dap::Thread { + id: 1, + name: "Thread 1".into(), + }], + }) + }) + .await; + + client + .on_request::(move |_, _| { + Ok(dap::Capabilities { + supports_step_back: Some(false), + ..Default::default() + }) + }) + .await; + + client.on_request::(move |_, _| Ok(())).await; + + let stack_frames = vec![ + StackFrame { + id: 1, + name: "Stack Frame 1".into(), + source: Some(dap::Source { + name: Some("test.js".into()), + path: Some(path!("/project/src/test.js").into()), + source_reference: None, + presentation_hint: None, + origin: None, + sources: None, + adapter_data: None, + checksums: None, + }), + line: 3, + column: 1, + end_line: None, + end_column: None, + can_restart: None, + instruction_pointer_reference: None, + module_id: None, + presentation_hint: None, + }, + StackFrame { + id: 2, + name: "Stack Frame 2".into(), + source: Some(dap::Source { + name: Some("module.js".into()), + path: Some(path!("/project/src/module.js").into()), + source_reference: None, + presentation_hint: None, + origin: None, + sources: None, + adapter_data: None, + checksums: None, + }), + line: 1, + column: 1, + end_line: None, + end_column: None, + can_restart: None, + instruction_pointer_reference: None, + module_id: None, + presentation_hint: None, + }, + ]; + + client + .on_request::({ + let stack_frames = Arc::new(stack_frames.clone()); + move |_, args| { + assert_eq!(1, args.thread_id); + + Ok(dap::StackTraceResponse { + stack_frames: (*stack_frames).clone(), + total_frames: None, + }) + } + }) + .await; + + let frame_1_scopes = vec![Scope { + name: "Frame 1 Scope 1".into(), + presentation_hint: None, + variables_reference: 2, + named_variables: None, + indexed_variables: None, + expensive: false, + source: None, + line: None, + column: None, + end_line: None, + end_column: None, + }]; + + // add handlers for fetching the second stack frame's scopes and variables + // after the user clicked the stack frame + let frame_2_scopes = vec![Scope { + name: "Frame 2 Scope 1".into(), + presentation_hint: None, + variables_reference: 3, + named_variables: None, + indexed_variables: None, + expensive: false, + source: None, + line: None, + column: None, + end_line: None, + end_column: None, + }]; + + let called_second_stack_frame = Arc::new(AtomicBool::new(false)); + let called_first_stack_frame = Arc::new(AtomicBool::new(false)); + + client + .on_request::({ + let frame_1_scopes = Arc::new(frame_1_scopes.clone()); + let frame_2_scopes = Arc::new(frame_2_scopes.clone()); + let called_first_stack_frame = called_first_stack_frame.clone(); + let called_second_stack_frame = called_second_stack_frame.clone(); + move |_, args| match args.frame_id { + 1 => { + called_first_stack_frame.store(true, Ordering::SeqCst); + Ok(dap::ScopesResponse { + scopes: (*frame_1_scopes).clone(), + }) + } + 2 => { + called_second_stack_frame.store(true, Ordering::SeqCst); + + Ok(dap::ScopesResponse { + scopes: (*frame_2_scopes).clone(), + }) + } + _ => panic!("Made a scopes request with an invalid frame id"), + } + }) + .await; + + let frame_1_variables = vec![ + Variable { + name: "variable1".into(), + value: "value 1".into(), + type_: None, + presentation_hint: None, + evaluate_name: None, + variables_reference: 0, + named_variables: None, + indexed_variables: None, + memory_reference: None, + declaration_location_reference: None, + value_location_reference: None, + }, + Variable { + name: "variable2".into(), + value: "value 2".into(), + type_: None, + presentation_hint: None, + evaluate_name: None, + variables_reference: 0, + named_variables: None, + indexed_variables: None, + memory_reference: None, + declaration_location_reference: None, + value_location_reference: None, + }, + ]; + + let frame_2_variables = vec![ + Variable { + name: "variable3".into(), + value: "old value 1".into(), + type_: None, + presentation_hint: None, + evaluate_name: None, + variables_reference: 0, + named_variables: None, + indexed_variables: None, + memory_reference: None, + declaration_location_reference: None, + value_location_reference: None, + }, + Variable { + name: "variable4".into(), + value: "old value 2".into(), + type_: None, + presentation_hint: None, + evaluate_name: None, + variables_reference: 0, + named_variables: None, + indexed_variables: None, + memory_reference: None, + declaration_location_reference: None, + value_location_reference: None, + }, + ]; + + client + .on_request::({ + let frame_1_variables = Arc::new(frame_1_variables.clone()); + move |_, args| { + assert_eq!(2, args.variables_reference); + + Ok(dap::VariablesResponse { + variables: (*frame_1_variables).clone(), + }) + } + }) + .await; + + client + .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { + reason: dap::StoppedEventReason::Pause, + 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(); + + let running_state = + active_debug_session_panel(workspace, cx).update_in(cx, |item, window, cx| { + cx.focus_self(window); + item.mode() + .as_running() + .expect("Session should be running by this point") + .clone() + }); + + cx.run_until_parked(); + + running_state.update(cx, |running_state, cx| { + let (stack_frame_list, stack_frame_id) = + running_state.stack_frame_list().update(cx, |list, _| { + (list.flatten_entries(), list.current_stack_frame_id()) + }); + + let variable_list = running_state.variable_list().read(cx); + let variables = variable_list.variables(); + + assert_eq!(Some(1), stack_frame_id); + assert_eq!( + running_state + .stack_frame_list() + .read(cx) + .current_stack_frame_id(), + Some(1) + ); + + assert!( + called_first_stack_frame.load(std::sync::atomic::Ordering::SeqCst), + "Request scopes shouldn't be called before it's needed" + ); + assert!( + !called_second_stack_frame.load(std::sync::atomic::Ordering::SeqCst), + "Request scopes shouldn't be called before it's needed" + ); + + assert_eq!(stack_frames, stack_frame_list); + assert_eq!(frame_1_variables, variables); + }); + + client + .on_request::({ + let frame_2_variables = Arc::new(frame_2_variables.clone()); + move |_, args| { + assert_eq!(3, args.variables_reference); + + Ok(dap::VariablesResponse { + variables: (*frame_2_variables).clone(), + }) + } + }) + .await; + + running_state + .update_in(cx, |running_state, window, cx| { + running_state + .stack_frame_list() + .update(cx, |stack_frame_list, cx| { + stack_frame_list.select_stack_frame(&stack_frames[1], true, window, cx) + }) + }) + .await + .unwrap(); + + cx.run_until_parked(); + + running_state.update(cx, |running_state, cx| { + let (stack_frame_list, stack_frame_id) = + running_state.stack_frame_list().update(cx, |list, _| { + (list.flatten_entries(), list.current_stack_frame_id()) + }); + + let variable_list = running_state.variable_list().read(cx); + let variables = variable_list.variables(); + + assert_eq!(Some(2), stack_frame_id); + assert!( + called_second_stack_frame.load(std::sync::atomic::Ordering::SeqCst), + "Request scopes shouldn't be called before it's needed" + ); + + assert_eq!(stack_frames, stack_frame_list); + + assert_eq!(variables, frame_2_variables,); + }); + + 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(); +} diff --git a/crates/editor/Cargo.toml b/crates/editor/Cargo.toml index 41991142d3..5e153a690b 100644 --- a/crates/editor/Cargo.toml +++ b/crates/editor/Cargo.toml @@ -40,6 +40,7 @@ convert_case.workspace = true db.workspace = true buffer_diff.workspace = true emojis.workspace = true +feature_flags.workspace = true file_icons.workspace = true futures.workspace = true fuzzy.workspace = true @@ -55,6 +56,7 @@ linkify.workspace = true log.workspace = true lsp.workspace = true markdown.workspace = true +menu.workspace = true multi_buffer.workspace = true ordered-float.workspace = true parking_lot.workspace = true diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index 782e910b64..a6f2b1ca17 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -412,6 +412,8 @@ actions!( SwitchSourceHeader, Tab, Backtab, + ToggleBreakpoint, + EditLogBreakpoint, ToggleAutoSignatureHelp, ToggleGitBlameInline, ToggleIndentGuides, diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index ef4e8de08a..721ac0441f 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -68,6 +68,7 @@ use element::{layout_line, AcceptEditPredictionBinding, LineWithInvisibles, Posi pub use element::{ CursorLayout, EditorElement, HighlightedRange, HighlightedRangeLine, PointForPosition, }; +use feature_flags::{Debugger, FeatureFlagAppExt}; use futures::{ future::{self, join, Shared}, FutureExt, @@ -83,7 +84,7 @@ use git::blame::GitBlame; use gpui::{ div, impl_actions, point, prelude::*, pulsating_between, px, relative, size, Action, Animation, AnimationExt, AnyElement, App, AppContext, AsyncWindowContext, AvailableSpace, Background, - Bounds, ClipboardEntry, ClipboardItem, Context, DispatchPhase, Edges, Entity, + Bounds, ClickEvent, ClipboardEntry, ClipboardItem, Context, DispatchPhase, Edges, Entity, EntityInputHandler, EventEmitter, FocusHandle, FocusOutEvent, Focusable, FontId, FontWeight, Global, HighlightStyle, Hsla, KeyContext, Modifiers, MouseButton, MouseDownEvent, PaintQuad, ParentElement, Pixels, Render, SharedString, Size, Stateful, Styled, StyledText, Subscription, @@ -91,6 +92,7 @@ use gpui::{ WeakEntity, WeakFocusHandle, Window, }; use highlight_matching_bracket::refresh_matching_bracket_highlights; +use hover_links::{find_file, HoverLink, HoveredLinkState, InlayHighlight}; use hover_popover::{hide_hover, HoverState}; use indent_guides::ActiveIndentGuidesState; use inlay_hint_cache::{InlayHintCache, InlaySplice, InvalidationStrategy}; @@ -112,6 +114,11 @@ use language::{point_to_lsp, BufferRow, CharClassifier, Runnable, RunnableRange} use linked_editing_ranges::refresh_linked_ranges; use mouse_context_menu::MouseContextMenu; use persistence::DB; +use project::{ + debugger::breakpoint_store::{BreakpointEditAction, BreakpointStore, BreakpointStoreEvent}, + ProjectPath, +}; + pub use proposed_changes_editor::{ ProposedChangeLocation, ProposedChangesEditor, ProposedChangesEditorToolbar, }; @@ -119,7 +126,6 @@ use smallvec::smallvec; use std::iter::Peekable; use task::{ResolvedTask, TaskTemplate, TaskVariables}; -use hover_links::{find_file, HoverLink, HoveredLinkState, InlayHighlight}; pub use lsp::CompletionContext; use lsp::{ CodeActionKind, CompletionItemKind, CompletionTriggerKind, DiagnosticSeverity, @@ -136,7 +142,9 @@ use multi_buffer::{ ExcerptInfo, ExpandExcerptDirection, MultiBufferDiffHunk, MultiBufferPoint, MultiBufferRow, MultiOrSingleBufferOffsetRange, ToOffsetUtf16, }; +use parking_lot::Mutex; use project::{ + debugger::breakpoint_store::{Breakpoint, BreakpointKind}, lsp_store::{CompletionDocumentation, FormatTrigger, LspFormatTarget, OpenLspBufferHandle}, project_settings::{GitGutterSetting, ProjectSettings}, CodeAction, Completion, CompletionIntent, CompletionSource, DocumentHighlight, InlayHint, @@ -153,6 +161,7 @@ use serde::{Deserialize, Serialize}; use settings::{update_settings_file, Settings, SettingsLocation, SettingsStore}; use smallvec::SmallVec; use snippet::Snippet; +use std::sync::Arc; use std::{ any::TypeId, borrow::Cow, @@ -163,7 +172,6 @@ use std::{ ops::{ControlFlow, Deref, DerefMut, Not as _, Range, RangeInclusive}, path::{Path, PathBuf}, rc::Rc, - sync::Arc, time::{Duration, Instant}, }; pub use sum_tree::Bias; @@ -236,6 +244,7 @@ impl InlayId { } } +pub enum DebugCurrentRowHighlight {} enum DocumentHighlightRead {} enum DocumentHighlightWrite {} enum InputComposition {} @@ -589,6 +598,7 @@ struct ResolvedTasks { templates: SmallVec<[(TaskSourceKind, ResolvedTask); 1]>, position: Anchor, } + #[derive(Copy, Clone, Debug, PartialEq, PartialOrd)] struct BufferOffset(usize); @@ -659,6 +669,7 @@ pub struct Editor { show_git_diff_gutter: Option, show_code_actions: Option, show_runnables: Option, + show_breakpoints: Option, show_wrap_guides: Option, show_indent_guides: Option, placeholder_text: Option>, @@ -749,6 +760,11 @@ pub struct Editor { expect_bounds_change: Option>, tasks: BTreeMap<(BufferId, BufferRow), RunnableTasks>, tasks_update_task: Option>, + pub breakpoint_store: Option>, + /// Allow's a user to create a breakpoint by selecting this indicator + /// It should be None while a user is not hovering over the gutter + /// Otherwise it represents the point that the breakpoint will be shown + pub gutter_breakpoint_indicator: Option, in_project_search: bool, previous_search_ranges: Option]>>, breadcrumb_header: Option, @@ -789,6 +805,7 @@ pub struct EditorSnapshot { show_git_diff_gutter: Option, show_code_actions: Option, show_runnables: Option, + show_breakpoints: Option, git_blame_gutter_max_author_length: Option, pub display_snapshot: DisplaySnapshot, pub placeholder_text: Option>, @@ -1279,7 +1296,18 @@ impl Editor { editor.tasks_update_task = Some(editor.refresh_runnables(window, cx)); }, )); - } + }; + + project_subscriptions.push(cx.subscribe_in( + &project.read(cx).breakpoint_store(), + window, + |editor, _, event, window, cx| match event { + BreakpointStoreEvent::ActiveDebugLineChanged => { + editor.go_to_active_debug_line(window, cx); + } + _ => {} + }, + )); } } @@ -1303,6 +1331,11 @@ impl Editor { None }; + let breakpoint_store = match (mode, project.as_ref()) { + (EditorMode::Full, Some(project)) => Some(project.read(cx).breakpoint_store()), + _ => None, + }; + let mut code_action_providers = Vec::new(); let mut load_uncommitted_diff = None; if let Some(project) = project.clone() { @@ -1356,6 +1389,7 @@ impl Editor { show_git_diff_gutter: None, show_code_actions: None, show_runnables: None, + show_breakpoints: None, show_wrap_guides: None, show_indent_guides, placeholder_text: None, @@ -1440,6 +1474,9 @@ impl Editor { blame: None, blame_subscription: None, tasks: Default::default(), + + breakpoint_store, + gutter_breakpoint_indicator: None, _subscriptions: vec![ cx.observe(&buffer, Self::on_buffer_changed), cx.subscribe_in(&buffer, window, Self::on_buffer_event), @@ -1474,6 +1511,12 @@ impl Editor { text_style_refinement: None, load_diff_task: load_uncommitted_diff, }; + if let Some(breakpoints) = this.breakpoint_store.as_ref() { + this._subscriptions + .push(cx.observe(breakpoints, |_, _, cx| { + cx.notify(); + })); + } this.tasks_update_task = Some(this.refresh_runnables(window, cx)); this._subscriptions.extend(project_subscriptions); @@ -1490,6 +1533,8 @@ impl Editor { this.start_git_blame_inline(false, window, cx); } + this.go_to_active_debug_line(window, cx); + if let Some(buffer) = buffer.read(cx).as_singleton() { if let Some(project) = this.project.as_ref() { let handle = project.update(cx, |project, cx| { @@ -1764,6 +1809,7 @@ impl Editor { show_git_diff_gutter: self.show_git_diff_gutter, show_code_actions: self.show_code_actions, show_runnables: self.show_runnables, + show_breakpoints: self.show_breakpoints, git_blame_gutter_max_author_length, display_snapshot: self.display_map.update(cx, |map, cx| map.snapshot(cx)), scroll_anchor: self.scroll_manager.anchor(), @@ -5857,14 +5903,28 @@ impl Editor { _style: &EditorStyle, row: DisplayRow, is_active: bool, + breakpoint: Option<&(Anchor, Breakpoint)>, cx: &mut Context, ) -> Option { + let color = if breakpoint.is_some() { + Color::Debugger + } else { + Color::Muted + }; + + let position = breakpoint.as_ref().map(|(anchor, _)| *anchor); + let bp_kind = Arc::new( + breakpoint + .map(|(_, bp)| bp.kind.clone()) + .unwrap_or(BreakpointKind::Standard), + ); + if self.available_code_actions.is_some() { Some( IconButton::new("code_actions_indicator", ui::IconName::Bolt) .shape(ui::IconButtonShape::Square) .icon_size(IconSize::XSmall) - .icon_color(Color::Muted) + .icon_color(color) .toggle_state(is_active) .tooltip({ let focus_handle = self.focus_handle.clone(); @@ -5889,6 +5949,16 @@ impl Editor { window, cx, ); + })) + .on_right_click(cx.listener(move |editor, event: &ClickEvent, window, cx| { + editor.set_breakpoint_context_menu( + row, + position, + bp_kind.clone(), + event.down.position, + window, + cx, + ); })), ) } else { @@ -5907,6 +5977,198 @@ impl Editor { } } + /// Get all display points of breakpoints that will be rendered within editor + /// + /// This function is used to handle overlaps between breakpoints and Code action/runner symbol. + /// It's also used to set the color of line numbers with breakpoints to the breakpoint color. + /// TODO debugger: Use this function to color toggle symbols that house nested breakpoints + fn active_breakpoints( + &mut self, + range: Range, + window: &mut Window, + cx: &mut Context, + ) -> HashMap { + let mut breakpoint_display_points = HashMap::default(); + + let Some(breakpoint_store) = self.breakpoint_store.clone() else { + return breakpoint_display_points; + }; + + let snapshot = self.snapshot(window, cx); + + let multi_buffer_snapshot = &snapshot.display_snapshot.buffer_snapshot; + let Some(project) = self.project.as_ref() else { + return breakpoint_display_points; + }; + + if let Some(buffer) = self.buffer.read(cx).as_singleton() { + let buffer_snapshot = buffer.read(cx).snapshot(); + + for breakpoint in + breakpoint_store + .read(cx) + .breakpoints(&buffer, None, buffer_snapshot.clone(), cx) + { + let point = buffer_snapshot.summary_for_anchor::(&breakpoint.0); + let anchor = multi_buffer_snapshot.anchor_before(point); + breakpoint_display_points.insert( + snapshot + .point_to_display_point( + MultiBufferPoint { + row: point.row, + column: point.column, + }, + Bias::Left, + ) + .row(), + (anchor, breakpoint.1.clone()), + ); + } + + return breakpoint_display_points; + } + + let range = snapshot.display_point_to_point(DisplayPoint::new(range.start, 0), Bias::Left) + ..snapshot.display_point_to_point(DisplayPoint::new(range.end, 0), Bias::Right); + for excerpt_boundary in multi_buffer_snapshot.excerpt_boundaries_in_range(range) { + let info = excerpt_boundary.next; + + let Some(excerpt_ranges) = multi_buffer_snapshot.range_for_excerpt(info.id) else { + continue; + }; + + let Some(buffer) = + project.read_with(cx, |this, cx| this.buffer_for_id(info.buffer_id, cx)) + else { + continue; + }; + + let breakpoints = breakpoint_store.read(cx).breakpoints( + &buffer, + Some(info.range.context.start..info.range.context.end), + info.buffer.clone(), + cx, + ); + + // To translate a breakpoint's position within a singular buffer to a multi buffer + // position we need to know it's excerpt starting location, it's position within + // the singular buffer, and if that position is within the excerpt's range. + let excerpt_head = excerpt_ranges + .start + .to_display_point(&snapshot.display_snapshot); + + let buffer_start = info + .buffer + .summary_for_anchor::(&info.range.context.start); + + for (anchor, breakpoint) in breakpoints { + let as_row = info.buffer.summary_for_anchor::(&anchor).row; + let delta = as_row - buffer_start.row; + + let position = excerpt_head + DisplayPoint::new(DisplayRow(delta), 0); + + let anchor = snapshot.display_point_to_anchor(position, Bias::Left); + + breakpoint_display_points.insert(position.row(), (anchor, breakpoint.clone())); + } + } + + breakpoint_display_points + } + + fn breakpoint_context_menu( + &self, + anchor: Anchor, + kind: Arc, + window: &mut Window, + cx: &mut Context, + ) -> Entity { + let weak_editor = cx.weak_entity(); + let focus_handle = self.focus_handle(cx); + + let second_entry_msg = if kind.log_message().is_some() { + "Edit Log Breakpoint" + } else { + "Add Log Breakpoint" + }; + + ui::ContextMenu::build(window, cx, |menu, _, _cx| { + menu.on_blur_subscription(Subscription::new(|| {})) + .context(focus_handle) + .entry("Toggle Breakpoint", None, { + let weak_editor = weak_editor.clone(); + move |_window, cx| { + weak_editor + .update(cx, |this, cx| { + this.edit_breakpoint_at_anchor( + anchor, + BreakpointKind::Standard, + BreakpointEditAction::Toggle, + cx, + ); + }) + .log_err(); + } + }) + .entry(second_entry_msg, None, move |window, cx| { + weak_editor + .update(cx, |this, cx| { + this.add_edit_breakpoint_block(anchor, kind.as_ref(), window, cx); + }) + .log_err(); + }) + }) + } + + fn render_breakpoint( + &self, + position: Anchor, + row: DisplayRow, + kind: &BreakpointKind, + cx: &mut Context, + ) -> IconButton { + let color = if self + .gutter_breakpoint_indicator + .is_some_and(|gutter_bp| gutter_bp.row() == row) + { + Color::Hint + } else { + Color::Debugger + }; + + let icon = match &kind { + BreakpointKind::Standard => ui::IconName::DebugBreakpoint, + BreakpointKind::Log(_) => ui::IconName::DebugLogBreakpoint, + }; + let arc_kind = Arc::new(kind.clone()); + let arc_kind2 = arc_kind.clone(); + + IconButton::new(("breakpoint_indicator", row.0 as usize), icon) + .icon_size(IconSize::XSmall) + .size(ui::ButtonSize::None) + .icon_color(color) + .style(ButtonStyle::Transparent) + .on_click(cx.listener(move |editor, _e, window, cx| { + window.focus(&editor.focus_handle(cx)); + editor.edit_breakpoint_at_anchor( + position, + arc_kind.as_ref().clone(), + BreakpointEditAction::Toggle, + cx, + ); + })) + .on_right_click(cx.listener(move |editor, event: &ClickEvent, window, cx| { + editor.set_breakpoint_context_menu( + row, + Some(position), + arc_kind2.clone(), + event.down.position, + window, + cx, + ); + })) + } + fn build_tasks_context( project: &Entity, buffer: &Entity, @@ -6043,12 +6305,26 @@ impl Editor { _style: &EditorStyle, is_active: bool, row: DisplayRow, + breakpoint: Option<(Anchor, Breakpoint)>, cx: &mut Context, ) -> IconButton { + let color = if breakpoint.is_some() { + Color::Debugger + } else { + Color::Muted + }; + + let position = breakpoint.as_ref().map(|(anchor, _)| *anchor); + let bp_kind = Arc::new( + breakpoint + .map(|(_, bp)| bp.kind) + .unwrap_or(BreakpointKind::Standard), + ); + IconButton::new(("run_indicator", row.0 as usize), ui::IconName::Play) .shape(ui::IconButtonShape::Square) .icon_size(IconSize::XSmall) - .icon_color(Color::Muted) + .icon_color(color) .toggle_state(is_active) .on_click(cx.listener(move |editor, _e, window, cx| { window.focus(&editor.focus_handle(cx)); @@ -6060,6 +6336,16 @@ impl Editor { cx, ); })) + .on_right_click(cx.listener(move |editor, event: &ClickEvent, window, cx| { + editor.set_breakpoint_context_menu( + row, + position, + bp_kind.clone(), + event.down.position, + window, + cx, + ); + })) } pub fn context_menu_visible(&self) -> bool { @@ -8032,6 +8318,235 @@ impl Editor { } } + fn set_breakpoint_context_menu( + &mut self, + row: DisplayRow, + position: Option, + kind: Arc, + clicked_point: gpui::Point, + window: &mut Window, + cx: &mut Context, + ) { + if !cx.has_flag::() { + return; + } + let source = self + .buffer + .read(cx) + .snapshot(cx) + .anchor_before(Point::new(row.0, 0u32)); + + let context_menu = + self.breakpoint_context_menu(position.unwrap_or(source), kind, window, cx); + + self.mouse_context_menu = MouseContextMenu::pinned_to_editor( + self, + source, + clicked_point, + context_menu, + window, + cx, + ); + } + + fn add_edit_breakpoint_block( + &mut self, + anchor: Anchor, + kind: &BreakpointKind, + window: &mut Window, + cx: &mut Context, + ) { + let weak_editor = cx.weak_entity(); + let bp_prompt = + cx.new(|cx| BreakpointPromptEditor::new(weak_editor, anchor, kind.clone(), window, cx)); + + let height = bp_prompt.update(cx, |this, cx| { + this.prompt + .update(cx, |prompt, cx| prompt.max_point(cx).row().0 + 1 + 2) + }); + let cloned_prompt = bp_prompt.clone(); + let blocks = vec![BlockProperties { + style: BlockStyle::Sticky, + placement: BlockPlacement::Above(anchor), + height, + render: Arc::new(move |cx| { + *cloned_prompt.read(cx).gutter_dimensions.lock() = *cx.gutter_dimensions; + cloned_prompt.clone().into_any_element() + }), + priority: 0, + }]; + + let focus_handle = bp_prompt.focus_handle(cx); + window.focus(&focus_handle); + + let block_ids = self.insert_blocks(blocks, None, cx); + bp_prompt.update(cx, |prompt, _| { + prompt.add_block_ids(block_ids); + }); + } + + pub(crate) fn breakpoint_at_cursor_head( + &self, + window: &mut Window, + cx: &mut Context, + ) -> Option<(Anchor, Breakpoint)> { + let cursor_position: Point = self.selections.newest(cx).head(); + let snapshot = self.snapshot(window, cx); + // We Set the column position to zero so this function interacts correctly + // between calls by clicking on the gutter & using an action to toggle a + // breakpoint. Otherwise, toggling a breakpoint through an action wouldn't + // untoggle a breakpoint that was added through clicking on the gutter + let cursor_position = snapshot + .display_snapshot + .buffer_snapshot + .anchor_before(Point::new(cursor_position.row, 0)); + + let project = self.project.clone(); + + let buffer_id = cursor_position.text_anchor.buffer_id?; + let enclosing_excerpt = snapshot + .buffer_snapshot + .excerpt_ids_for_range(cursor_position..cursor_position) + .next()?; + let buffer = project?.read_with(cx, |project, cx| project.buffer_for_id(buffer_id, cx))?; + let buffer_snapshot = buffer.read(cx).snapshot(); + + let row = buffer_snapshot + .summary_for_anchor::(&cursor_position.text_anchor) + .row; + + let bp = self + .breakpoint_store + .as_ref()? + .read_with(cx, |breakpoint_store, cx| { + breakpoint_store + .breakpoints( + &buffer, + Some(cursor_position.text_anchor..(text::Anchor::MAX)), + buffer_snapshot.clone(), + cx, + ) + .next() + .and_then(move |(anchor, bp)| { + let breakpoint_row = buffer_snapshot + .summary_for_anchor::(anchor) + .row; + + if breakpoint_row == row { + snapshot + .buffer_snapshot + .anchor_in_excerpt(enclosing_excerpt, *anchor) + .map(|anchor| (anchor, bp.clone())) + } else { + None + } + }) + }); + bp + } + + pub fn edit_log_breakpoint( + &mut self, + _: &EditLogBreakpoint, + window: &mut Window, + cx: &mut Context, + ) { + let (anchor, bp) = self + .breakpoint_at_cursor_head(window, cx) + .unwrap_or_else(|| { + let cursor_position: Point = self.selections.newest(cx).head(); + + let breakpoint_position = self + .snapshot(window, cx) + .display_snapshot + .buffer_snapshot + .anchor_before(Point::new(cursor_position.row, 0)); + + ( + breakpoint_position, + Breakpoint { + kind: BreakpointKind::Standard, + }, + ) + }); + + self.add_edit_breakpoint_block(anchor, &bp.kind, window, cx); + } + + pub fn toggle_breakpoint( + &mut self, + _: &crate::actions::ToggleBreakpoint, + window: &mut Window, + cx: &mut Context, + ) { + let edit_action = BreakpointEditAction::Toggle; + + if let Some((anchor, breakpoint)) = self.breakpoint_at_cursor_head(window, cx) { + self.edit_breakpoint_at_anchor(anchor, breakpoint.kind, edit_action, cx); + } else { + let cursor_position: Point = self.selections.newest(cx).head(); + + let breakpoint_position = self + .snapshot(window, cx) + .display_snapshot + .buffer_snapshot + .anchor_before(Point::new(cursor_position.row, 0)); + + self.edit_breakpoint_at_anchor( + breakpoint_position, + BreakpointKind::Standard, + edit_action, + cx, + ); + } + } + + pub fn edit_breakpoint_at_anchor( + &mut self, + breakpoint_position: Anchor, + kind: BreakpointKind, + edit_action: BreakpointEditAction, + cx: &mut Context, + ) { + let Some(breakpoint_store) = &self.breakpoint_store else { + return; + }; + + let Some(buffer_id) = breakpoint_position.buffer_id.or_else(|| { + if breakpoint_position == Anchor::min() { + self.buffer() + .read(cx) + .excerpt_buffer_ids() + .into_iter() + .next() + } else { + None + } + }) else { + return; + }; + + let Some(buffer) = self.buffer().read(cx).buffer(buffer_id) else { + return; + }; + + breakpoint_store.update(cx, |breakpoint_store, cx| { + breakpoint_store.toggle_breakpoint( + buffer, + (breakpoint_position.text_anchor, Breakpoint { kind }), + edit_action, + cx, + ); + }); + + cx.notify(); + } + + #[cfg(any(test, feature = "test-support"))] + pub fn breakpoint_store(&self) -> Option> { + self.breakpoint_store.clone() + } + pub fn prepare_restore_change( &self, revert_changes: &mut HashMap, Rope)>>, @@ -11866,6 +12381,33 @@ impl Editor { .or_else(|| snapshot.buffer_snapshot.diff_hunk_before(Point::MAX)) } + fn go_to_line( + &mut self, + position: Anchor, + highlight_color: Option, + window: &mut Window, + cx: &mut Context, + ) { + let snapshot = self.snapshot(window, cx).display_snapshot; + let position = position.to_point(&snapshot.buffer_snapshot); + let start = snapshot + .buffer_snapshot + .clip_point(Point::new(position.row, 0), Bias::Left); + let end = start + Point::new(1, 0); + let start = snapshot.buffer_snapshot.anchor_before(start); + let end = snapshot.buffer_snapshot.anchor_before(end); + + self.clear_row_highlights::(); + self.highlight_rows::( + start..end, + highlight_color + .unwrap_or_else(|| cx.theme().colors().editor_highlighted_line_background), + true, + cx, + ); + self.request_autoscroll(Autoscroll::center(), cx); + } + pub fn go_to_definition( &mut self, _: &GoToDefinition, @@ -14532,6 +15074,11 @@ impl Editor { cx.notify(); } + pub fn set_show_breakpoints(&mut self, show_breakpoints: bool, cx: &mut Context) { + self.show_breakpoints = Some(show_breakpoints); + cx.notify(); + } + pub fn set_masked(&mut self, masked: bool, cx: &mut Context) { if self.display_map.read(cx).masked != masked { self.display_map.update(cx, |map, _| map.masked = masked); @@ -14634,6 +15181,61 @@ impl Editor { } } + pub fn project_path(&self, cx: &mut Context) -> Option { + if let Some(buffer) = self.buffer.read(cx).as_singleton() { + buffer.read_with(cx, |buffer, cx| buffer.project_path(cx)) + } else { + None + } + } + + pub fn go_to_active_debug_line(&mut self, window: &mut Window, cx: &mut Context) { + let _ = maybe!({ + let breakpoint_store = self.breakpoint_store.as_ref()?; + + let Some((_, _, active_position)) = + breakpoint_store.read(cx).active_position().cloned() + else { + self.clear_row_highlights::(); + return None; + }; + + let snapshot = self + .project + .as_ref()? + .read(cx) + .buffer_for_id(active_position.buffer_id?, cx)? + .read(cx) + .snapshot(); + + for (id, ExcerptRange { context, .. }) in self + .buffer + .read(cx) + .excerpts_for_buffer(active_position.buffer_id?, cx) + { + if context.start.cmp(&active_position, &snapshot).is_ge() + || context.end.cmp(&active_position, &snapshot).is_lt() + { + continue; + } + let snapshot = self.buffer.read(cx).snapshot(cx); + let multibuffer_anchor = snapshot.anchor_in_excerpt(id, active_position)?; + + self.clear_row_highlights::(); + self.go_to_line::( + multibuffer_anchor, + Some(cx.theme().colors().editor_debugger_active_line_background), + window, + cx, + ); + + cx.notify(); + } + + Some(()) + }); + } + pub fn copy_file_name_without_extension( &mut self, _: &CopyFileNameWithoutExtension, @@ -17645,6 +18247,7 @@ impl EditorSnapshot { .unwrap_or(gutter_settings.code_actions); let show_runnables = self.show_runnables.unwrap_or(gutter_settings.runnables); + let show_breakpoints = self.show_breakpoints.unwrap_or(gutter_settings.breakpoints); let git_blame_entries_width = self.git_blame_gutter_max_author_length @@ -17668,7 +18271,7 @@ impl EditorSnapshot { let mut left_padding = git_blame_entries_width.unwrap_or(Pixels::ZERO); left_padding += if !is_singleton { em_width * 4.0 - } else if show_code_actions || show_runnables { + } else if show_code_actions || show_runnables || show_breakpoints { em_width * 3.0 } else if show_git_gutter && show_line_numbers { em_width * 2.0 @@ -18668,6 +19271,157 @@ impl Global for KillRing {} const UPDATE_DEBOUNCE: Duration = Duration::from_millis(50); +struct BreakpointPromptEditor { + pub(crate) prompt: Entity, + editor: WeakEntity, + breakpoint_anchor: Anchor, + kind: BreakpointKind, + block_ids: HashSet, + gutter_dimensions: Arc>, + _subscriptions: Vec, +} + +impl BreakpointPromptEditor { + const MAX_LINES: u8 = 4; + + fn new( + editor: WeakEntity, + breakpoint_anchor: Anchor, + kind: BreakpointKind, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let buffer = cx.new(|cx| { + Buffer::local( + kind.log_message() + .map(|msg| msg.to_string()) + .unwrap_or_default(), + cx, + ) + }); + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + + let prompt = cx.new(|cx| { + let mut prompt = Editor::new( + EditorMode::AutoHeight { + max_lines: Self::MAX_LINES as usize, + }, + buffer, + None, + window, + cx, + ); + prompt.set_soft_wrap_mode(language::language_settings::SoftWrap::EditorWidth, cx); + prompt.set_show_cursor_when_unfocused(false, cx); + prompt.set_placeholder_text( + "Message to log when breakpoint is hit. Expressions within {} are interpolated.", + cx, + ); + + prompt + }); + + Self { + prompt, + editor, + breakpoint_anchor, + kind, + gutter_dimensions: Arc::new(Mutex::new(GutterDimensions::default())), + block_ids: Default::default(), + _subscriptions: vec![], + } + } + + pub(crate) fn add_block_ids(&mut self, block_ids: Vec) { + self.block_ids.extend(block_ids) + } + + fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) { + if let Some(editor) = self.editor.upgrade() { + let log_message = self + .prompt + .read(cx) + .buffer + .read(cx) + .as_singleton() + .expect("A multi buffer in breakpoint prompt isn't possible") + .read(cx) + .as_rope() + .to_string(); + + editor.update(cx, |editor, cx| { + editor.edit_breakpoint_at_anchor( + self.breakpoint_anchor, + self.kind.clone(), + BreakpointEditAction::EditLogMessage(log_message.into()), + cx, + ); + + editor.remove_blocks(self.block_ids.clone(), None, cx); + cx.focus_self(window); + }); + } + } + + fn cancel(&mut self, _: &menu::Cancel, window: &mut Window, cx: &mut Context) { + self.editor + .update(cx, |editor, cx| { + editor.remove_blocks(self.block_ids.clone(), None, cx); + window.focus(&editor.focus_handle); + }) + .log_err(); + } + + fn render_prompt_editor(&self, cx: &mut Context) -> impl IntoElement { + let settings = ThemeSettings::get_global(cx); + let text_style = TextStyle { + color: if self.prompt.read(cx).read_only(cx) { + cx.theme().colors().text_disabled + } else { + cx.theme().colors().text + }, + font_family: settings.buffer_font.family.clone(), + font_fallbacks: settings.buffer_font.fallbacks.clone(), + font_size: settings.buffer_font_size(cx).into(), + font_weight: settings.buffer_font.weight, + line_height: relative(settings.buffer_line_height.value()), + ..Default::default() + }; + EditorElement::new( + &self.prompt, + EditorStyle { + background: cx.theme().colors().editor_background, + local_player: cx.theme().players().local(), + text: text_style, + ..Default::default() + }, + ) + } +} + +impl Render for BreakpointPromptEditor { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let gutter_dimensions = *self.gutter_dimensions.lock(); + h_flex() + .key_context("Editor") + .bg(cx.theme().colors().editor_background) + .border_y_1() + .border_color(cx.theme().status().info_border) + .size_full() + .py(window.line_height() / 2.5) + .on_action(cx.listener(Self::confirm)) + .on_action(cx.listener(Self::cancel)) + .child(h_flex().w(gutter_dimensions.full_width() + (gutter_dimensions.margin / 2.0))) + .child(div().flex_1().child(self.render_prompt_editor(cx))) + } +} + +impl Focusable for BreakpointPromptEditor { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.prompt.focus_handle(cx) + } +} + fn all_edits_insertions_or_deletions( edits: &Vec<(Range, String)>, snapshot: &MultiBufferSnapshot, diff --git a/crates/editor/src/editor_settings.rs b/crates/editor/src/editor_settings.rs index b464a7b310..0de00403dc 100644 --- a/crates/editor/src/editor_settings.rs +++ b/crates/editor/src/editor_settings.rs @@ -117,6 +117,7 @@ pub struct Gutter { pub line_numbers: bool, pub code_actions: bool, pub runnables: bool, + pub breakpoints: bool, pub folds: bool, } @@ -464,6 +465,10 @@ pub struct GutterContent { /// /// Default: true pub runnables: Option, + /// Whether to show breakpoints in the gutter. + /// + /// Default: true + pub breakpoints: Option, /// Whether to show fold buttons in the gutter. /// /// Default: true diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index d813ae4048..e9c48a11cc 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -28,8 +28,11 @@ use language_settings::{Formatter, FormatterList, IndentGuideSettings}; use multi_buffer::{IndentGuide, PathKey}; use parking_lot::Mutex; use pretty_assertions::{assert_eq, assert_ne}; -use project::project_settings::{LspSettings, ProjectSettings}; -use project::FakeFs; +use project::{ + debugger::breakpoint_store::{BreakpointKind, SerializedBreakpoint}, + project_settings::{LspSettings, ProjectSettings}, + FakeFs, +}; use serde_json::{self, json}; use std::{cell::RefCell, future::Future, rc::Rc, sync::atomic::AtomicBool, time::Instant}; use std::{ @@ -11924,6 +11927,7 @@ async fn test_move_to_enclosing_bracket(cx: &mut TestAppContext) { cx.update_editor(|editor, window, cx| { editor.move_to_enclosing_bracket(&MoveToEnclosingBracket, window, cx) }); + cx.run_until_parked(); cx.assert_editor_state(after); }; @@ -17111,6 +17115,337 @@ async fn assert_highlighted_edits( }); } +#[track_caller] +fn assert_breakpoint( + breakpoints: &BTreeMap, Vec>, + path: &Arc, + expected: Vec<(u32, BreakpointKind)>, +) { + if expected.len() == 0usize { + assert!(!breakpoints.contains_key(path)); + } else { + let mut breakpoint = breakpoints + .get(path) + .unwrap() + .into_iter() + .map(|breakpoint| (breakpoint.position, breakpoint.kind.clone())) + .collect::>(); + + breakpoint.sort_by_key(|(cached_position, _)| *cached_position); + + assert_eq!(expected, breakpoint); + } +} + +fn add_log_breakpoint_at_cursor( + editor: &mut Editor, + log_message: &str, + window: &mut Window, + cx: &mut Context, +) { + let (anchor, bp) = editor + .breakpoint_at_cursor_head(window, cx) + .unwrap_or_else(|| { + let cursor_position: Point = editor.selections.newest(cx).head(); + + let breakpoint_position = editor + .snapshot(window, cx) + .display_snapshot + .buffer_snapshot + .anchor_before(Point::new(cursor_position.row, 0)); + + let kind = BreakpointKind::Log(Arc::from(log_message)); + + (breakpoint_position, Breakpoint { kind }) + }); + + editor.edit_breakpoint_at_anchor( + anchor, + bp.kind, + BreakpointEditAction::EditLogMessage(log_message.into()), + cx, + ); +} + +#[gpui::test] +async fn test_breakpoint_toggling(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let sample_text = "First line\nSecond line\nThird line\nFourth line".to_string(); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/a"), + json!({ + "main.rs": sample_text, + }), + ) + .await; + let project = Project::test(fs, [path!("/a").as_ref()], cx).await; + let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*workspace.deref(), cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/a"), + json!({ + "main.rs": sample_text, + }), + ) + .await; + let project = Project::test(fs, [path!("/a").as_ref()], cx).await; + let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*workspace.deref(), cx); + let worktree_id = workspace + .update(cx, |workspace, _window, cx| { + workspace.project().update(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }) + }) + .unwrap(); + + let buffer = project + .update(cx, |project, cx| { + project.open_buffer((worktree_id, "main.rs"), cx) + }) + .await + .unwrap(); + + let (editor, cx) = cx.add_window_view(|window, cx| { + Editor::new( + EditorMode::Full, + MultiBuffer::build_from_buffer(buffer, cx), + Some(project.clone()), + window, + cx, + ) + }); + + let project_path = editor.update(cx, |editor, cx| editor.project_path(cx).unwrap()); + let abs_path = project.read_with(cx, |project, cx| { + project + .absolute_path(&project_path, cx) + .map(|path_buf| Arc::from(path_buf.to_owned())) + .unwrap() + }); + + // assert we can add breakpoint on the first line + editor.update_in(cx, |editor, window, cx| { + editor.toggle_breakpoint(&actions::ToggleBreakpoint, window, cx); + editor.move_to_end(&MoveToEnd, window, cx); + editor.toggle_breakpoint(&actions::ToggleBreakpoint, window, cx); + }); + + let breakpoints = editor.update(cx, |editor, cx| { + editor + .breakpoint_store() + .as_ref() + .unwrap() + .read(cx) + .all_breakpoints(cx) + .clone() + }); + + assert_eq!(1, breakpoints.len()); + assert_breakpoint( + &breakpoints, + &abs_path, + vec![(0, BreakpointKind::Standard), (3, BreakpointKind::Standard)], + ); + + editor.update_in(cx, |editor, window, cx| { + editor.move_to_beginning(&MoveToBeginning, window, cx); + editor.toggle_breakpoint(&actions::ToggleBreakpoint, window, cx); + }); + + let breakpoints = editor.update(cx, |editor, cx| { + editor + .breakpoint_store() + .as_ref() + .unwrap() + .read(cx) + .all_breakpoints(cx) + .clone() + }); + + assert_eq!(1, breakpoints.len()); + assert_breakpoint(&breakpoints, &abs_path, vec![(3, BreakpointKind::Standard)]); + + editor.update_in(cx, |editor, window, cx| { + editor.move_to_end(&MoveToEnd, window, cx); + editor.toggle_breakpoint(&actions::ToggleBreakpoint, window, cx); + }); + + let breakpoints = editor.update(cx, |editor, cx| { + editor + .breakpoint_store() + .as_ref() + .unwrap() + .read(cx) + .all_breakpoints(cx) + .clone() + }); + + assert_eq!(0, breakpoints.len()); + assert_breakpoint(&breakpoints, &abs_path, vec![]); +} + +#[gpui::test] +async fn test_log_breakpoint_editing(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let sample_text = "First line\nSecond line\nThird line\nFourth line".to_string(); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/a"), + json!({ + "main.rs": sample_text, + }), + ) + .await; + let project = Project::test(fs, [path!("/a").as_ref()], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + let worktree_id = workspace.update(cx, |workspace, cx| { + workspace.project().update(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }) + }); + + let buffer = project + .update(cx, |project, cx| { + project.open_buffer((worktree_id, "main.rs"), cx) + }) + .await + .unwrap(); + + let (editor, cx) = cx.add_window_view(|window, cx| { + Editor::new( + EditorMode::Full, + MultiBuffer::build_from_buffer(buffer, cx), + Some(project.clone()), + window, + cx, + ) + }); + + let project_path = editor.update(cx, |editor, cx| editor.project_path(cx).unwrap()); + let abs_path = project.read_with(cx, |project, cx| { + project + .absolute_path(&project_path, cx) + .map(|path_buf| Arc::from(path_buf.to_owned())) + .unwrap() + }); + + editor.update_in(cx, |editor, window, cx| { + add_log_breakpoint_at_cursor(editor, "hello world", window, cx); + }); + + let breakpoints = editor.update(cx, |editor, cx| { + editor + .breakpoint_store() + .as_ref() + .unwrap() + .read(cx) + .all_breakpoints(cx) + .clone() + }); + + assert_breakpoint( + &breakpoints, + &abs_path, + vec![(0, BreakpointKind::Log("hello world".into()))], + ); + + // Removing a log message from a log breakpoint should remove it + editor.update_in(cx, |editor, window, cx| { + add_log_breakpoint_at_cursor(editor, "", window, cx); + }); + + let breakpoints = editor.update(cx, |editor, cx| { + editor + .breakpoint_store() + .as_ref() + .unwrap() + .read(cx) + .all_breakpoints(cx) + .clone() + }); + + assert_breakpoint(&breakpoints, &abs_path, vec![]); + + editor.update_in(cx, |editor, window, cx| { + editor.toggle_breakpoint(&actions::ToggleBreakpoint, window, cx); + editor.move_to_end(&MoveToEnd, window, cx); + editor.toggle_breakpoint(&actions::ToggleBreakpoint, window, cx); + // Not adding a log message to a standard breakpoint shouldn't remove it + add_log_breakpoint_at_cursor(editor, "", window, cx); + }); + + let breakpoints = editor.update(cx, |editor, cx| { + editor + .breakpoint_store() + .as_ref() + .unwrap() + .read(cx) + .all_breakpoints(cx) + .clone() + }); + + assert_breakpoint( + &breakpoints, + &abs_path, + vec![(0, BreakpointKind::Standard), (3, BreakpointKind::Standard)], + ); + + editor.update_in(cx, |editor, window, cx| { + add_log_breakpoint_at_cursor(editor, "hello world", window, cx); + }); + + let breakpoints = editor.update(cx, |editor, cx| { + editor + .breakpoint_store() + .as_ref() + .unwrap() + .read(cx) + .all_breakpoints(cx) + .clone() + }); + + assert_breakpoint( + &breakpoints, + &abs_path, + vec![ + (0, BreakpointKind::Standard), + (3, BreakpointKind::Log("hello world".into())), + ], + ); + + editor.update_in(cx, |editor, window, cx| { + add_log_breakpoint_at_cursor(editor, "hello Earth!!", window, cx); + }); + + let breakpoints = editor.update(cx, |editor, cx| { + editor + .breakpoint_store() + .as_ref() + .unwrap() + .read(cx) + .all_breakpoints(cx) + .clone() + }); + + assert_breakpoint( + &breakpoints, + &abs_path, + vec![ + (0, BreakpointKind::Standard), + (3, BreakpointKind::Log("hello Earth !!".into())), + ], + ); +} + #[gpui::test] async fn test_rename_with_duplicate_edits(cx: &mut TestAppContext) { init_test(cx, |_| {}); diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 9abdaca114..17e765db91 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -29,6 +29,7 @@ use crate::{ use buffer_diff::{DiffHunkStatus, DiffHunkStatusKind}; use client::ParticipantIndex; use collections::{BTreeMap, HashMap, HashSet}; +use feature_flags::{Debugger, FeatureFlagAppExt}; use file_icons::FileIcons; use git::{blame::BlameEntry, status::FileStatus, Oid}; use gpui::{ @@ -55,7 +56,10 @@ use multi_buffer::{ Anchor, ExcerptId, ExcerptInfo, ExpandExcerptDirection, ExpandInfo, MultiBufferPoint, MultiBufferRow, RowInfo, }; -use project::project_settings::{self, GitGutterSetting, GitHunkStyleSetting, ProjectSettings}; +use project::{ + debugger::breakpoint_store::{Breakpoint, BreakpointKind}, + project_settings::{self, GitGutterSetting, GitHunkStyleSetting, ProjectSettings}, +}; use settings::Settings; use smallvec::{smallvec, SmallVec}; use std::{ @@ -82,6 +86,14 @@ use workspace::{item::Item, notifications::NotifyTaskExt}; const INLINE_BLAME_PADDING_EM_WIDTHS: f32 = 7.; const MIN_SCROLL_THUMB_SIZE: f32 = 25.; +/// Determines what kinds of highlights should be applied to a lines background. +#[derive(Clone, Copy, Default)] +struct LineHighlightSpec { + selection: bool, + breakpoint: bool, + _active_stack_frame: bool, +} + struct SelectionLayout { head: DisplayPoint, cursor_shape: CursorShape, @@ -509,6 +521,10 @@ impl EditorElement { register_action(editor, window, Editor::insert_uuid_v4); register_action(editor, window, Editor::insert_uuid_v7); register_action(editor, window, Editor::open_selections_in_multibuffer); + if cx.has_flag::() { + register_action(editor, window, Editor::toggle_breakpoint); + register_action(editor, window, Editor::edit_log_breakpoint); + } } fn register_key_listeners(&self, window: &mut Window, _: &mut App, layout: &EditorLayout) { @@ -875,6 +891,18 @@ impl EditorElement { let gutter_hovered = gutter_hitbox.is_hovered(window); editor.set_gutter_hovered(gutter_hovered, cx); + if gutter_hovered { + editor.gutter_breakpoint_indicator = Some( + position_map + .point_for_position(event.position) + .previous_valid, + ); + } else { + editor.gutter_breakpoint_indicator = None; + } + + cx.notify(); + // Don't trigger hover popover if mouse is hovering over context menu if text_hitbox.is_hovered(window) { let point_for_position = position_map.point_for_position(event.position); @@ -970,7 +998,7 @@ impl EditorElement { cx: &mut App, ) -> ( Vec<(PlayerColor, Vec)>, - BTreeMap, + BTreeMap, Option, ) { let mut selections: Vec<(PlayerColor, Vec)> = Vec::new(); @@ -1000,9 +1028,10 @@ impl EditorElement { for row in cmp::max(layout.active_rows.start.0, start_row.0) ..=cmp::min(layout.active_rows.end.0, end_row.0) { - let contains_non_empty_selection = - active_rows.entry(DisplayRow(row)).or_insert(!is_empty); - *contains_non_empty_selection |= !is_empty; + let contains_non_empty_selection = active_rows + .entry(DisplayRow(row)) + .or_insert_with(LineHighlightSpec::default); + contains_non_empty_selection.selection |= !is_empty; } layouts.push(layout); } @@ -2019,6 +2048,54 @@ impl EditorElement { (offset_y, length) } + #[allow(clippy::too_many_arguments)] + fn layout_breakpoints( + &self, + line_height: Pixels, + range: Range, + scroll_pixel_position: gpui::Point, + gutter_dimensions: &GutterDimensions, + gutter_hitbox: &Hitbox, + display_hunks: &[(DisplayDiffHunk, Option)], + snapshot: &EditorSnapshot, + breakpoints: HashMap, + window: &mut Window, + cx: &mut App, + ) -> Vec { + self.editor.update(cx, |editor, cx| { + breakpoints + .into_iter() + .filter_map(|(point, (text_anchor, bp))| { + let row = MultiBufferRow { 0: point.0 }; + + if range.start > point || range.end < point { + return None; + } + + if snapshot.is_line_folded(row) { + return None; + } + + let button = editor.render_breakpoint(text_anchor, point, &bp.kind, cx); + + let button = prepaint_gutter_button( + button, + point, + line_height, + gutter_dimensions, + scroll_pixel_position, + gutter_hitbox, + display_hunks, + window, + cx, + ); + Some(button) + }) + .collect_vec() + }) + } + + #[allow(clippy::too_many_arguments)] fn layout_run_indicators( &self, line_height: Pixels, @@ -2029,6 +2106,7 @@ impl EditorElement { gutter_hitbox: &Hitbox, display_hunks: &[(DisplayDiffHunk, Option)], snapshot: &EditorSnapshot, + breakpoints: &mut HashMap, window: &mut Window, cx: &mut App, ) -> Vec { @@ -2088,6 +2166,7 @@ impl EditorElement { return None; } } + let display_row = multibuffer_point.to_display_point(snapshot).row(); if row_infos .get((display_row - range.start).0 as usize) @@ -2099,6 +2178,7 @@ impl EditorElement { &self.style, Some(display_row) == active_task_indicator_row, display_row, + breakpoints.remove(&display_row), cx, ); @@ -2211,6 +2291,7 @@ impl EditorElement { scroll_pixel_position: gpui::Point, gutter_dimensions: &GutterDimensions, gutter_hitbox: &Hitbox, + breakpoint_points: &mut HashMap, display_hunks: &[(DisplayDiffHunk, Option)], window: &mut Window, cx: &mut App, @@ -2226,11 +2307,16 @@ impl EditorElement { { active = deployed_from_indicator.map_or(true, |indicator_row| indicator_row == row); }; - button = editor.render_code_actions_indicator(&self.style, row, active, cx); + + let breakpoint = breakpoint_points.get(&row); + button = editor.render_code_actions_indicator(&self.style, row, active, breakpoint, cx); }); + let button = button?; + breakpoint_points.remove(&row); + let button = prepaint_gutter_button( - button?, + button, row, line_height, gutter_dimensions, @@ -2310,6 +2396,7 @@ impl EditorElement { scroll_position: gpui::Point, rows: Range, buffer_rows: &[RowInfo], + active_rows: &BTreeMap, newest_selection_head: Option, snapshot: &EditorSnapshot, window: &mut Window, @@ -2365,7 +2452,18 @@ impl EditorElement { return None; } - let color = cx.theme().colors().editor_line_number; + let color = active_rows + .get(&display_row) + .and_then(|spec| { + if spec.breakpoint { + Some(cx.theme().colors().debugger_accent) + } else if spec.selection { + Some(cx.theme().colors().editor_active_line_number) + } else { + None + } + }) + .unwrap_or_else(|| cx.theme().colors().editor_line_number); let shaped_line = self .shape_line_number(SharedString::from(&line_number), color, window) .log_err()?; @@ -2396,7 +2494,6 @@ impl EditorElement { let line_number = LineNumberLayout { shaped_line, hitbox, - display_row, }; Some((multi_buffer_row, line_number)) }) @@ -2408,7 +2505,7 @@ impl EditorElement { &self, rows: Range, row_infos: &[RowInfo], - active_rows: &BTreeMap, + active_rows: &BTreeMap, snapshot: &EditorSnapshot, window: &mut Window, cx: &mut App, @@ -4035,14 +4132,14 @@ impl EditorElement { .peek() .map_or(false, |(active_row, has_selection)| { active_row.0 == end_row + 1 - && *has_selection == contains_non_empty_selection + && has_selection.selection == contains_non_empty_selection.selection }) { active_rows.next().unwrap(); end_row += 1; } - if !contains_non_empty_selection { + if !contains_non_empty_selection.selection { let highlight_h_range = match layout.position_map.snapshot.current_line_highlight { CurrentLineHighlight::Gutter => Some(Range { @@ -4285,32 +4382,31 @@ impl EditorElement { for LineNumberLayout { shaped_line, hitbox, - display_row, } in layout.line_numbers.values() { let Some(hitbox) = hitbox else { continue; }; - let is_active = layout.active_rows.contains_key(&display_row); + let Some(()) = (if !is_singleton && hitbox.is_hovered(window) { + let color = cx.theme().colors().editor_hover_line_number; - let color = if is_active { - cx.theme().colors().editor_active_line_number - } else if !is_singleton && hitbox.is_hovered(window) { - cx.theme().colors().editor_hover_line_number + let Some(line) = self + .shape_line_number(shaped_line.text.clone(), color, window) + .log_err() + else { + continue; + }; + + line.paint(hitbox.origin, line_height, window, cx).log_err() } else { - cx.theme().colors().editor_line_number + shaped_line + .paint(hitbox.origin, line_height, window, cx) + .log_err() + }) else { + continue; }; - let Some(line) = self - .shape_line_number(shaped_line.text.clone(), color, window) - .log_err() - else { - continue; - }; - let Some(()) = line.paint(hitbox.origin, line_height, window, cx).log_err() else { - continue; - }; // In singleton buffers, we select corresponding lines on the line number click, so use | -like cursor. // In multi buffers, we open file at the line number clicked, so use a pointing hand cursor. if is_singleton { @@ -4335,7 +4431,7 @@ impl EditorElement { &layout.position_map.snapshot, line_height, layout.gutter_hitbox.bounds, - hunk, + &hunk, ); Some(( hunk_bounds, @@ -4507,6 +4603,10 @@ impl EditorElement { } }); + for breakpoint in layout.breakpoints.iter_mut() { + breakpoint.paint(window, cx); + } + for test_indicator in layout.test_indicators.iter_mut() { test_indicator.paint(window, cx); } @@ -5707,6 +5807,7 @@ fn prepaint_gutter_button( cx: &mut App, ) -> AnyElement { let mut button = button.into_any_element(); + let available_space = size( AvailableSpace::MinContent, AvailableSpace::Definite(line_height), @@ -6871,16 +6972,25 @@ impl Element for EditorElement { (selections, selected_buffer_ids) }); - let (selections, active_rows, newest_selection_head) = self.layout_selections( - start_anchor, - end_anchor, - &local_selections, - &snapshot, - start_row, - end_row, - window, - cx, - ); + let (selections, mut active_rows, newest_selection_head) = self + .layout_selections( + start_anchor, + end_anchor, + &local_selections, + &snapshot, + start_row, + end_row, + window, + cx, + ); + let mut breakpoint_rows = self.editor.update(cx, |editor, cx| { + editor.active_breakpoints(start_row..end_row, window, cx) + }); + if cx.has_flag::() { + for display_row in breakpoint_rows.keys() { + active_rows.entry(*display_row).or_default().breakpoint = true; + } + } let line_numbers = self.layout_line_numbers( Some(&gutter_hitbox), @@ -6889,12 +6999,36 @@ impl Element for EditorElement { scroll_position, start_row..end_row, &row_infos, + &active_rows, newest_selection_head, &snapshot, window, cx, ); + // We add the gutter breakpoint indicator to breakpoint_rows after painting + // line numbers so we don't paint a line number debug accent color if a user + // has their mouse over that line when a breakpoint isn't there + if cx.has_flag::() { + let gutter_breakpoint_indicator = + self.editor.read(cx).gutter_breakpoint_indicator; + if let Some(gutter_breakpoint_point) = gutter_breakpoint_indicator { + breakpoint_rows + .entry(gutter_breakpoint_point.row()) + .or_insert_with(|| { + let position = snapshot.display_point_to_anchor( + gutter_breakpoint_point, + Bias::Left, + ); + let breakpoint = Breakpoint { + kind: BreakpointKind::Standard, + }; + + (position, breakpoint) + }); + } + } + let mut expand_toggles = window.with_element_namespace("expand_toggles", |window| { self.layout_expand_toggles( @@ -7339,6 +7473,7 @@ impl Element for EditorElement { scroll_pixel_position, &gutter_dimensions, &gutter_hitbox, + &mut breakpoint_rows, &display_hunks, window, cx, @@ -7370,6 +7505,7 @@ impl Element for EditorElement { &gutter_hitbox, &display_hunks, &snapshot, + &mut breakpoint_rows, window, cx, ) @@ -7377,6 +7513,26 @@ impl Element for EditorElement { Vec::new() }; + let show_breakpoints = snapshot + .show_breakpoints + .unwrap_or(gutter_settings.breakpoints); + let breakpoints = if cx.has_flag::() && show_breakpoints { + self.layout_breakpoints( + line_height, + start_row..end_row, + scroll_pixel_position, + &gutter_dimensions, + &gutter_hitbox, + &display_hunks, + &snapshot, + breakpoint_rows, + window, + cx, + ) + } else { + vec![] + }; + self.layout_signature_help( &hitbox, content_origin, @@ -7526,6 +7682,7 @@ impl Element for EditorElement { diff_hunk_controls, mouse_context_menu, test_indicators, + breakpoints, code_actions_indicator, crease_toggles, crease_trailers, @@ -7688,7 +7845,7 @@ pub struct EditorLayout { wrap_guides: SmallVec<[(Pixels, bool); 2]>, indent_guides: Option>, visible_display_row_range: Range, - active_rows: BTreeMap, + active_rows: BTreeMap, highlighted_rows: BTreeMap, line_elements: SmallVec<[AnyElement; 1]>, line_numbers: Arc>, @@ -7705,6 +7862,7 @@ pub struct EditorLayout { selections: Vec<(PlayerColor, Vec)>, code_actions_indicator: Option, test_indicators: Vec, + breakpoints: Vec, crease_toggles: Vec>, expand_toggles: Vec)>>, diff_hunk_controls: Vec, @@ -7725,7 +7883,6 @@ impl EditorLayout { struct LineNumberLayout { shaped_line: ShapedLine, hitbox: Option, - display_row: DisplayRow, } struct ColoredRange { @@ -8376,6 +8533,7 @@ mod tests { ..Default::default() }) .collect::>(), + &BTreeMap::default(), Some(DisplayPoint::new(DisplayRow(0), 0)), &snapshot, window, diff --git a/crates/extension/src/extension_host_proxy.rs b/crates/extension/src/extension_host_proxy.rs index fee590d345..513011d083 100644 --- a/crates/extension/src/extension_host_proxy.rs +++ b/crates/extension/src/extension_host_proxy.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use anyhow::Result; use fs::Fs; use gpui::{App, Global, ReadGlobal, SharedString, Task}; -use language::{LanguageMatcher, LanguageName, LanguageServerBinaryStatus, LoadedLanguage}; +use language::{BinaryStatus, LanguageMatcher, LanguageName, LoadedLanguage}; use lsp::LanguageServerName; use parking_lot::RwLock; @@ -284,7 +284,7 @@ pub trait ExtensionLanguageServerProxy: Send + Sync + 'static { fn update_language_server_status( &self, language_server_id: LanguageServerName, - status: LanguageServerBinaryStatus, + status: BinaryStatus, ); } @@ -317,7 +317,7 @@ impl ExtensionLanguageServerProxy for ExtensionHostProxy { fn update_language_server_status( &self, language_server_id: LanguageServerName, - status: LanguageServerBinaryStatus, + status: BinaryStatus, ) { let Some(proxy) = self.language_server_proxy.read().clone() else { return; diff --git a/crates/extension_host/src/extension_store_test.rs b/crates/extension_host/src/extension_store_test.rs index 9e3fc4a175..9ce6c82e89 100644 --- a/crates/extension_host/src/extension_store_test.rs +++ b/crates/extension_host/src/extension_store_test.rs @@ -8,9 +8,9 @@ use collections::BTreeMap; use extension::ExtensionHostProxy; use fs::{FakeFs, Fs, RealFs}; use futures::{io::BufReader, AsyncReadExt, StreamExt}; -use gpui::{AppContext as _, SemanticVersion, TestAppContext}; +use gpui::{AppContext as _, SemanticVersion, SharedString, TestAppContext}; use http_client::{FakeHttpClient, Response}; -use language::{LanguageMatcher, LanguageRegistry, LanguageServerBinaryStatus}; +use language::{BinaryStatus, LanguageMatcher, LanguageRegistry}; use lsp::LanguageServerName; use node_runtime::NodeRuntime; use parking_lot::Mutex; @@ -663,18 +663,9 @@ async fn test_extension_store_with_test_extension(cx: &mut TestAppContext) { status_updates.next().await.unwrap(), ], [ - ( - LanguageServerName("gleam".into()), - LanguageServerBinaryStatus::CheckingForUpdate - ), - ( - LanguageServerName("gleam".into()), - LanguageServerBinaryStatus::Downloading - ), - ( - LanguageServerName("gleam".into()), - LanguageServerBinaryStatus::None - ) + (SharedString::new("gleam"), BinaryStatus::CheckingForUpdate), + (SharedString::new("gleam"), BinaryStatus::Downloading), + (SharedString::new("gleam"), BinaryStatus::None) ] ); diff --git a/crates/extension_host/src/wasm_host/wit/since_v0_0_1.rs b/crates/extension_host/src/wasm_host/wit/since_v0_0_1.rs index 1ebf3ee3a7..5210726a56 100644 --- a/crates/extension_host/src/wasm_host/wit/since_v0_0_1.rs +++ b/crates/extension_host/src/wasm_host/wit/since_v0_0_1.rs @@ -3,7 +3,7 @@ use crate::wasm_host::wit::since_v0_0_4; use crate::wasm_host::WasmState; use anyhow::Result; use extension::{ExtensionLanguageServerProxy, WorktreeDelegate}; -use language::LanguageServerBinaryStatus; +use language::BinaryStatus; use semantic_version::SemanticVersion; use std::sync::{Arc, OnceLock}; use wasmtime::component::{Linker, Resource}; @@ -132,17 +132,11 @@ impl ExtensionImports for WasmState { status: LanguageServerInstallationStatus, ) -> wasmtime::Result<()> { let status = match status { - LanguageServerInstallationStatus::CheckingForUpdate => { - LanguageServerBinaryStatus::CheckingForUpdate - } - LanguageServerInstallationStatus::Downloading => { - LanguageServerBinaryStatus::Downloading - } + LanguageServerInstallationStatus::CheckingForUpdate => BinaryStatus::CheckingForUpdate, + LanguageServerInstallationStatus::Downloading => BinaryStatus::Downloading, LanguageServerInstallationStatus::Cached - | LanguageServerInstallationStatus::Downloaded => LanguageServerBinaryStatus::None, - LanguageServerInstallationStatus::Failed(error) => { - LanguageServerBinaryStatus::Failed { error } - } + | LanguageServerInstallationStatus::Downloaded => BinaryStatus::None, + LanguageServerInstallationStatus::Failed(error) => BinaryStatus::Failed { error }, }; self.host diff --git a/crates/extension_host/src/wasm_host/wit/since_v0_1_0.rs b/crates/extension_host/src/wasm_host/wit/since_v0_1_0.rs index 37db2197c9..95c6ed0037 100644 --- a/crates/extension_host/src/wasm_host/wit/since_v0_1_0.rs +++ b/crates/extension_host/src/wasm_host/wit/since_v0_1_0.rs @@ -8,7 +8,7 @@ use extension::{ExtensionLanguageServerProxy, KeyValueStoreDelegate, WorktreeDel use futures::{io::BufReader, FutureExt as _}; use futures::{lock::Mutex, AsyncReadExt}; use language::LanguageName; -use language::{language_settings::AllLanguageSettings, LanguageServerBinaryStatus}; +use language::{language_settings::AllLanguageSettings, BinaryStatus}; use project::project_settings::ProjectSettings; use semantic_version::SemanticVersion; use std::{ @@ -474,16 +474,10 @@ impl ExtensionImports for WasmState { status: LanguageServerInstallationStatus, ) -> wasmtime::Result<()> { let status = match status { - LanguageServerInstallationStatus::CheckingForUpdate => { - LanguageServerBinaryStatus::CheckingForUpdate - } - LanguageServerInstallationStatus::Downloading => { - LanguageServerBinaryStatus::Downloading - } - LanguageServerInstallationStatus::None => LanguageServerBinaryStatus::None, - LanguageServerInstallationStatus::Failed(error) => { - LanguageServerBinaryStatus::Failed { error } - } + LanguageServerInstallationStatus::CheckingForUpdate => BinaryStatus::CheckingForUpdate, + LanguageServerInstallationStatus::Downloading => BinaryStatus::Downloading, + LanguageServerInstallationStatus::None => BinaryStatus::None, + LanguageServerInstallationStatus::Failed(error) => BinaryStatus::Failed { error }, }; self.host diff --git a/crates/extension_host/src/wasm_host/wit/since_v0_3_0.rs b/crates/extension_host/src/wasm_host/wit/since_v0_3_0.rs index e7bdebfa89..b5dfb55fd4 100644 --- a/crates/extension_host/src/wasm_host/wit/since_v0_3_0.rs +++ b/crates/extension_host/src/wasm_host/wit/since_v0_3_0.rs @@ -13,7 +13,7 @@ use extension::{ }; use futures::{io::BufReader, FutureExt as _}; use futures::{lock::Mutex, AsyncReadExt}; -use language::{language_settings::AllLanguageSettings, LanguageName, LanguageServerBinaryStatus}; +use language::{language_settings::AllLanguageSettings, BinaryStatus, LanguageName}; use project::project_settings::ProjectSettings; use semantic_version::SemanticVersion; use std::{ @@ -692,16 +692,10 @@ impl ExtensionImports for WasmState { status: LanguageServerInstallationStatus, ) -> wasmtime::Result<()> { let status = match status { - LanguageServerInstallationStatus::CheckingForUpdate => { - LanguageServerBinaryStatus::CheckingForUpdate - } - LanguageServerInstallationStatus::Downloading => { - LanguageServerBinaryStatus::Downloading - } - LanguageServerInstallationStatus::None => LanguageServerBinaryStatus::None, - LanguageServerInstallationStatus::Failed(error) => { - LanguageServerBinaryStatus::Failed { error } - } + LanguageServerInstallationStatus::CheckingForUpdate => BinaryStatus::CheckingForUpdate, + LanguageServerInstallationStatus::Downloading => BinaryStatus::Downloading, + LanguageServerInstallationStatus::None => BinaryStatus::None, + LanguageServerInstallationStatus::Failed(error) => BinaryStatus::Failed { error }, }; self.host diff --git a/crates/feature_flags/src/feature_flags.rs b/crates/feature_flags/src/feature_flags.rs index 4e94ae302d..1d36fc0b7e 100644 --- a/crates/feature_flags/src/feature_flags.rs +++ b/crates/feature_flags/src/feature_flags.rs @@ -115,6 +115,11 @@ impl FeatureFlag for AutoCommand { } } +pub struct Debugger {} +impl FeatureFlag for Debugger { + const NAME: &'static str = "debugger"; +} + pub trait FeatureFlagViewExt { fn observe_flag(&mut self, window: &Window, callback: F) -> Subscription where @@ -202,7 +207,7 @@ impl FeatureFlagAppExt for App { fn has_flag(&self) -> bool { self.try_global::() .map(|flags| flags.has_flag::()) - .unwrap_or(false) + .unwrap_or_else(|| T::enabled_in_development()) } fn is_staff(&self) -> bool { diff --git a/crates/language/src/buffer_tests.rs b/crates/language/src/buffer_tests.rs index 70da611b2b..b4ff4a63f8 100644 --- a/crates/language/src/buffer_tests.rs +++ b/crates/language/src/buffer_tests.rs @@ -2539,7 +2539,7 @@ fn test_branch_and_merge(cx: &mut TestAppContext) { assert_eq!(buffer.text(), "one\n1.5\ntwo\nTHREE\n"); }); - // Convert from branch buffer ranges to the corresoponing ranges in the + // Convert from branch buffer ranges to the corresponding ranges in the // base buffer. branch.read_with(cx, |buffer, cx| { assert_eq!( diff --git a/crates/language/src/language.rs b/crates/language/src/language.rs index 4960591d7c..645d2108a4 100644 --- a/crates/language/src/language.rs +++ b/crates/language/src/language.rs @@ -73,8 +73,8 @@ pub use buffer::Operation; pub use buffer::*; pub use diagnostic_set::{DiagnosticEntry, DiagnosticGroup}; pub use language_registry::{ - AvailableLanguage, LanguageNotFound, LanguageQueries, LanguageRegistry, - LanguageServerBinaryStatus, QUERY_FILENAME_PREFIXES, + AvailableLanguage, BinaryStatus, LanguageNotFound, LanguageQueries, LanguageRegistry, + QUERY_FILENAME_PREFIXES, }; pub use lsp::{LanguageServerId, LanguageServerName}; pub use outline::*; @@ -304,7 +304,7 @@ pub trait LspAdapterDelegate: Send + Sync { fn worktree_id(&self) -> WorktreeId; fn worktree_root_path(&self) -> &Path; fn exists(&self, path: &Path, is_dir: Option) -> bool; - fn update_status(&self, language: LanguageServerName, status: LanguageServerBinaryStatus); + fn update_status(&self, language: LanguageServerName, status: BinaryStatus); async fn language_server_download_dir(&self, name: &LanguageServerName) -> Option>; async fn npm_package_installed_version( @@ -382,7 +382,7 @@ pub trait LspAdapter: 'static + Send + Sync { } else { delegate.update_status( self.name(), - LanguageServerBinaryStatus::Failed { + BinaryStatus::Failed { error: format!("{error:?}"), }, ); @@ -586,7 +586,7 @@ async fn try_fetch_server_binary let name = adapter.name(); log::info!("fetching latest version of language server {:?}", name.0); - delegate.update_status(name.clone(), LanguageServerBinaryStatus::CheckingForUpdate); + delegate.update_status(name.clone(), BinaryStatus::CheckingForUpdate); let latest_version = adapter .fetch_latest_server_version(delegate.as_ref()) @@ -597,16 +597,16 @@ async fn try_fetch_server_binary .await { log::info!("language server {:?} is already installed", name.0); - delegate.update_status(name.clone(), LanguageServerBinaryStatus::None); + delegate.update_status(name.clone(), BinaryStatus::None); Ok(binary) } else { log::info!("downloading language server {:?}", name.0); - delegate.update_status(adapter.name(), LanguageServerBinaryStatus::Downloading); + delegate.update_status(adapter.name(), BinaryStatus::Downloading); let binary = adapter .fetch_server_binary(latest_version, container_dir, delegate.as_ref()) .await; - delegate.update_status(name.clone(), LanguageServerBinaryStatus::None); + delegate.update_status(name.clone(), BinaryStatus::None); binary } } diff --git a/crates/language/src/language_registry.rs b/crates/language/src/language_registry.rs index c789ab80cf..f6b5940028 100644 --- a/crates/language/src/language_registry.rs +++ b/crates/language/src/language_registry.rs @@ -98,7 +98,8 @@ pub struct LanguageRegistry { state: RwLock, language_server_download_dir: Option>, executor: BackgroundExecutor, - lsp_binary_status_tx: LspBinaryStatusSender, + lsp_binary_status_tx: BinaryStatusSender, + dap_binary_status_tx: BinaryStatusSender, } struct LanguageRegistryState { @@ -130,7 +131,7 @@ pub struct FakeLanguageServerEntry { } #[derive(Clone, Debug, PartialEq, Eq)] -pub enum LanguageServerBinaryStatus { +pub enum BinaryStatus { None, CheckingForUpdate, Downloading, @@ -213,8 +214,8 @@ pub struct LanguageQueries { } #[derive(Clone, Default)] -struct LspBinaryStatusSender { - txs: Arc>>>, +struct BinaryStatusSender { + txs: Arc>>>, } pub struct LoadedLanguage { @@ -247,6 +248,7 @@ impl LanguageRegistry { }), language_server_download_dir: None, lsp_binary_status_tx: Default::default(), + dap_binary_status_tx: Default::default(), executor, }; this.add(PLAIN_TEXT.clone()); @@ -914,12 +916,12 @@ impl LanguageRegistry { self.state.read().all_lsp_adapters.get(name).cloned() } - pub fn update_lsp_status( - &self, - server_name: LanguageServerName, - status: LanguageServerBinaryStatus, - ) { - self.lsp_binary_status_tx.send(server_name, status); + pub fn update_lsp_status(&self, server_name: LanguageServerName, status: BinaryStatus) { + self.lsp_binary_status_tx.send(server_name.0, status); + } + + pub fn update_dap_status(&self, server_name: LanguageServerName, status: BinaryStatus) { + self.dap_binary_status_tx.send(server_name.0, status); } pub fn next_language_server_id(&self) -> LanguageServerId { @@ -974,10 +976,16 @@ impl LanguageRegistry { pub fn language_server_binary_statuses( &self, - ) -> mpsc::UnboundedReceiver<(LanguageServerName, LanguageServerBinaryStatus)> { + ) -> mpsc::UnboundedReceiver<(SharedString, BinaryStatus)> { self.lsp_binary_status_tx.subscribe() } + pub fn dap_server_binary_statuses( + &self, + ) -> mpsc::UnboundedReceiver<(SharedString, BinaryStatus)> { + self.dap_binary_status_tx.subscribe() + } + pub async fn delete_server_container(&self, name: LanguageServerName) { log::info!("deleting server container"); let Some(dir) = self.language_server_download_dir(&name) else { @@ -1088,16 +1096,14 @@ impl LanguageRegistryState { } } -impl LspBinaryStatusSender { - fn subscribe( - &self, - ) -> mpsc::UnboundedReceiver<(LanguageServerName, LanguageServerBinaryStatus)> { +impl BinaryStatusSender { + fn subscribe(&self) -> mpsc::UnboundedReceiver<(SharedString, BinaryStatus)> { let (tx, rx) = mpsc::unbounded(); self.txs.lock().push(tx); rx } - fn send(&self, name: LanguageServerName, status: LanguageServerBinaryStatus) { + fn send(&self, name: SharedString, status: BinaryStatus) { let mut txs = self.txs.lock(); txs.retain(|tx| tx.unbounded_send((name.clone(), status.clone())).is_ok()); } diff --git a/crates/language_extension/src/extension_lsp_adapter.rs b/crates/language_extension/src/extension_lsp_adapter.rs index 28ccdd04c3..3ccefb1f56 100644 --- a/crates/language_extension/src/extension_lsp_adapter.rs +++ b/crates/language_extension/src/extension_lsp_adapter.rs @@ -12,8 +12,8 @@ use fs::Fs; use futures::{Future, FutureExt}; use gpui::AsyncApp; use language::{ - CodeLabel, HighlightId, Language, LanguageName, LanguageServerBinaryStatus, - LanguageToolchainStore, LspAdapter, LspAdapterDelegate, + BinaryStatus, CodeLabel, HighlightId, Language, LanguageName, LanguageToolchainStore, + LspAdapter, LspAdapterDelegate, }; use lsp::{CodeActionKind, LanguageServerBinary, LanguageServerBinaryOptions, LanguageServerName}; use serde::Serialize; @@ -80,7 +80,7 @@ impl ExtensionLanguageServerProxy for LanguageServerRegistryProxy { fn update_language_server_status( &self, language_server_id: LanguageServerName, - status: LanguageServerBinaryStatus, + status: BinaryStatus, ) { self.language_registry .update_lsp_status(language_server_id, status); diff --git a/crates/languages/src/json.rs b/crates/languages/src/json.rs index 778d3ae3c8..98a0520180 100644 --- a/crates/languages/src/json.rs +++ b/crates/languages/src/json.rs @@ -86,6 +86,7 @@ impl JsonLspAdapter { cx, ); let tasks_schema = task::TaskTemplates::generate_json_schema(); + let debug_schema = task::DebugTaskFile::generate_json_schema(); let snippets_schema = snippet_provider::format::VSSnippetsFile::generate_json_schema(); let tsconfig_schema = serde_json::Value::from_str(TSCONFIG_SCHEMA).unwrap(); let package_json_schema = serde_json::Value::from_str(PACKAGE_JSON_SCHEMA).unwrap(); @@ -137,7 +138,15 @@ impl JsonLspAdapter { ) ], "schema": snippets_schema, - } + }, + { + "fileMatch": [ + schema_file_match(paths::debug_tasks_file()), + paths::local_debug_file_relative_path() + ], + "schema": debug_schema, + + }, ] } }) diff --git a/crates/paths/src/paths.rs b/crates/paths/src/paths.rs index fa6217ce69..84f653bc90 100644 --- a/crates/paths/src/paths.rs +++ b/crates/paths/src/paths.rs @@ -169,6 +169,12 @@ pub fn tasks_file() -> &'static PathBuf { TASKS_FILE.get_or_init(|| config_dir().join("tasks.json")) } +/// Returns the path to the `debug.json` file. +pub fn debug_tasks_file() -> &'static PathBuf { + static DEBUG_TASKS_FILE: OnceLock = OnceLock::new(); + DEBUG_TASKS_FILE.get_or_init(|| config_dir().join("debug.json")) +} + /// Returns the path to the extensions directory. /// /// This is where installed extensions are stored. @@ -284,6 +290,14 @@ pub fn languages_dir() -> &'static PathBuf { LANGUAGES_DIR.get_or_init(|| support_dir().join("languages")) } +/// Returns the path to the debug adapters directory +/// +/// This is where debug adapters are downloaded to for DAPs that are built-in to Zed. +pub fn debug_adapters_dir() -> &'static PathBuf { + static DEBUG_ADAPTERS_DIR: OnceLock = OnceLock::new(); + DEBUG_ADAPTERS_DIR.get_or_init(|| support_dir().join("debug_adapters")) +} + /// Returns the path to the Copilot directory. pub fn copilot_dir() -> &'static PathBuf { static COPILOT_DIR: OnceLock = OnceLock::new(); @@ -328,5 +342,15 @@ pub fn local_vscode_tasks_file_relative_path() -> &'static Path { Path::new(".vscode/tasks.json") } +/// Returns the relative path to a `launch.json` file within a project. +pub fn local_debug_file_relative_path() -> &'static Path { + Path::new(".zed/debug.json") +} + +/// Returns the relative path to a `.vscode/launch.json` file within a project. +pub fn local_vscode_launch_file_relative_path() -> &'static Path { + Path::new(".vscode/launch.json") +} + /// A default editorconfig file name to use when resolving project settings. pub const EDITORCONFIG_NAME: &str = ".editorconfig"; diff --git a/crates/project/Cargo.toml b/crates/project/Cargo.toml index 84163a0a4d..01f3803846 100644 --- a/crates/project/Cargo.toml +++ b/crates/project/Cargo.toml @@ -22,6 +22,8 @@ test-support = [ "prettier/test-support", "worktree/test-support", "gpui/test-support", + "dap/test-support", + "dap_adapters/test-support", ] [dependencies] @@ -30,9 +32,12 @@ anyhow.workspace = true askpass.workspace = true async-trait.workspace = true buffer_diff.workspace = true +circular-buffer.workspace = true client.workspace = true clock.workspace = true collections.workspace = true +dap.workspace = true +dap_adapters.workspace = true extension.workspace = true fancy-regex.workspace = true fs.workspace = true @@ -44,6 +49,7 @@ gpui.workspace = true http_client.workspace = true image.workspace = true itertools.workspace = true +indexmap.workspace = true language.workspace = true log.workspace = true lsp.workspace = true @@ -81,17 +87,19 @@ worktree.workspace = true client = { workspace = true, features = ["test-support"] } collections = { workspace = true, features = ["test-support"] } buffer_diff = { workspace = true, features = ["test-support"] } +dap = { workspace = true, features = ["test-support"] } +dap_adapters = { workspace = true, features = ["test-support"] } env_logger.workspace = true fs = { workspace = true, features = ["test-support"] } git2.workspace = true gpui = { workspace = true, features = ["test-support"] } language = { workspace = true, features = ["test-support"] } -release_channel.workspace = true lsp = { workspace = true, features = ["test-support"] } prettier = { workspace = true, features = ["test-support"] } pretty_assertions.workspace = true -worktree = { workspace = true, features = ["test-support"] } +release_channel.workspace = true rpc = { workspace = true, features = ["test-support"] } settings = { workspace = true, features = ["test-support"] } unindent.workspace = true util = { workspace = true, features = ["test-support"] } +worktree = { workspace = true, features = ["test-support"] } diff --git a/crates/project/src/buffer_store.rs b/crates/project/src/buffer_store.rs index d9f775bafd..d3a0f5788c 100644 --- a/crates/project/src/buffer_store.rs +++ b/crates/project/src/buffer_store.rs @@ -85,6 +85,10 @@ enum OpenBuffer { pub enum BufferStoreEvent { BufferAdded(Entity), + BufferOpened { + buffer: Entity, + project_path: ProjectPath, + }, SharedBufferClosed(proto::PeerId, BufferId), BufferDropped(BufferId), BufferChangedFilePath { @@ -802,6 +806,13 @@ impl BufferStore { } } + fn as_local(&self) -> Option<&LocalBufferStore> { + match &self.state { + BufferStoreState::Local(state) => Some(state), + _ => None, + } + } + fn as_local_mut(&mut self) -> Option<&mut LocalBufferStore> { match &mut self.state { BufferStoreState::Local(state) => Some(state), @@ -829,6 +840,11 @@ impl BufferStore { cx: &mut Context, ) -> Task>> { if let Some(buffer) = self.get_by_path(&project_path, cx) { + cx.emit(BufferStoreEvent::BufferOpened { + buffer: buffer.clone(), + project_path, + }); + return Task::ready(Ok(buffer)); } @@ -852,12 +868,18 @@ impl BufferStore { .insert( cx.spawn(move |this, mut cx| async move { let load_result = load_buffer.await; - this.update(&mut cx, |this, _cx| { + this.update(&mut cx, |this, cx| { // Record the fact that the buffer is no longer loading. this.loading_buffers.remove(&project_path); - }) - .ok(); - load_result.map_err(Arc::new) + + let buffer = load_result.map_err(Arc::new)?; + cx.emit(BufferStoreEvent::BufferOpened { + buffer: buffer.clone(), + project_path, + }); + + Ok(buffer) + })? }) .shared(), ) @@ -1147,6 +1169,11 @@ impl BufferStore { }) } + pub fn buffer_id_for_project_path(&self, project_path: &ProjectPath) -> Option<&BufferId> { + self.as_local() + .and_then(|state| state.local_buffer_ids_by_path.get(project_path)) + } + pub fn get_by_path(&self, path: &ProjectPath, cx: &App) -> Option> { self.buffers().find_map(|buffer| { let file = File::from_dyn(buffer.read(cx).file())?; diff --git a/crates/project/src/debugger.rs b/crates/project/src/debugger.rs new file mode 100644 index 0000000000..f8e9c9005e --- /dev/null +++ b/crates/project/src/debugger.rs @@ -0,0 +1,19 @@ +//! Zed's debugger data layer is implemented in terms of 3 concepts: +//! - DAP store - that knows about all of the available debug sessions. +//! - Debug sessions - that bear responsibility of communicating with debug adapters and managing the state of each individual session. +//! For the most part it is agnostic over the communication layer (it'll use RPC for peers and actual DAP requests for the host). +//! - Breakpoint store - that knows about all breakpoints set for a project. +//! +//! There are few reasons for this divide: +//! - Breakpoints persist across debug sessions and they're not really specific to any particular session. Sure, we have to send protocol messages for them +//! (so they're a "thing" in the protocol), but we also want to set them before any session starts up. +//! - Debug clients are doing the heavy lifting, and this is where UI grabs all of it's data from. They also rely on breakpoint store during initialization to obtain +//! current set of breakpoints. +//! - Since DAP store knows about all of the available debug sessions, it is responsible for routing RPC requests to sessions. It also knows how to find adapters for particular kind of session. + +pub mod breakpoint_store; +pub mod dap_command; +pub mod dap_store; +pub mod session; + +pub use dap_adapters::attach_processes; diff --git a/crates/project/src/debugger/README.md b/crates/project/src/debugger/README.md new file mode 100644 index 0000000000..0e88094926 --- /dev/null +++ b/crates/project/src/debugger/README.md @@ -0,0 +1,350 @@ +# Debugger + +Zed uses the Debug Adapter Protocol (DAP) to provide debugging functionality across multiple programming languages. +DAP is a standardized protocol that defines how debuggers, editors, and IDEs communicate with each other. +It allows Zed to support various debuggers without needing to implement language-specific debugging logic. +This protocol enables features like setting breakpoints, stepping through code, inspecting variables, +and more, in a consistent manner across different programming languages and runtime environments. + +## Supported Debug Adapters + +Zed supports a variety of debug adapters for different programming languages: + +- JavaScript (node): Enables debugging of Node.js applications, including setting breakpoints, stepping through code, and inspecting variables in JavaScript. + +- Python (debugpy): Provides debugging capabilities for Python applications, supporting features like remote debugging, multi-threaded debugging, and Django/Flask application debugging. + +- LLDB: A powerful debugger for C, C++, Objective-C, and Swift, offering low-level debugging features and support for Apple platforms. + +- GDB: The GNU Debugger, which supports debugging for multiple programming languages including C, C++, Go, and Rust, across various platforms. + +- Go (dlv): Delve, a debugger for the Go programming language, offering both local and remote debugging capabilities with full support for Go's runtime and standard library. + +- PHP (xdebug): Provides debugging and profiling capabilities for PHP applications, including remote debugging and code coverage analysis. + +- Custom: Allows you to configure any debug adapter that supports the Debug Adapter Protocol, enabling debugging for additional languages or specialized environments not natively supported by Zed. + +These adapters enable Zed to provide a consistent debugging experience across multiple languages while leveraging the specific features and capabilities of each debugger. + +## How To Get Started + +To start a debug session, we added few default debug configurations for each supported language that supports generic configuration options. To see all the available debug configurations, you can use the command palette `debugger: start` action, this should list all the available debug configurations. + +### Configuration + +To create a custom debug configuration you have to create a `.zed/debug.json` file in your project root directory. This file should contain an array of debug configurations, each with a unique label and adapter the other option are optional/required based on the adapter. + +```json +[ + { + // The label for the debug configuration and used to identify the debug session inside the debug panel + "label": "Example Start debugger config" + // The debug adapter that Zed should use to debug the program + "adapter": "custom", + // Request: defaults to launch + // - launch: Zed will launch the program if specified or shows a debug terminal with the right configuration + // - attach: Zed will attach to a running program to debug it or when the process_id is not specified we will show a process picker (only supported for node currently) + "request": "launch", + // cwd: defaults to the current working directory of your project ($ZED_WORKTREE_ROOT) + // this field also supports task variables e.g. $ZED_WORKTREE_ROOT + "cwd": "$ZED_WORKTREE_ROOT", + // program: The program that you want to debug + // this fields also support task variables e.g. $ZED_FILE + // Note: this field should only contain the path to the program you want to debug + "program": "path_to_program", + // initialize_args: This field should contain all the adapter specific initialization arguments that are directly send to the debug adapter + "initialize_args": { + // "stopOnEntry": true // e.g. to stop on the first line of the program (These args are DAP specific) + } + } +] +``` + +### Using Attach [WIP] + +Only javascript and lldb supports starting a debug session using attach. + +When using the attach request with a process ID the syntax is as follows: + +```json +{ + "label": "Attach to Process", + "adapter": "javascript", + "request": { + "attach": { + "process_id": "12345" + } + } +} +``` + +Without process ID the syntax is as follows: + +```json +{ + "label": "Attach to Process", + "adapter": "javascript", + "request": { + "attach": {} + } +} +``` + +#### JavaScript Configuration + +##### Debug Active File + +This configuration allows you to debug a JavaScript file in your project. + +```json +{ + "label": "JavaScript: Debug Active File", + "adapter": "javascript", + "program": "$ZED_FILE", + "request": "launch", + "cwd": "$ZED_WORKTREE_ROOT" +} +``` + +##### Debug Terminal + +This configuration will spawn a debug terminal where you could start you program by typing `node test.js`, and the debug adapter will automatically attach to the process. + +```json +{ + "label": "JavaScript: Debug Terminal", + "adapter": "javascript", + "request": "launch", + "cwd": "$ZED_WORKTREE_ROOT", + // "program": "$ZED_FILE", // optional if you pass this in, you will see the output inside the terminal itself + "initialize_args": { + "console": "integratedTerminal" + } +} +``` + +#### PHP Configuration + +##### Debug Active File + +This configuration allows you to debug a PHP file in your project. + +```json +{ + "label": "PHP: Debug Active File", + "adapter": "php", + "program": "$ZED_FILE", + "request": "launch", + "cwd": "$ZED_WORKTREE_ROOT" +} +``` + +#### Python Configuration + +##### Debug Active File + +This configuration allows you to debug a Python file in your project. + +```json +{ + "label": "Python: Debug Active File", + "adapter": "python", + "program": "$ZED_FILE", + "request": "launch", + "cwd": "$ZED_WORKTREE_ROOT" +} +``` + +#### GDB Configuration + +**NOTE:** This configuration is for Linux systems only & intel macbooks. + +##### Debug Program + +This configuration allows you to debug a program using GDB e.g. Zed itself. + +```json +{ + "label": "GDB: Debug program", + "adapter": "gdb", + "program": "$ZED_WORKTREE_ROOT/target/debug/zed", + "request": "launch", + "cwd": "$ZED_WORKTREE_ROOT" +} +``` + +#### LLDB Configuration + +##### Debug Program + +This configuration allows you to debug a program using LLDB e.g. Zed itself. + +```json +{ + "label": "LLDB: Debug program", + "adapter": "lldb", + "program": "$ZED_WORKTREE_ROOT/target/debug/zed", + "request": "launch", + "cwd": "$ZED_WORKTREE_ROOT" +} +``` + +## Breakpoints + +Zed currently supports these types of breakpoints + +- Log Breakpoints: Output a log message instead of stopping at the breakpoint when it's hit +- Standard Breakpoints: Stop at the breakpoint when it's hit + +Standard breakpoints can be toggled by left clicking on the editor gutter or using the Toggle Breakpoint action. Right clicking on a breakpoint, code action symbol, or code runner symbol brings up the breakpoint context menu. That has options for toggling breakpoints and editing log breakpoints. + +Log breakpoints can also be edited/added through the edit log breakpoint action + +## Settings + +- `stepping_granularity`: Determines the stepping granularity. +- `save_breakpoints`: Whether the breakpoints should be reused across Zed sessions. +- `button`: Whether to show the debug button in the status bar. +- `timeout`: Time in milliseconds until timeout error when connecting to a TCP debug adapter. +- `log_dap_communications`: Whether to log messages between active debug adapters and Zed +- `format_dap_log_messages`: Whether to format dap messages in when adding them to debug adapter logger + +### Stepping granularity + +- Description: The Step granularity that the debugger will use +- Default: line +- Setting: debugger.stepping_granularity + +**Options** + +1. Statement - The step should allow the program to run until the current statement has finished executing. + The meaning of a statement is determined by the adapter and it may be considered equivalent to a line. + For example 'for(int i = 0; i < 10; i++)' could be considered to have 3 statements 'int i = 0', 'i < 10', and 'i++'. + +```json +{ + "debugger": { + "stepping_granularity": "statement" + } +} +``` + +2. Line - The step should allow the program to run until the current source line has executed. + +```json +{ + "debugger": { + "stepping_granularity": "line" + } +} +``` + +3. Instruction - The step should allow one instruction to execute (e.g. one x86 instruction). + +```json +{ + "debugger": { + "stepping_granularity": "instruction" + } +} +``` + +### Save Breakpoints + +- Description: Whether the breakpoints should be saved across Zed sessions. +- Default: true +- Setting: debugger.save_breakpoints + +**Options** + +`boolean` values + +```json +{ + "debugger": { + "save_breakpoints": true + } +} +``` + +### Button + +- Description: Whether the button should be displayed in the debugger toolbar. +- Default: true +- Setting: debugger.show_button + +**Options** + +`boolean` values + +```json +{ + "debugger": { + "show_button": true + } +} +``` + +### Timeout + +- Description: Time in milliseconds until timeout error when connecting to a TCP debug adapter. +- Default: 2000ms +- Setting: debugger.timeout + +**Options** + +`integer` values + +```json +{ + "debugger": { + "timeout": 3000 + } +} +``` + +### Log Dap Communications + +- Description: Whether to log messages between active debug adapters and Zed. (Used for DAP development) +- Default: false +- Setting: debugger.log_dap_communications + +**Options** + +`boolean` values + +```json +{ + "debugger": { + "log_dap_communications": true + } +} +``` + +### Format Dap Log Messages + +- Description: Whether to format dap messages in when adding them to debug adapter logger. (Used for DAP development) +- Default: false +- Setting: debugger.format_dap_log_messages + +**Options** + +`boolean` values + +```json +{ + "debugger": { + "format_dap_log_messages": true + } +} +``` + +## Theme + +The Debugger supports the following theme options + + /// Color used to accent some of the debuggers elements + /// Only accent breakpoint & breakpoint related symbols right now + +**debugger.accent**: Color used to accent breakpoint & breakpoint related symbols +**editor.debugger_active_line.background**: Background color of active debug line diff --git a/crates/project/src/debugger/breakpoint_store.rs b/crates/project/src/debugger/breakpoint_store.rs new file mode 100644 index 0000000000..19297a924d --- /dev/null +++ b/crates/project/src/debugger/breakpoint_store.rs @@ -0,0 +1,612 @@ +//! Module for managing breakpoints in a project. +//! +//! Breakpoints are separate from a session because they're not associated with any particular debug session. They can also be set up without a session running. +use anyhow::{anyhow, Result}; +use breakpoints_in_file::BreakpointsInFile; +use collections::BTreeMap; +use dap::client::SessionId; +use gpui::{App, AppContext, AsyncApp, Context, Entity, EventEmitter, Task}; +use language::{proto::serialize_anchor as serialize_text_anchor, Buffer, BufferSnapshot}; +use rpc::{ + proto::{self}, + AnyProtoClient, TypedEnvelope, +}; +use std::{ + hash::{Hash, Hasher}, + ops::Range, + path::Path, + sync::Arc, +}; +use text::PointUtf16; + +use crate::{buffer_store::BufferStore, worktree_store::WorktreeStore, Project, ProjectPath}; + +mod breakpoints_in_file { + use language::BufferEvent; + + use super::*; + + #[derive(Clone)] + pub(super) struct BreakpointsInFile { + pub(super) buffer: Entity, + // TODO: This is.. less than ideal, as it's O(n) and does not return entries in order. We'll have to change TreeMap to support passing in the context for comparisons + pub(super) breakpoints: Vec<(text::Anchor, Breakpoint)>, + _subscription: Arc, + } + + impl BreakpointsInFile { + pub(super) fn new(buffer: Entity, cx: &mut Context) -> Self { + let subscription = + Arc::from(cx.subscribe(&buffer, |_, buffer, event, cx| match event { + BufferEvent::Saved => { + if let Some(abs_path) = BreakpointStore::abs_path_from_buffer(&buffer, cx) { + cx.emit(BreakpointStoreEvent::BreakpointsUpdated( + abs_path, + BreakpointUpdatedReason::FileSaved, + )); + } + } + _ => {} + })); + + BreakpointsInFile { + buffer, + breakpoints: Vec::new(), + _subscription: subscription, + } + } + } +} + +#[derive(Clone)] +struct RemoteBreakpointStore { + upstream_client: AnyProtoClient, + _upstream_project_id: u64, +} + +#[derive(Clone)] +struct LocalBreakpointStore { + worktree_store: Entity, + buffer_store: Entity, +} + +#[derive(Clone)] +enum BreakpointStoreMode { + Local(LocalBreakpointStore), + Remote(RemoteBreakpointStore), +} +pub struct BreakpointStore { + breakpoints: BTreeMap, BreakpointsInFile>, + downstream_client: Option<(AnyProtoClient, u64)>, + active_stack_frame: Option<(SessionId, Arc, text::Anchor)>, + // E.g ssh + mode: BreakpointStoreMode, +} + +impl BreakpointStore { + pub fn init(client: &AnyProtoClient) { + client.add_entity_request_handler(Self::handle_toggle_breakpoint); + client.add_entity_message_handler(Self::handle_breakpoints_for_file); + } + pub fn local(worktree_store: Entity, buffer_store: Entity) -> Self { + BreakpointStore { + breakpoints: BTreeMap::new(), + mode: BreakpointStoreMode::Local(LocalBreakpointStore { + worktree_store, + buffer_store, + }), + downstream_client: None, + active_stack_frame: Default::default(), + } + } + + pub(crate) fn remote(upstream_project_id: u64, upstream_client: AnyProtoClient) -> Self { + BreakpointStore { + breakpoints: BTreeMap::new(), + mode: BreakpointStoreMode::Remote(RemoteBreakpointStore { + upstream_client, + _upstream_project_id: upstream_project_id, + }), + downstream_client: None, + active_stack_frame: Default::default(), + } + } + + pub(crate) fn shared(&mut self, project_id: u64, downstream_client: AnyProtoClient) { + self.downstream_client = Some((downstream_client.clone(), project_id)); + } + + pub(crate) fn unshared(&mut self, cx: &mut Context) { + self.downstream_client.take(); + + cx.notify(); + } + + async fn handle_breakpoints_for_file( + this: Entity, + message: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result<()> { + let breakpoints = cx.update(|cx| this.read(cx).breakpoint_store())?; + if message.payload.breakpoints.is_empty() { + return Ok(()); + } + + let buffer = this + .update(&mut cx, |this, cx| { + let path = + this.project_path_for_absolute_path(message.payload.path.as_ref(), cx)?; + Some(this.open_buffer(path, cx)) + }) + .ok() + .flatten() + .ok_or_else(|| anyhow!("Invalid project path"))? + .await?; + + breakpoints.update(&mut cx, move |this, cx| { + let bps = this + .breakpoints + .entry(Arc::::from(message.payload.path.as_ref())) + .or_insert_with(|| BreakpointsInFile::new(buffer, cx)); + + bps.breakpoints = message + .payload + .breakpoints + .into_iter() + .filter_map(|breakpoint| { + let anchor = language::proto::deserialize_anchor(breakpoint.position.clone()?)?; + let breakpoint = Breakpoint::from_proto(breakpoint)?; + Some((anchor, breakpoint)) + }) + .collect(); + + cx.notify(); + })?; + + Ok(()) + } + + async fn handle_toggle_breakpoint( + this: Entity, + message: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result { + let breakpoints = this.update(&mut cx, |this, _| this.breakpoint_store())?; + let path = this + .update(&mut cx, |this, cx| { + this.project_path_for_absolute_path(message.payload.path.as_ref(), cx) + })? + .ok_or_else(|| anyhow!("Could not resolve provided abs path"))?; + let buffer = this + .update(&mut cx, |this, cx| { + this.buffer_store().read(cx).get_by_path(&path, cx) + })? + .ok_or_else(|| anyhow!("Could not find buffer for a given path"))?; + let breakpoint = message + .payload + .breakpoint + .ok_or_else(|| anyhow!("Breakpoint not present in RPC payload"))?; + let anchor = language::proto::deserialize_anchor( + breakpoint + .position + .clone() + .ok_or_else(|| anyhow!("Anchor not present in RPC payload"))?, + ) + .ok_or_else(|| anyhow!("Anchor deserialization failed"))?; + let breakpoint = Breakpoint::from_proto(breakpoint) + .ok_or_else(|| anyhow!("Could not deserialize breakpoint"))?; + + breakpoints.update(&mut cx, |this, cx| { + this.toggle_breakpoint( + buffer, + (anchor, breakpoint), + BreakpointEditAction::Toggle, + cx, + ); + })?; + Ok(proto::Ack {}) + } + + pub(crate) fn broadcast(&self) { + if let Some((client, project_id)) = &self.downstream_client { + for (path, breakpoint_set) in &self.breakpoints { + let _ = client.send(proto::BreakpointsForFile { + project_id: *project_id, + path: path.to_str().map(ToOwned::to_owned).unwrap(), + breakpoints: breakpoint_set + .breakpoints + .iter() + .filter_map(|(anchor, bp)| bp.to_proto(&path, anchor)) + .collect(), + }); + } + } + } + + fn abs_path_from_buffer(buffer: &Entity, cx: &App) -> Option> { + worktree::File::from_dyn(buffer.read(cx).file()) + .and_then(|file| file.worktree.read(cx).absolutize(&file.path).ok()) + .map(Arc::::from) + } + + pub fn toggle_breakpoint( + &mut self, + buffer: Entity, + mut breakpoint: (text::Anchor, Breakpoint), + edit_action: BreakpointEditAction, + cx: &mut Context, + ) { + let Some(abs_path) = Self::abs_path_from_buffer(&buffer, cx) else { + return; + }; + + let breakpoint_set = self + .breakpoints + .entry(abs_path.clone()) + .or_insert_with(|| BreakpointsInFile::new(buffer, cx)); + + match edit_action { + BreakpointEditAction::Toggle => { + let len_before = breakpoint_set.breakpoints.len(); + breakpoint_set + .breakpoints + .retain(|value| &breakpoint != value); + if len_before == breakpoint_set.breakpoints.len() { + // We did not remove any breakpoint, hence let's toggle one. + breakpoint_set.breakpoints.push(breakpoint.clone()); + } + } + BreakpointEditAction::EditLogMessage(log_message) => { + if !log_message.is_empty() { + breakpoint.1.kind = BreakpointKind::Log(log_message.clone()); + + let found_bp = + breakpoint_set + .breakpoints + .iter_mut() + .find_map(|(other_pos, other_bp)| { + if breakpoint.0 == *other_pos { + Some(other_bp) + } else { + None + } + }); + + if let Some(found_bp) = found_bp { + found_bp.kind = BreakpointKind::Log(log_message.clone()); + } else { + // We did not remove any breakpoint, hence let's toggle one. + breakpoint_set.breakpoints.push(breakpoint.clone()); + } + } else if matches!(&breakpoint.1.kind, BreakpointKind::Log(_)) { + breakpoint_set + .breakpoints + .retain(|(other_pos, other_kind)| { + &breakpoint.0 != other_pos + && matches!(other_kind.kind, BreakpointKind::Standard) + }); + } + } + } + + if breakpoint_set.breakpoints.is_empty() { + self.breakpoints.remove(&abs_path); + } + if let BreakpointStoreMode::Remote(remote) = &self.mode { + if let Some(breakpoint) = breakpoint.1.to_proto(&abs_path, &breakpoint.0) { + cx.background_spawn(remote.upstream_client.request(proto::ToggleBreakpoint { + project_id: remote._upstream_project_id, + path: abs_path.to_str().map(ToOwned::to_owned).unwrap(), + breakpoint: Some(breakpoint), + })) + .detach(); + } + } else if let Some((client, project_id)) = &self.downstream_client { + let breakpoints = self + .breakpoints + .get(&abs_path) + .map(|breakpoint_set| { + breakpoint_set + .breakpoints + .iter() + .filter_map(|(anchor, bp)| bp.to_proto(&abs_path, anchor)) + .collect() + }) + .unwrap_or_default(); + + let _ = client.send(proto::BreakpointsForFile { + project_id: *project_id, + path: abs_path.to_str().map(ToOwned::to_owned).unwrap(), + breakpoints, + }); + } + + cx.emit(BreakpointStoreEvent::BreakpointsUpdated( + abs_path, + BreakpointUpdatedReason::Toggled, + )); + cx.notify(); + } + + pub fn on_file_rename( + &mut self, + old_path: Arc, + new_path: Arc, + cx: &mut Context, + ) { + if let Some(breakpoints) = self.breakpoints.remove(&old_path) { + self.breakpoints.insert(new_path.clone(), breakpoints); + + cx.notify(); + } + } + + pub fn breakpoints<'a>( + &'a self, + buffer: &'a Entity, + range: Option>, + buffer_snapshot: BufferSnapshot, + cx: &App, + ) -> impl Iterator + 'a { + let abs_path = Self::abs_path_from_buffer(buffer, cx); + abs_path + .and_then(|path| self.breakpoints.get(&path)) + .into_iter() + .flat_map(move |file_breakpoints| { + file_breakpoints.breakpoints.iter().filter({ + let range = range.clone(); + let buffer_snapshot = buffer_snapshot.clone(); + move |(position, _)| { + if let Some(range) = &range { + position.cmp(&range.start, &buffer_snapshot).is_ge() + && position.cmp(&range.end, &buffer_snapshot).is_le() + } else { + true + } + } + }) + }) + } + + pub fn active_position(&self) -> Option<&(SessionId, Arc, text::Anchor)> { + self.active_stack_frame.as_ref() + } + + pub fn remove_active_position( + &mut self, + session_id: Option, + cx: &mut Context, + ) { + if let Some(session_id) = session_id { + self.active_stack_frame + .take_if(|(id, _, _)| *id == session_id); + } else { + self.active_stack_frame.take(); + } + + cx.emit(BreakpointStoreEvent::ActiveDebugLineChanged); + cx.notify(); + } + + pub fn set_active_position( + &mut self, + position: (SessionId, Arc, text::Anchor), + cx: &mut Context, + ) { + self.active_stack_frame = Some(position); + cx.emit(BreakpointStoreEvent::ActiveDebugLineChanged); + cx.notify(); + } + + pub fn breakpoints_from_path(&self, path: &Arc, cx: &App) -> Vec { + self.breakpoints + .get(path) + .map(|bp| { + let snapshot = bp.buffer.read(cx).snapshot(); + bp.breakpoints + .iter() + .map(|(position, breakpoint)| { + let position = snapshot.summary_for_anchor::(position).row; + SerializedBreakpoint { + position, + path: path.clone(), + kind: breakpoint.kind.clone(), + } + }) + .collect() + }) + .unwrap_or_default() + } + + pub fn all_breakpoints(&self, cx: &App) -> BTreeMap, Vec> { + self.breakpoints + .iter() + .map(|(path, bp)| { + let snapshot = bp.buffer.read(cx).snapshot(); + ( + path.clone(), + bp.breakpoints + .iter() + .map(|(position, breakpoint)| { + let position = snapshot.summary_for_anchor::(position).row; + SerializedBreakpoint { + position, + path: path.clone(), + kind: breakpoint.kind.clone(), + } + }) + .collect(), + ) + }) + .collect() + } + + pub fn with_serialized_breakpoints( + &self, + breakpoints: BTreeMap, Vec>, + cx: &mut Context<'_, BreakpointStore>, + ) -> Task> { + if let BreakpointStoreMode::Local(mode) = &self.mode { + let mode = mode.clone(); + cx.spawn(move |this, mut cx| async move { + let mut new_breakpoints = BTreeMap::default(); + for (path, bps) in breakpoints { + if bps.is_empty() { + continue; + } + let (worktree, relative_path) = mode + .worktree_store + .update(&mut cx, |this, cx| { + this.find_or_create_worktree(&path, false, cx) + })? + .await?; + let buffer = mode + .buffer_store + .update(&mut cx, |this, cx| { + let path = ProjectPath { + worktree_id: worktree.read(cx).id(), + path: relative_path.into(), + }; + this.open_buffer(path, cx) + })? + .await; + let Ok(buffer) = buffer else { + log::error!("Todo: Serialized breakpoints which do not have buffer (yet)"); + continue; + }; + let snapshot = buffer.update(&mut cx, |buffer, _| buffer.snapshot())?; + + let mut breakpoints_for_file = + this.update(&mut cx, |_, cx| BreakpointsInFile::new(buffer, cx))?; + + for bp in bps { + let position = snapshot.anchor_before(PointUtf16::new(bp.position, 0)); + breakpoints_for_file + .breakpoints + .push((position, Breakpoint { kind: bp.kind })) + } + new_breakpoints.insert(path, breakpoints_for_file); + } + this.update(&mut cx, |this, cx| { + this.breakpoints = new_breakpoints; + cx.notify(); + })?; + + Ok(()) + }) + } else { + Task::ready(Ok(())) + } + } +} + +#[derive(Clone, Copy)] +pub enum BreakpointUpdatedReason { + Toggled, + FileSaved, +} + +pub enum BreakpointStoreEvent { + ActiveDebugLineChanged, + BreakpointsUpdated(Arc, BreakpointUpdatedReason), +} + +impl EventEmitter for BreakpointStore {} + +type LogMessage = Arc; + +#[derive(Clone, Debug)] +pub enum BreakpointEditAction { + Toggle, + EditLogMessage(LogMessage), +} + +#[derive(Clone, Debug)] +pub enum BreakpointKind { + Standard, + Log(LogMessage), +} + +impl BreakpointKind { + pub fn to_int(&self) -> i32 { + match self { + BreakpointKind::Standard => 0, + BreakpointKind::Log(_) => 1, + } + } + + pub fn log_message(&self) -> Option { + match self { + BreakpointKind::Standard => None, + BreakpointKind::Log(message) => Some(message.clone()), + } + } +} + +impl PartialEq for BreakpointKind { + fn eq(&self, other: &Self) -> bool { + std::mem::discriminant(self) == std::mem::discriminant(other) + } +} + +impl Eq for BreakpointKind {} + +impl Hash for BreakpointKind { + fn hash(&self, state: &mut H) { + std::mem::discriminant(self).hash(state); + } +} + +#[derive(Clone, Debug, Hash, PartialEq, Eq)] +pub struct Breakpoint { + pub kind: BreakpointKind, +} + +impl Breakpoint { + fn to_proto(&self, _path: &Path, position: &text::Anchor) -> Option { + Some(client::proto::Breakpoint { + position: Some(serialize_text_anchor(position)), + + kind: match self.kind { + BreakpointKind::Standard => proto::BreakpointKind::Standard.into(), + BreakpointKind::Log(_) => proto::BreakpointKind::Log.into(), + }, + message: if let BreakpointKind::Log(message) = &self.kind { + Some(message.to_string()) + } else { + None + }, + }) + } + + fn from_proto(breakpoint: client::proto::Breakpoint) -> Option { + Some(Self { + kind: match proto::BreakpointKind::from_i32(breakpoint.kind) { + Some(proto::BreakpointKind::Log) => { + BreakpointKind::Log(breakpoint.message.clone().unwrap_or_default().into()) + } + None | Some(proto::BreakpointKind::Standard) => BreakpointKind::Standard, + }, + }) + } +} + +#[derive(Clone, Debug, Hash, PartialEq, Eq)] +pub struct SerializedBreakpoint { + pub position: u32, + pub path: Arc, + pub kind: BreakpointKind, +} + +impl From for dap::SourceBreakpoint { + fn from(bp: SerializedBreakpoint) -> Self { + Self { + line: bp.position as u64 + 1, + column: None, + condition: None, + hit_condition: None, + log_message: bp.kind.log_message().as_deref().map(Into::into), + mode: None, + } + } +} diff --git a/crates/project/src/debugger/dap_command.rs b/crates/project/src/debugger/dap_command.rs new file mode 100644 index 0000000000..e21480d950 --- /dev/null +++ b/crates/project/src/debugger/dap_command.rs @@ -0,0 +1,1738 @@ +use std::sync::Arc; + +use anyhow::{anyhow, Ok, Result}; +use dap::{ + client::SessionId, + proto_conversions::ProtoConversion, + requests::{Continue, Next}, + Capabilities, ContinueArguments, InitializeRequestArguments, + InitializeRequestArgumentsPathFormat, NextArguments, SetVariableResponse, SourceBreakpoint, + StepInArguments, StepOutArguments, SteppingGranularity, ValueFormat, Variable, + VariablesArgumentsFilter, +}; +use rpc::proto; +use serde_json::Value; +use util::ResultExt; + +pub(crate) trait LocalDapCommand: 'static + Send + Sync + std::fmt::Debug { + type Response: 'static + Send + std::fmt::Debug; + type DapRequest: 'static + Send + dap::requests::Request; + + fn is_supported(_capabilities: &Capabilities) -> bool { + true + } + + fn to_dap(&self) -> ::Arguments; + + fn response_from_dap( + &self, + message: ::Response, + ) -> Result; +} + +pub(crate) trait DapCommand: LocalDapCommand { + type ProtoRequest: 'static + Send; + type ProtoResponse: 'static + Send; + const CACHEABLE: bool = false; + + #[allow(dead_code)] + fn client_id_from_proto(request: &Self::ProtoRequest) -> SessionId; + + #[allow(dead_code)] + fn from_proto(request: &Self::ProtoRequest) -> Self; + + #[allow(unused)] + fn to_proto(&self, debug_client_id: SessionId, upstream_project_id: u64) -> Self::ProtoRequest; + + #[allow(dead_code)] + fn response_to_proto( + debug_client_id: SessionId, + message: Self::Response, + ) -> Self::ProtoResponse; + + #[allow(unused)] + fn response_from_proto(&self, message: Self::ProtoResponse) -> Result; +} + +impl LocalDapCommand for Arc { + type Response = T::Response; + type DapRequest = T::DapRequest; + + fn is_supported(capabilities: &Capabilities) -> bool { + T::is_supported(capabilities) + } + + fn to_dap(&self) -> ::Arguments { + T::to_dap(self) + } + + fn response_from_dap( + &self, + message: ::Response, + ) -> Result { + T::response_from_dap(self, message) + } +} + +impl DapCommand for Arc { + type ProtoRequest = T::ProtoRequest; + type ProtoResponse = T::ProtoResponse; + + fn client_id_from_proto(request: &Self::ProtoRequest) -> SessionId { + T::client_id_from_proto(request) + } + + fn from_proto(request: &Self::ProtoRequest) -> Self { + Arc::new(T::from_proto(request)) + } + + fn to_proto(&self, debug_client_id: SessionId, upstream_project_id: u64) -> Self::ProtoRequest { + T::to_proto(self, debug_client_id, upstream_project_id) + } + + fn response_to_proto( + debug_client_id: SessionId, + message: Self::Response, + ) -> Self::ProtoResponse { + T::response_to_proto(debug_client_id, message) + } + + fn response_from_proto(&self, message: Self::ProtoResponse) -> Result { + T::response_from_proto(self, message) + } +} + +#[derive(Debug, Hash, PartialEq, Eq)] +pub struct StepCommand { + pub thread_id: u64, + pub granularity: Option, + pub single_thread: Option, +} + +impl StepCommand { + fn from_proto(message: proto::DapNextRequest) -> Self { + const LINE: i32 = proto::SteppingGranularity::Line as i32; + const INSTRUCTION: i32 = proto::SteppingGranularity::Instruction as i32; + + let granularity = message.granularity.map(|granularity| match granularity { + LINE => SteppingGranularity::Line, + INSTRUCTION => SteppingGranularity::Instruction, + _ => SteppingGranularity::Statement, + }); + + Self { + thread_id: message.thread_id, + granularity, + single_thread: message.single_thread, + } + } +} + +#[derive(Debug, Hash, PartialEq, Eq)] +pub(crate) struct NextCommand { + pub inner: StepCommand, +} + +impl LocalDapCommand for NextCommand { + type Response = ::Response; + type DapRequest = Next; + + fn to_dap(&self) -> ::Arguments { + NextArguments { + thread_id: self.inner.thread_id, + single_thread: self.inner.single_thread, + granularity: self.inner.granularity, + } + } + fn response_from_dap( + &self, + _message: ::Response, + ) -> Result { + Ok(()) + } +} + +impl DapCommand for NextCommand { + type ProtoRequest = proto::DapNextRequest; + type ProtoResponse = proto::Ack; + + fn client_id_from_proto(request: &Self::ProtoRequest) -> SessionId { + SessionId::from_proto(request.client_id) + } + + fn from_proto(request: &Self::ProtoRequest) -> Self { + Self { + inner: StepCommand::from_proto(request.clone()), + } + } + + fn response_to_proto( + _debug_client_id: SessionId, + _message: Self::Response, + ) -> Self::ProtoResponse { + proto::Ack {} + } + + fn to_proto( + &self, + debug_client_id: SessionId, + upstream_project_id: u64, + ) -> proto::DapNextRequest { + proto::DapNextRequest { + project_id: upstream_project_id, + client_id: debug_client_id.to_proto(), + thread_id: self.inner.thread_id, + single_thread: self.inner.single_thread, + granularity: self.inner.granularity.map(|gran| gran.to_proto() as i32), + } + } + + fn response_from_proto(&self, _message: Self::ProtoResponse) -> Result { + Ok(()) + } +} + +#[derive(Debug, Hash, PartialEq, Eq)] +pub(crate) struct StepInCommand { + pub inner: StepCommand, +} + +impl LocalDapCommand for StepInCommand { + type Response = ::Response; + type DapRequest = dap::requests::StepIn; + + fn to_dap(&self) -> ::Arguments { + StepInArguments { + thread_id: self.inner.thread_id, + single_thread: self.inner.single_thread, + target_id: None, + granularity: self.inner.granularity, + } + } + + fn response_from_dap( + &self, + _message: ::Response, + ) -> Result { + Ok(()) + } +} + +impl DapCommand for StepInCommand { + type ProtoRequest = proto::DapStepInRequest; + type ProtoResponse = proto::Ack; + + fn client_id_from_proto(request: &Self::ProtoRequest) -> SessionId { + SessionId::from_proto(request.client_id) + } + + fn from_proto(request: &Self::ProtoRequest) -> Self { + Self { + inner: StepCommand::from_proto(proto::DapNextRequest { + project_id: request.project_id, + client_id: request.client_id, + thread_id: request.thread_id, + single_thread: request.single_thread, + granularity: request.granularity, + }), + } + } + + fn response_to_proto( + _debug_client_id: SessionId, + _message: Self::Response, + ) -> Self::ProtoResponse { + proto::Ack {} + } + + fn to_proto( + &self, + debug_client_id: SessionId, + upstream_project_id: u64, + ) -> proto::DapStepInRequest { + proto::DapStepInRequest { + project_id: upstream_project_id, + client_id: debug_client_id.to_proto(), + thread_id: self.inner.thread_id, + single_thread: self.inner.single_thread, + granularity: self.inner.granularity.map(|gran| gran.to_proto() as i32), + target_id: None, + } + } + + fn response_from_proto(&self, _message: Self::ProtoResponse) -> Result { + Ok(()) + } +} + +#[derive(Debug, Hash, PartialEq, Eq)] +pub(crate) struct StepOutCommand { + pub inner: StepCommand, +} + +impl LocalDapCommand for StepOutCommand { + type Response = ::Response; + type DapRequest = dap::requests::StepOut; + + fn to_dap(&self) -> ::Arguments { + StepOutArguments { + thread_id: self.inner.thread_id, + single_thread: self.inner.single_thread, + granularity: self.inner.granularity, + } + } + + fn response_from_dap( + &self, + _message: ::Response, + ) -> Result { + Ok(()) + } +} + +impl DapCommand for StepOutCommand { + type ProtoRequest = proto::DapStepOutRequest; + type ProtoResponse = proto::Ack; + + fn client_id_from_proto(request: &Self::ProtoRequest) -> SessionId { + SessionId::from_proto(request.client_id) + } + + fn from_proto(request: &Self::ProtoRequest) -> Self { + Self { + inner: StepCommand::from_proto(proto::DapNextRequest { + project_id: request.project_id, + client_id: request.client_id, + thread_id: request.thread_id, + single_thread: request.single_thread, + granularity: request.granularity, + }), + } + } + + fn response_to_proto( + _debug_client_id: SessionId, + _message: Self::Response, + ) -> Self::ProtoResponse { + proto::Ack {} + } + + fn to_proto( + &self, + debug_client_id: SessionId, + upstream_project_id: u64, + ) -> proto::DapStepOutRequest { + proto::DapStepOutRequest { + project_id: upstream_project_id, + client_id: debug_client_id.to_proto(), + thread_id: self.inner.thread_id, + single_thread: self.inner.single_thread, + granularity: self.inner.granularity.map(|gran| gran.to_proto() as i32), + } + } + + fn response_from_proto(&self, _message: Self::ProtoResponse) -> Result { + Ok(()) + } +} + +#[derive(Debug, Hash, PartialEq, Eq)] +pub(crate) struct StepBackCommand { + pub inner: StepCommand, +} +impl LocalDapCommand for StepBackCommand { + type Response = ::Response; + type DapRequest = dap::requests::StepBack; + + fn is_supported(capabilities: &Capabilities) -> bool { + capabilities.supports_step_back.unwrap_or_default() + } + + fn to_dap(&self) -> ::Arguments { + dap::StepBackArguments { + thread_id: self.inner.thread_id, + single_thread: self.inner.single_thread, + granularity: self.inner.granularity, + } + } + + fn response_from_dap( + &self, + _message: ::Response, + ) -> Result { + Ok(()) + } +} + +impl DapCommand for StepBackCommand { + type ProtoRequest = proto::DapStepBackRequest; + type ProtoResponse = proto::Ack; + + fn client_id_from_proto(request: &Self::ProtoRequest) -> SessionId { + SessionId::from_proto(request.client_id) + } + + fn from_proto(request: &Self::ProtoRequest) -> Self { + Self { + inner: StepCommand::from_proto(proto::DapNextRequest { + project_id: request.project_id, + client_id: request.client_id, + thread_id: request.thread_id, + single_thread: request.single_thread, + granularity: request.granularity, + }), + } + } + + fn response_to_proto( + _debug_client_id: SessionId, + _message: Self::Response, + ) -> Self::ProtoResponse { + proto::Ack {} + } + + fn to_proto( + &self, + debug_client_id: SessionId, + upstream_project_id: u64, + ) -> proto::DapStepBackRequest { + proto::DapStepBackRequest { + project_id: upstream_project_id, + client_id: debug_client_id.to_proto(), + thread_id: self.inner.thread_id, + single_thread: self.inner.single_thread, + granularity: self.inner.granularity.map(|gran| gran.to_proto() as i32), + } + } + + fn response_from_proto(&self, _message: Self::ProtoResponse) -> Result { + Ok(()) + } +} + +#[derive(Debug, Hash, PartialEq, Eq)] +pub(crate) struct ContinueCommand { + pub args: ContinueArguments, +} + +impl LocalDapCommand for ContinueCommand { + type Response = ::Response; + type DapRequest = Continue; + + fn to_dap(&self) -> ::Arguments { + self.args.clone() + } + + fn response_from_dap( + &self, + message: ::Response, + ) -> Result { + Ok(message) + } +} + +impl DapCommand for ContinueCommand { + type ProtoRequest = proto::DapContinueRequest; + type ProtoResponse = proto::DapContinueResponse; + + fn client_id_from_proto(request: &Self::ProtoRequest) -> SessionId { + SessionId::from_proto(request.client_id) + } + + fn to_proto( + &self, + debug_client_id: SessionId, + upstream_project_id: u64, + ) -> proto::DapContinueRequest { + proto::DapContinueRequest { + project_id: upstream_project_id, + client_id: debug_client_id.to_proto(), + thread_id: self.args.thread_id, + single_thread: self.args.single_thread, + } + } + + fn from_proto(request: &Self::ProtoRequest) -> Self { + Self { + args: ContinueArguments { + thread_id: request.thread_id, + single_thread: request.single_thread, + }, + } + } + + fn response_from_proto(&self, message: Self::ProtoResponse) -> Result { + Ok(Self::Response { + all_threads_continued: message.all_threads_continued, + }) + } + + fn response_to_proto( + debug_client_id: SessionId, + message: Self::Response, + ) -> Self::ProtoResponse { + proto::DapContinueResponse { + client_id: debug_client_id.to_proto(), + all_threads_continued: message.all_threads_continued, + } + } +} + +#[derive(Debug, Hash, PartialEq, Eq)] +pub(crate) struct PauseCommand { + pub thread_id: u64, +} + +impl LocalDapCommand for PauseCommand { + type Response = ::Response; + type DapRequest = dap::requests::Pause; + fn to_dap(&self) -> ::Arguments { + dap::PauseArguments { + thread_id: self.thread_id, + } + } + + fn response_from_dap( + &self, + _message: ::Response, + ) -> Result { + Ok(()) + } +} + +impl DapCommand for PauseCommand { + type ProtoRequest = proto::DapPauseRequest; + type ProtoResponse = proto::Ack; + + fn client_id_from_proto(request: &Self::ProtoRequest) -> SessionId { + SessionId::from_proto(request.client_id) + } + + fn from_proto(request: &Self::ProtoRequest) -> Self { + Self { + thread_id: request.thread_id, + } + } + + fn to_proto( + &self, + debug_client_id: SessionId, + upstream_project_id: u64, + ) -> proto::DapPauseRequest { + proto::DapPauseRequest { + project_id: upstream_project_id, + client_id: debug_client_id.to_proto(), + thread_id: self.thread_id, + } + } + + fn response_to_proto( + _debug_client_id: SessionId, + _message: Self::Response, + ) -> Self::ProtoResponse { + proto::Ack {} + } + + fn response_from_proto(&self, _message: Self::ProtoResponse) -> Result { + Ok(()) + } +} + +#[derive(Debug, Hash, PartialEq, Eq)] +pub(crate) struct DisconnectCommand { + pub restart: Option, + pub terminate_debuggee: Option, + pub suspend_debuggee: Option, +} + +impl LocalDapCommand for DisconnectCommand { + type Response = ::Response; + type DapRequest = dap::requests::Disconnect; + + fn to_dap(&self) -> ::Arguments { + dap::DisconnectArguments { + restart: self.restart, + terminate_debuggee: self.terminate_debuggee, + suspend_debuggee: self.suspend_debuggee, + } + } + + fn response_from_dap( + &self, + _message: ::Response, + ) -> Result { + Ok(()) + } +} + +impl DapCommand for DisconnectCommand { + type ProtoRequest = proto::DapDisconnectRequest; + type ProtoResponse = proto::Ack; + + fn client_id_from_proto(request: &Self::ProtoRequest) -> SessionId { + SessionId::from_proto(request.client_id) + } + + fn from_proto(request: &Self::ProtoRequest) -> Self { + Self { + restart: request.restart, + terminate_debuggee: request.terminate_debuggee, + suspend_debuggee: request.suspend_debuggee, + } + } + + fn to_proto( + &self, + debug_client_id: SessionId, + upstream_project_id: u64, + ) -> proto::DapDisconnectRequest { + proto::DapDisconnectRequest { + project_id: upstream_project_id, + client_id: debug_client_id.to_proto(), + restart: self.restart, + terminate_debuggee: self.terminate_debuggee, + suspend_debuggee: self.suspend_debuggee, + } + } + + fn response_to_proto( + _debug_client_id: SessionId, + _message: Self::Response, + ) -> Self::ProtoResponse { + proto::Ack {} + } + + fn response_from_proto(&self, _message: Self::ProtoResponse) -> Result { + Ok(()) + } +} + +#[derive(Debug, Hash, PartialEq, Eq)] +pub(crate) struct TerminateThreadsCommand { + pub thread_ids: Option>, +} + +impl LocalDapCommand for TerminateThreadsCommand { + type Response = ::Response; + type DapRequest = dap::requests::TerminateThreads; + + fn is_supported(capabilities: &Capabilities) -> bool { + capabilities + .supports_terminate_threads_request + .unwrap_or_default() + } + + fn to_dap(&self) -> ::Arguments { + dap::TerminateThreadsArguments { + thread_ids: self.thread_ids.clone(), + } + } + + fn response_from_dap( + &self, + _message: ::Response, + ) -> Result { + Ok(()) + } +} + +impl DapCommand for TerminateThreadsCommand { + type ProtoRequest = proto::DapTerminateThreadsRequest; + type ProtoResponse = proto::Ack; + + fn client_id_from_proto(request: &Self::ProtoRequest) -> SessionId { + SessionId::from_proto(request.client_id) + } + + fn from_proto(request: &Self::ProtoRequest) -> Self { + let thread_ids = if request.thread_ids.is_empty() { + None + } else { + Some(request.thread_ids.clone()) + }; + + Self { thread_ids } + } + + fn to_proto( + &self, + debug_client_id: SessionId, + upstream_project_id: u64, + ) -> proto::DapTerminateThreadsRequest { + proto::DapTerminateThreadsRequest { + project_id: upstream_project_id, + client_id: debug_client_id.to_proto(), + thread_ids: self.thread_ids.clone().unwrap_or_default(), + } + } + + fn response_to_proto( + _debug_client_id: SessionId, + _message: Self::Response, + ) -> Self::ProtoResponse { + proto::Ack {} + } + + fn response_from_proto(&self, _message: Self::ProtoResponse) -> Result { + Ok(()) + } +} + +#[derive(Debug, Hash, PartialEq, Eq)] +pub(crate) struct TerminateCommand { + pub restart: Option, +} + +impl LocalDapCommand for TerminateCommand { + type Response = ::Response; + type DapRequest = dap::requests::Terminate; + + fn is_supported(capabilities: &Capabilities) -> bool { + capabilities.supports_terminate_request.unwrap_or_default() + } + fn to_dap(&self) -> ::Arguments { + dap::TerminateArguments { + restart: self.restart, + } + } + + fn response_from_dap( + &self, + _message: ::Response, + ) -> Result { + Ok(()) + } +} + +impl DapCommand for TerminateCommand { + type ProtoRequest = proto::DapTerminateRequest; + type ProtoResponse = proto::Ack; + + fn client_id_from_proto(request: &Self::ProtoRequest) -> SessionId { + SessionId::from_proto(request.client_id) + } + + fn from_proto(request: &Self::ProtoRequest) -> Self { + Self { + restart: request.restart, + } + } + + fn to_proto( + &self, + debug_client_id: SessionId, + upstream_project_id: u64, + ) -> proto::DapTerminateRequest { + proto::DapTerminateRequest { + project_id: upstream_project_id, + client_id: debug_client_id.to_proto(), + restart: self.restart, + } + } + + fn response_to_proto( + _debug_client_id: SessionId, + _message: Self::Response, + ) -> Self::ProtoResponse { + proto::Ack {} + } + + fn response_from_proto(&self, _message: Self::ProtoResponse) -> Result { + Ok(()) + } +} + +#[derive(Debug, Hash, PartialEq, Eq)] +pub(crate) struct RestartCommand { + pub raw: serde_json::Value, +} + +impl LocalDapCommand for RestartCommand { + type Response = ::Response; + type DapRequest = dap::requests::Restart; + + fn is_supported(capabilities: &Capabilities) -> bool { + capabilities.supports_restart_request.unwrap_or_default() + } + + fn to_dap(&self) -> ::Arguments { + dap::RestartArguments { + raw: self.raw.clone(), + } + } + + fn response_from_dap( + &self, + _message: ::Response, + ) -> Result { + Ok(()) + } +} + +impl DapCommand for RestartCommand { + type ProtoRequest = proto::DapRestartRequest; + type ProtoResponse = proto::Ack; + + fn client_id_from_proto(request: &Self::ProtoRequest) -> SessionId { + SessionId::from_proto(request.client_id) + } + + fn from_proto(request: &Self::ProtoRequest) -> Self { + Self { + raw: serde_json::from_slice(&request.raw_args) + .log_err() + .unwrap_or(serde_json::Value::Null), + } + } + + fn to_proto( + &self, + debug_client_id: SessionId, + upstream_project_id: u64, + ) -> proto::DapRestartRequest { + let raw_args = serde_json::to_vec(&self.raw).log_err().unwrap_or_default(); + + proto::DapRestartRequest { + project_id: upstream_project_id, + client_id: debug_client_id.to_proto(), + raw_args, + } + } + + fn response_to_proto( + _debug_client_id: SessionId, + _message: Self::Response, + ) -> Self::ProtoResponse { + proto::Ack {} + } + + fn response_from_proto(&self, _message: Self::ProtoResponse) -> Result { + Ok(()) + } +} + +#[derive(Debug, Hash, PartialEq, Eq)] +pub struct VariablesCommand { + pub variables_reference: u64, + pub filter: Option, + pub start: Option, + pub count: Option, + pub format: Option, +} + +impl LocalDapCommand for VariablesCommand { + type Response = Vec; + type DapRequest = dap::requests::Variables; + + fn to_dap(&self) -> ::Arguments { + dap::VariablesArguments { + variables_reference: self.variables_reference, + filter: self.filter, + start: self.start, + count: self.count, + format: self.format.clone(), + } + } + + fn response_from_dap( + &self, + message: ::Response, + ) -> Result { + Ok(message.variables) + } +} + +impl DapCommand for VariablesCommand { + type ProtoRequest = proto::VariablesRequest; + type ProtoResponse = proto::DapVariables; + const CACHEABLE: bool = true; + + fn client_id_from_proto(request: &Self::ProtoRequest) -> SessionId { + SessionId::from_proto(request.client_id) + } + + fn to_proto(&self, debug_client_id: SessionId, upstream_project_id: u64) -> Self::ProtoRequest { + proto::VariablesRequest { + project_id: upstream_project_id, + client_id: debug_client_id.to_proto(), + variables_reference: self.variables_reference, + filter: None, + start: self.start, + count: self.count, + format: None, + } + } + + fn from_proto(request: &Self::ProtoRequest) -> Self { + Self { + variables_reference: request.variables_reference, + filter: None, + start: request.start, + count: request.count, + format: None, + } + } + + fn response_to_proto( + debug_client_id: SessionId, + message: Self::Response, + ) -> Self::ProtoResponse { + proto::DapVariables { + client_id: debug_client_id.to_proto(), + variables: message.to_proto(), + } + } + + fn response_from_proto(&self, message: Self::ProtoResponse) -> Result { + Ok(Vec::from_proto(message.variables)) + } +} + +#[derive(Debug, Hash, PartialEq, Eq)] +pub(crate) struct SetVariableValueCommand { + pub name: String, + pub value: String, + pub variables_reference: u64, +} +impl LocalDapCommand for SetVariableValueCommand { + type Response = SetVariableResponse; + type DapRequest = dap::requests::SetVariable; + fn is_supported(capabilities: &Capabilities) -> bool { + capabilities.supports_set_variable.unwrap_or_default() + } + fn to_dap(&self) -> ::Arguments { + dap::SetVariableArguments { + format: None, + name: self.name.clone(), + value: self.value.clone(), + variables_reference: self.variables_reference, + } + } + fn response_from_dap( + &self, + message: ::Response, + ) -> Result { + Ok(message) + } +} + +impl DapCommand for SetVariableValueCommand { + type ProtoRequest = proto::DapSetVariableValueRequest; + type ProtoResponse = proto::DapSetVariableValueResponse; + + fn client_id_from_proto(request: &Self::ProtoRequest) -> SessionId { + SessionId::from_proto(request.client_id) + } + + fn to_proto(&self, debug_client_id: SessionId, upstream_project_id: u64) -> Self::ProtoRequest { + proto::DapSetVariableValueRequest { + project_id: upstream_project_id, + client_id: debug_client_id.to_proto(), + variables_reference: self.variables_reference, + value: self.value.clone(), + name: self.name.clone(), + } + } + + fn from_proto(request: &Self::ProtoRequest) -> Self { + Self { + variables_reference: request.variables_reference, + name: request.name.clone(), + value: request.value.clone(), + } + } + + fn response_to_proto( + debug_client_id: SessionId, + message: Self::Response, + ) -> Self::ProtoResponse { + proto::DapSetVariableValueResponse { + client_id: debug_client_id.to_proto(), + value: message.value, + variable_type: message.type_, + named_variables: message.named_variables, + variables_reference: message.variables_reference, + indexed_variables: message.indexed_variables, + memory_reference: message.memory_reference, + } + } + + fn response_from_proto(&self, message: Self::ProtoResponse) -> Result { + Ok(SetVariableResponse { + value: message.value, + type_: message.variable_type, + variables_reference: message.variables_reference, + named_variables: message.named_variables, + indexed_variables: message.indexed_variables, + memory_reference: message.memory_reference, + value_location_reference: None, // TODO + }) + } +} + +#[derive(Debug, Clone, Hash, PartialEq, Eq)] +pub(crate) struct RestartStackFrameCommand { + pub stack_frame_id: u64, +} + +impl LocalDapCommand for RestartStackFrameCommand { + type Response = ::Response; + type DapRequest = dap::requests::RestartFrame; + + fn is_supported(capabilities: &Capabilities) -> bool { + capabilities.supports_restart_frame.unwrap_or_default() + } + + fn to_dap(&self) -> ::Arguments { + dap::RestartFrameArguments { + frame_id: self.stack_frame_id, + } + } + + fn response_from_dap( + &self, + _message: ::Response, + ) -> Result { + Ok(()) + } +} + +impl DapCommand for RestartStackFrameCommand { + type ProtoRequest = proto::DapRestartStackFrameRequest; + type ProtoResponse = proto::Ack; + + fn client_id_from_proto(request: &Self::ProtoRequest) -> SessionId { + SessionId::from_proto(request.client_id) + } + + fn from_proto(request: &Self::ProtoRequest) -> Self { + Self { + stack_frame_id: request.stack_frame_id, + } + } + + fn to_proto( + &self, + debug_client_id: SessionId, + upstream_project_id: u64, + ) -> proto::DapRestartStackFrameRequest { + proto::DapRestartStackFrameRequest { + project_id: upstream_project_id, + client_id: debug_client_id.to_proto(), + stack_frame_id: self.stack_frame_id, + } + } + + fn response_to_proto( + _debug_client_id: SessionId, + _message: Self::Response, + ) -> Self::ProtoResponse { + proto::Ack {} + } + + fn response_from_proto(&self, _message: Self::ProtoResponse) -> Result { + Ok(()) + } +} + +#[derive(Debug, Clone, Hash, PartialEq, Eq)] +pub(crate) struct ModulesCommand; + +impl LocalDapCommand for ModulesCommand { + type Response = Vec; + type DapRequest = dap::requests::Modules; + + fn is_supported(capabilities: &Capabilities) -> bool { + capabilities.supports_modules_request.unwrap_or_default() + } + + fn to_dap(&self) -> ::Arguments { + dap::ModulesArguments { + start_module: None, + module_count: None, + } + } + + fn response_from_dap( + &self, + message: ::Response, + ) -> Result { + Ok(message.modules) + } +} + +impl DapCommand for ModulesCommand { + type ProtoRequest = proto::DapModulesRequest; + type ProtoResponse = proto::DapModulesResponse; + const CACHEABLE: bool = true; + + fn client_id_from_proto(request: &Self::ProtoRequest) -> SessionId { + SessionId::from_proto(request.client_id) + } + + fn from_proto(_request: &Self::ProtoRequest) -> Self { + Self {} + } + + fn to_proto( + &self, + debug_client_id: SessionId, + upstream_project_id: u64, + ) -> proto::DapModulesRequest { + proto::DapModulesRequest { + project_id: upstream_project_id, + client_id: debug_client_id.to_proto(), + } + } + + fn response_to_proto( + debug_client_id: SessionId, + message: Self::Response, + ) -> Self::ProtoResponse { + proto::DapModulesResponse { + modules: message + .into_iter() + .map(|module| module.to_proto()) + .collect(), + client_id: debug_client_id.to_proto(), + } + } + + fn response_from_proto(&self, message: Self::ProtoResponse) -> Result { + Ok(message + .modules + .into_iter() + .filter_map(|module| dap::Module::from_proto(module).ok()) + .collect()) + } +} + +#[derive(Debug, Clone, Hash, PartialEq, Eq)] +pub(crate) struct LoadedSourcesCommand; + +impl LocalDapCommand for LoadedSourcesCommand { + type Response = Vec; + type DapRequest = dap::requests::LoadedSources; + + fn is_supported(capabilities: &Capabilities) -> bool { + capabilities + .supports_loaded_sources_request + .unwrap_or_default() + } + fn to_dap(&self) -> ::Arguments { + dap::LoadedSourcesArguments {} + } + + fn response_from_dap( + &self, + message: ::Response, + ) -> Result { + Ok(message.sources) + } +} + +impl DapCommand for LoadedSourcesCommand { + type ProtoRequest = proto::DapLoadedSourcesRequest; + type ProtoResponse = proto::DapLoadedSourcesResponse; + const CACHEABLE: bool = true; + + fn client_id_from_proto(request: &Self::ProtoRequest) -> SessionId { + SessionId::from_proto(request.client_id) + } + + fn from_proto(_request: &Self::ProtoRequest) -> Self { + Self {} + } + + fn to_proto( + &self, + debug_client_id: SessionId, + upstream_project_id: u64, + ) -> proto::DapLoadedSourcesRequest { + proto::DapLoadedSourcesRequest { + project_id: upstream_project_id, + client_id: debug_client_id.to_proto(), + } + } + + fn response_to_proto( + debug_client_id: SessionId, + message: Self::Response, + ) -> Self::ProtoResponse { + proto::DapLoadedSourcesResponse { + sources: message + .into_iter() + .map(|source| source.to_proto()) + .collect(), + client_id: debug_client_id.to_proto(), + } + } + + fn response_from_proto(&self, message: Self::ProtoResponse) -> Result { + Ok(message + .sources + .into_iter() + .map(dap::Source::from_proto) + .collect()) + } +} + +#[derive(Debug, Clone, Hash, PartialEq, Eq)] +pub(crate) struct StackTraceCommand { + pub thread_id: u64, + pub start_frame: Option, + pub levels: Option, +} + +impl LocalDapCommand for StackTraceCommand { + type Response = Vec; + type DapRequest = dap::requests::StackTrace; + + fn to_dap(&self) -> ::Arguments { + dap::StackTraceArguments { + thread_id: self.thread_id, + start_frame: self.start_frame, + levels: self.levels, + format: None, + } + } + + fn response_from_dap( + &self, + message: ::Response, + ) -> Result { + Ok(message.stack_frames) + } +} + +impl DapCommand for StackTraceCommand { + type ProtoRequest = proto::DapStackTraceRequest; + type ProtoResponse = proto::DapStackTraceResponse; + const CACHEABLE: bool = true; + + fn to_proto(&self, debug_client_id: SessionId, upstream_project_id: u64) -> Self::ProtoRequest { + proto::DapStackTraceRequest { + project_id: upstream_project_id, + client_id: debug_client_id.to_proto(), + thread_id: self.thread_id, + start_frame: self.start_frame, + stack_trace_levels: self.levels, + } + } + + fn from_proto(request: &Self::ProtoRequest) -> Self { + Self { + thread_id: request.thread_id, + start_frame: request.start_frame, + levels: request.stack_trace_levels, + } + } + + fn client_id_from_proto(request: &Self::ProtoRequest) -> SessionId { + SessionId::from_proto(request.client_id) + } + + fn response_from_proto(&self, message: Self::ProtoResponse) -> Result { + Ok(message + .frames + .into_iter() + .map(dap::StackFrame::from_proto) + .collect()) + } + + fn response_to_proto( + _debug_client_id: SessionId, + message: Self::Response, + ) -> Self::ProtoResponse { + proto::DapStackTraceResponse { + frames: message.to_proto(), + } + } +} + +#[derive(Debug, Clone, Hash, PartialEq, Eq)] +pub(crate) struct ScopesCommand { + pub stack_frame_id: u64, +} + +impl LocalDapCommand for ScopesCommand { + type Response = Vec; + type DapRequest = dap::requests::Scopes; + + fn to_dap(&self) -> ::Arguments { + dap::ScopesArguments { + frame_id: self.stack_frame_id, + } + } + + fn response_from_dap( + &self, + message: ::Response, + ) -> Result { + Ok(message.scopes) + } +} + +impl DapCommand for ScopesCommand { + type ProtoRequest = proto::DapScopesRequest; + type ProtoResponse = proto::DapScopesResponse; + const CACHEABLE: bool = true; + + fn to_proto(&self, debug_client_id: SessionId, upstream_project_id: u64) -> Self::ProtoRequest { + proto::DapScopesRequest { + project_id: upstream_project_id, + client_id: debug_client_id.to_proto(), + stack_frame_id: self.stack_frame_id, + } + } + + fn from_proto(request: &Self::ProtoRequest) -> Self { + Self { + stack_frame_id: request.stack_frame_id, + } + } + + fn client_id_from_proto(request: &Self::ProtoRequest) -> SessionId { + SessionId::from_proto(request.client_id) + } + + fn response_from_proto(&self, message: Self::ProtoResponse) -> Result { + Ok(Vec::from_proto(message.scopes)) + } + + fn response_to_proto( + _debug_client_id: SessionId, + message: Self::Response, + ) -> Self::ProtoResponse { + proto::DapScopesResponse { + scopes: message.to_proto(), + } + } +} + +impl LocalDapCommand for super::session::CompletionsQuery { + type Response = dap::CompletionsResponse; + type DapRequest = dap::requests::Completions; + + fn to_dap(&self) -> ::Arguments { + dap::CompletionsArguments { + text: self.query.clone(), + frame_id: self.frame_id, + column: self.column, + line: None, + } + } + + fn response_from_dap( + &self, + message: ::Response, + ) -> Result { + Ok(message) + } + + fn is_supported(capabilities: &Capabilities) -> bool { + capabilities + .supports_completions_request + .unwrap_or_default() + } +} + +impl DapCommand for super::session::CompletionsQuery { + type ProtoRequest = proto::DapCompletionRequest; + type ProtoResponse = proto::DapCompletionResponse; + const CACHEABLE: bool = true; + + fn to_proto(&self, debug_client_id: SessionId, upstream_project_id: u64) -> Self::ProtoRequest { + proto::DapCompletionRequest { + client_id: debug_client_id.to_proto(), + project_id: upstream_project_id, + frame_id: self.frame_id, + query: self.query.clone(), + column: self.column, + line: self.line, + } + } + + fn client_id_from_proto(request: &Self::ProtoRequest) -> SessionId { + SessionId::from_proto(request.client_id) + } + + fn from_proto(request: &Self::ProtoRequest) -> Self { + Self { + query: request.query.clone(), + frame_id: request.frame_id, + column: request.column, + line: request.line, + } + } + + fn response_from_proto(&self, message: Self::ProtoResponse) -> Result { + Ok(dap::CompletionsResponse { + targets: Vec::from_proto(message.completions), + }) + } + + fn response_to_proto( + _debug_client_id: SessionId, + message: Self::Response, + ) -> Self::ProtoResponse { + proto::DapCompletionResponse { + client_id: _debug_client_id.to_proto(), + completions: message.targets.to_proto(), + } + } +} + +#[derive(Debug, Clone, Hash, PartialEq, Eq)] +pub(crate) struct EvaluateCommand { + pub expression: String, + pub frame_id: Option, + pub context: Option, + pub source: Option, +} + +impl LocalDapCommand for EvaluateCommand { + type Response = dap::EvaluateResponse; + type DapRequest = dap::requests::Evaluate; + fn to_dap(&self) -> ::Arguments { + dap::EvaluateArguments { + expression: self.expression.clone(), + frame_id: self.frame_id, + context: self.context.clone(), + source: self.source.clone(), + line: None, + column: None, + format: None, + } + } + + fn response_from_dap( + &self, + message: ::Response, + ) -> Result { + Ok(message) + } +} +impl DapCommand for EvaluateCommand { + type ProtoRequest = proto::DapEvaluateRequest; + type ProtoResponse = proto::DapEvaluateResponse; + + fn to_proto(&self, debug_client_id: SessionId, upstream_project_id: u64) -> Self::ProtoRequest { + proto::DapEvaluateRequest { + client_id: debug_client_id.to_proto(), + project_id: upstream_project_id, + expression: self.expression.clone(), + frame_id: self.frame_id, + context: self + .context + .clone() + .map(|context| context.to_proto().into()), + } + } + + fn client_id_from_proto(request: &Self::ProtoRequest) -> SessionId { + SessionId::from_proto(request.client_id) + } + + fn from_proto(request: &Self::ProtoRequest) -> Self { + Self { + expression: request.expression.clone(), + frame_id: request.frame_id, + context: Some(dap::EvaluateArgumentsContext::from_proto(request.context())), + source: None, + } + } + + fn response_from_proto(&self, message: Self::ProtoResponse) -> Result { + Ok(dap::EvaluateResponse { + result: message.result.clone(), + type_: message.evaluate_type.clone(), + presentation_hint: None, + variables_reference: message.variable_reference, + named_variables: message.named_variables, + indexed_variables: message.indexed_variables, + memory_reference: message.memory_reference.clone(), + value_location_reference: None, //TODO + }) + } + + fn response_to_proto( + _debug_client_id: SessionId, + message: Self::Response, + ) -> Self::ProtoResponse { + proto::DapEvaluateResponse { + result: message.result, + evaluate_type: message.type_, + variable_reference: message.variables_reference, + named_variables: message.named_variables, + indexed_variables: message.indexed_variables, + memory_reference: message.memory_reference, + } + } +} + +#[derive(Debug, Clone, Hash, PartialEq, Eq)] +pub(crate) struct ThreadsCommand; + +impl LocalDapCommand for ThreadsCommand { + type Response = Vec; + type DapRequest = dap::requests::Threads; + + fn to_dap(&self) -> ::Arguments { + () + } + + fn response_from_dap( + &self, + message: ::Response, + ) -> Result { + Ok(message.threads) + } +} + +impl DapCommand for ThreadsCommand { + type ProtoRequest = proto::DapThreadsRequest; + type ProtoResponse = proto::DapThreadsResponse; + const CACHEABLE: bool = true; + + fn to_proto(&self, debug_client_id: SessionId, upstream_project_id: u64) -> Self::ProtoRequest { + proto::DapThreadsRequest { + project_id: upstream_project_id, + client_id: debug_client_id.to_proto(), + } + } + + fn from_proto(_request: &Self::ProtoRequest) -> Self { + Self {} + } + + fn client_id_from_proto(request: &Self::ProtoRequest) -> SessionId { + SessionId::from_proto(request.client_id) + } + + fn response_from_proto(&self, message: Self::ProtoResponse) -> Result { + Ok(Vec::from_proto(message.threads)) + } + + fn response_to_proto( + _debug_client_id: SessionId, + message: Self::Response, + ) -> Self::ProtoResponse { + proto::DapThreadsResponse { + threads: message.to_proto(), + } + } +} + +#[derive(Clone, Debug, Hash, PartialEq)] +pub(super) struct Initialize { + pub(super) adapter_id: String, +} + +fn dap_client_capabilities(adapter_id: String) -> InitializeRequestArguments { + InitializeRequestArguments { + client_id: Some("zed".to_owned()), + client_name: Some("Zed".to_owned()), + adapter_id, + locale: Some("en-US".to_owned()), + path_format: Some(InitializeRequestArgumentsPathFormat::Path), + supports_variable_type: Some(true), + supports_variable_paging: Some(false), + supports_run_in_terminal_request: Some(true), + supports_memory_references: Some(true), + supports_progress_reporting: Some(false), + supports_invalidated_event: Some(false), + lines_start_at1: Some(true), + columns_start_at1: Some(true), + supports_memory_event: Some(false), + supports_args_can_be_interpreted_by_shell: Some(false), + supports_start_debugging_request: Some(true), + supports_ansistyling: Some(false), + } +} + +impl LocalDapCommand for Initialize { + type Response = Capabilities; + type DapRequest = dap::requests::Initialize; + + fn to_dap(&self) -> ::Arguments { + dap_client_capabilities(self.adapter_id.clone()) + } + + fn response_from_dap( + &self, + message: ::Response, + ) -> Result { + Ok(message) + } +} + +#[derive(Clone, Debug, Hash, PartialEq)] +pub(super) struct ConfigurationDone; + +impl LocalDapCommand for ConfigurationDone { + type Response = (); + type DapRequest = dap::requests::ConfigurationDone; + + fn is_supported(capabilities: &Capabilities) -> bool { + capabilities + .supports_configuration_done_request + .unwrap_or_default() + } + + fn to_dap(&self) -> ::Arguments { + dap::ConfigurationDoneArguments + } + + fn response_from_dap( + &self, + message: ::Response, + ) -> Result { + Ok(message) + } +} + +#[derive(Clone, Debug, Hash, PartialEq)] +pub(super) struct Launch { + pub(super) raw: Value, +} + +impl LocalDapCommand for Launch { + type Response = (); + type DapRequest = dap::requests::Launch; + + fn to_dap(&self) -> ::Arguments { + dap::LaunchRequestArguments { + raw: self.raw.clone(), + } + } + + fn response_from_dap( + &self, + message: ::Response, + ) -> Result { + Ok(message) + } +} + +#[derive(Clone, Debug, Hash, PartialEq)] +pub(super) struct Attach { + pub(super) raw: Value, +} + +impl LocalDapCommand for Attach { + type Response = (); + type DapRequest = dap::requests::Attach; + + fn to_dap(&self) -> ::Arguments { + dap::AttachRequestArguments { + raw: self.raw.clone(), + } + } + + fn response_from_dap( + &self, + message: ::Response, + ) -> Result { + Ok(message) + } +} + +#[derive(Clone, Debug, Hash, PartialEq)] +pub(super) struct SetBreakpoints { + pub(super) source: dap::Source, + pub(super) breakpoints: Vec, + pub(super) source_modified: Option, +} + +impl LocalDapCommand for SetBreakpoints { + type Response = Vec; + type DapRequest = dap::requests::SetBreakpoints; + + fn to_dap(&self) -> ::Arguments { + dap::SetBreakpointsArguments { + lines: None, + source_modified: self.source_modified, + source: self.source.clone(), + breakpoints: Some(self.breakpoints.clone()), + } + } + + fn response_from_dap( + &self, + message: ::Response, + ) -> Result { + Ok(message.breakpoints) + } +} + +#[derive(Clone, Debug, Hash, PartialEq, Eq)] +pub(super) struct LocationsCommand { + pub(super) reference: u64, +} + +impl LocalDapCommand for LocationsCommand { + type Response = dap::LocationsResponse; + type DapRequest = dap::requests::Locations; + + fn to_dap(&self) -> ::Arguments { + dap::LocationsArguments { + location_reference: self.reference, + } + } + + fn response_from_dap( + &self, + message: ::Response, + ) -> Result { + Ok(message) + } +} + +impl DapCommand for LocationsCommand { + type ProtoRequest = proto::DapLocationsRequest; + type ProtoResponse = proto::DapLocationsResponse; + + const CACHEABLE: bool = true; + + fn client_id_from_proto(message: &Self::ProtoRequest) -> SessionId { + SessionId::from_proto(message.session_id) + } + + fn from_proto(message: &Self::ProtoRequest) -> Self { + Self { + reference: message.location_reference, + } + } + + fn to_proto(&self, session_id: SessionId, project_id: u64) -> Self::ProtoRequest { + proto::DapLocationsRequest { + project_id, + session_id: session_id.to_proto(), + location_reference: self.reference, + } + } + + fn response_to_proto(_: SessionId, response: Self::Response) -> Self::ProtoResponse { + proto::DapLocationsResponse { + source: Some(response.source.to_proto()), + line: response.line, + column: response.column, + end_line: response.end_line, + end_column: response.end_column, + } + } + + fn response_from_proto(&self, response: Self::ProtoResponse) -> Result { + Ok(dap::LocationsResponse { + source: response + .source + .map(::from_proto) + .ok_or_else(|| anyhow!("Missing `source` field in Locations proto"))?, + line: response.line, + column: response.column, + end_line: response.end_line, + end_column: response.end_column, + }) + } +} diff --git a/crates/project/src/debugger/dap_store.rs b/crates/project/src/debugger/dap_store.rs new file mode 100644 index 0000000000..abfea3b47e --- /dev/null +++ b/crates/project/src/debugger/dap_store.rs @@ -0,0 +1,882 @@ +use super::{ + breakpoint_store::BreakpointStore, + // Will need to uncomment this once we implement rpc message handler again + // dap_command::{ + // ContinueCommand, DapCommand, DisconnectCommand, NextCommand, PauseCommand, RestartCommand, + // RestartStackFrameCommand, StepBackCommand, StepCommand, StepInCommand, StepOutCommand, + // TerminateCommand, TerminateThreadsCommand, VariablesCommand, + // }, + session::{self, Session}, +}; +use crate::{debugger, worktree_store::WorktreeStore, ProjectEnvironment}; +use anyhow::{anyhow, Result}; +use async_trait::async_trait; +use collections::HashMap; +use dap::{ + adapters::{DapStatus, DebugAdapterName}, + client::SessionId, + messages::Message, + requests::{ + Completions, Evaluate, Request as _, RunInTerminal, SetExpression, SetVariable, + StartDebugging, + }, + Capabilities, CompletionItem, CompletionsArguments, ErrorResponse, EvaluateArguments, + EvaluateArgumentsContext, EvaluateResponse, RunInTerminalRequestArguments, + SetExpressionArguments, SetVariableArguments, Source, StartDebuggingRequestArguments, + StartDebuggingRequestArgumentsRequest, +}; +use fs::Fs; +use futures::{ + channel::{mpsc, oneshot}, + future::Shared, +}; +use gpui::{App, AppContext, AsyncApp, Context, Entity, EventEmitter, SharedString, Task}; +use http_client::HttpClient; +use language::{BinaryStatus, LanguageRegistry, LanguageToolchainStore}; +use lsp::LanguageServerName; +use node_runtime::NodeRuntime; + +use rpc::{ + proto::{self}, + AnyProtoClient, TypedEnvelope, +}; +use serde_json::Value; +use settings::WorktreeId; +use smol::{lock::Mutex, stream::StreamExt}; +use std::{ + borrow::Borrow, + collections::{BTreeMap, HashSet}, + ffi::OsStr, + path::PathBuf, + sync::{atomic::Ordering::SeqCst, Arc}, +}; +use std::{collections::VecDeque, sync::atomic::AtomicU32}; +use task::{AttachConfig, DebugAdapterConfig, DebugRequestType}; +use util::ResultExt as _; +use worktree::Worktree; + +pub enum DapStoreEvent { + DebugClientStarted(SessionId), + DebugClientShutdown(SessionId), + DebugClientEvent { + session_id: SessionId, + message: Message, + }, + RunInTerminal { + session_id: SessionId, + title: Option, + cwd: PathBuf, + command: Option, + args: Vec, + envs: HashMap, + sender: mpsc::Sender>, + }, + Notification(String), + RemoteHasInitialized, +} + +#[allow(clippy::large_enum_variant)] +pub enum DapStoreMode { + Local(LocalDapStore), // ssh host and collab host + Remote(RemoteDapStore), // collab guest +} + +pub struct LocalDapStore { + fs: Arc, + node_runtime: NodeRuntime, + next_session_id: AtomicU32, + http_client: Arc, + worktree_store: Entity, + environment: Entity, + language_registry: Arc, + toolchain_store: Arc, + start_debugging_tx: futures::channel::mpsc::UnboundedSender<(SessionId, Message)>, + _start_debugging_task: Task<()>, +} + +impl LocalDapStore { + fn next_session_id(&self) -> SessionId { + SessionId(self.next_session_id.fetch_add(1, SeqCst)) + } +} + +pub struct RemoteDapStore { + upstream_client: AnyProtoClient, + upstream_project_id: u64, + event_queue: Option>, +} + +pub struct DapStore { + mode: DapStoreMode, + downstream_client: Option<(AnyProtoClient, u64)>, + breakpoint_store: Entity, + sessions: BTreeMap>, +} + +impl EventEmitter for DapStore {} + +impl DapStore { + pub fn init(_client: &AnyProtoClient) { + // todo(debugger): Reenable these after we finish handle_dap_command refactor + // client.add_entity_request_handler(Self::handle_dap_command::); + // client.add_entity_request_handler(Self::handle_dap_command::); + // client.add_entity_request_handler(Self::handle_dap_command::); + // client.add_entity_request_handler(Self::handle_dap_command::); + // client.add_entity_request_handler(Self::handle_dap_command::); + // client.add_entity_request_handler(Self::handle_dap_command::); + // client.add_entity_request_handler(Self::handle_dap_command::); + // client.add_entity_request_handler(Self::handle_dap_command::); + // client.add_entity_request_handler(Self::handle_dap_command::); + // client.add_entity_request_handler(Self::handle_dap_command::); + // client.add_entity_request_handler(Self::handle_dap_command::); + // client.add_entity_request_handler(Self::handle_dap_command::); + } + + #[expect(clippy::too_many_arguments)] + pub fn new_local( + http_client: Arc, + node_runtime: NodeRuntime, + fs: Arc, + language_registry: Arc, + environment: Entity, + toolchain_store: Arc, + breakpoint_store: Entity, + worktree_store: Entity, + cx: &mut Context, + ) -> Self { + cx.on_app_quit(Self::shutdown_sessions).detach(); + + let (start_debugging_tx, mut message_rx) = + futures::channel::mpsc::unbounded::<(SessionId, Message)>(); + + let _start_debugging_task = cx.spawn(move |this, mut cx| async move { + while let Some((session_id, message)) = message_rx.next().await { + match message { + Message::Request(request) => { + let _ = this + .update(&mut cx, |this, cx| { + if request.command == StartDebugging::COMMAND { + this.handle_start_debugging_request(session_id, request, cx) + .detach_and_log_err(cx); + } else if request.command == RunInTerminal::COMMAND { + this.handle_run_in_terminal_request(session_id, request, cx) + .detach_and_log_err(cx); + } + }) + .log_err(); + } + _ => {} + } + } + }); + Self { + mode: DapStoreMode::Local(LocalDapStore { + fs, + environment, + http_client, + node_runtime, + worktree_store, + toolchain_store, + language_registry, + start_debugging_tx, + _start_debugging_task, + next_session_id: Default::default(), + }), + downstream_client: None, + breakpoint_store, + sessions: Default::default(), + } + } + + pub fn new_remote( + project_id: u64, + upstream_client: AnyProtoClient, + breakpoint_store: Entity, + ) -> Self { + Self { + mode: DapStoreMode::Remote(RemoteDapStore { + upstream_client, + upstream_project_id: project_id, + event_queue: Some(VecDeque::default()), + }), + downstream_client: None, + breakpoint_store, + sessions: Default::default(), + } + } + + pub fn as_remote(&self) -> Option<&RemoteDapStore> { + match &self.mode { + DapStoreMode::Remote(remote_dap_store) => Some(remote_dap_store), + _ => None, + } + } + + pub fn remote_event_queue(&mut self) -> Option> { + if let DapStoreMode::Remote(remote) = &mut self.mode { + remote.event_queue.take() + } else { + None + } + } + + pub fn as_local(&self) -> Option<&LocalDapStore> { + match &self.mode { + DapStoreMode::Local(local_dap_store) => Some(local_dap_store), + _ => None, + } + } + + pub fn as_local_mut(&mut self) -> Option<&mut LocalDapStore> { + match &mut self.mode { + DapStoreMode::Local(local_dap_store) => Some(local_dap_store), + _ => None, + } + } + + pub fn upstream_client(&self) -> Option<(AnyProtoClient, u64)> { + match &self.mode { + DapStoreMode::Remote(RemoteDapStore { + upstream_client, + upstream_project_id, + .. + }) => Some((upstream_client.clone(), *upstream_project_id)), + + DapStoreMode::Local(_) => None, + } + } + + pub fn downstream_client(&self) -> Option<&(AnyProtoClient, u64)> { + self.downstream_client.as_ref() + } + + pub fn add_remote_client( + &mut self, + session_id: SessionId, + ignore: Option, + cx: &mut Context, + ) { + if let DapStoreMode::Remote(remote) = &self.mode { + self.sessions.insert( + session_id, + cx.new(|_| { + debugger::session::Session::remote( + session_id, + remote.upstream_client.clone(), + remote.upstream_project_id, + ignore.unwrap_or(false), + ) + }), + ); + } else { + debug_assert!(false); + } + } + + pub fn session_by_id( + &self, + session_id: impl Borrow, + ) -> Option> { + let session_id = session_id.borrow(); + let client = self.sessions.get(session_id).cloned(); + + client + } + pub fn sessions(&self) -> impl Iterator> { + self.sessions.values() + } + + pub fn capabilities_by_id( + &self, + session_id: impl Borrow, + cx: &App, + ) -> Option { + let session_id = session_id.borrow(); + self.sessions + .get(session_id) + .map(|client| client.read(cx).capabilities.clone()) + } + + pub fn breakpoint_store(&self) -> &Entity { + &self.breakpoint_store + } + + #[allow(dead_code)] + async fn handle_ignore_breakpoint_state( + this: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result<()> { + let session_id = SessionId::from_proto(envelope.payload.session_id); + + this.update(&mut cx, |this, cx| { + if let Some(session) = this.session_by_id(&session_id) { + session.update(cx, |session, cx| { + session.set_ignore_breakpoints(envelope.payload.ignore, cx) + }) + } else { + Task::ready(()) + } + })? + .await; + + Ok(()) + } + + pub fn new_session( + &mut self, + config: DebugAdapterConfig, + worktree: &Entity, + parent_session: Option>, + cx: &mut Context, + ) -> (SessionId, Task>>) { + let Some(local_store) = self.as_local() else { + unimplemented!("Starting session on remote side"); + }; + + let delegate = DapAdapterDelegate::new( + local_store.fs.clone(), + worktree.read(cx).id(), + local_store.node_runtime.clone(), + local_store.http_client.clone(), + local_store.language_registry.clone(), + local_store.toolchain_store.clone(), + local_store.environment.update(cx, |env, cx| { + let worktree = worktree.read(cx); + env.get_environment(Some(worktree.id()), Some(worktree.abs_path()), cx) + }), + ); + let session_id = local_store.next_session_id(); + + let (initialized_tx, initialized_rx) = oneshot::channel(); + + let start_client_task = Session::local( + self.breakpoint_store.clone(), + session_id, + parent_session, + delegate, + config, + local_store.start_debugging_tx.clone(), + initialized_tx, + cx, + ); + + let task = cx.spawn(|this, mut cx| async move { + let session = match start_client_task.await { + Ok(session) => session, + Err(error) => { + this.update(&mut cx, |_, cx| { + cx.emit(DapStoreEvent::Notification(error.to_string())); + }) + .log_err(); + + return Err(error); + } + }; + + // we have to insert the session early, so we can handle reverse requests + // that need the session to be available + this.update(&mut cx, |store, cx| { + store.sessions.insert(session_id, session.clone()); + cx.emit(DapStoreEvent::DebugClientStarted(session_id)); + cx.notify(); + })?; + + match session + .update(&mut cx, |session, cx| { + session.initialize_sequence(initialized_rx, cx) + })? + .await + { + Ok(_) => {} + Err(error) => { + this.update(&mut cx, |this, cx| { + cx.emit(DapStoreEvent::Notification(error.to_string())); + + this.shutdown_session(session_id, cx) + })? + .await + .log_err(); + + return Err(error); + } + } + + Ok(session) + }); + (session_id, task) + } + + fn handle_start_debugging_request( + &mut self, + session_id: SessionId, + request: dap::messages::Request, + cx: &mut Context, + ) -> Task> { + let Some(local_store) = self.as_local() else { + unreachable!("Cannot response for non-local session"); + }; + + let Some(parent_session) = self.session_by_id(session_id) else { + return Task::ready(Err(anyhow!("Session not found"))); + }; + + let args = serde_json::from_value::( + request.arguments.unwrap_or_default(), + ) + .expect("To parse StartDebuggingRequestArguments"); + + let worktree = local_store + .worktree_store + .update(cx, |this, _| this.worktrees().next()) + .expect("worktree-less project"); + + let Some(config) = parent_session.read(cx).configuration() else { + unreachable!("there must be a config for local sessions"); + }; + + let (_, new_session_task) = self.new_session( + DebugAdapterConfig { + label: config.label, + kind: config.kind, + request: match &args.request { + StartDebuggingRequestArgumentsRequest::Launch => DebugRequestType::Launch, + StartDebuggingRequestArgumentsRequest::Attach => { + DebugRequestType::Attach(AttachConfig::default()) + } + }, + program: config.program, + cwd: config.cwd, + initialize_args: Some(args.configuration), + supports_attach: config.supports_attach, + }, + &worktree, + Some(parent_session.clone()), + cx, + ); + + let request_seq = request.seq; + cx.spawn(|_, mut cx| async move { + let (success, body) = match new_session_task.await { + Ok(_) => (true, None), + Err(error) => ( + false, + Some(serde_json::to_value(ErrorResponse { + error: Some(dap::Message { + id: request_seq, + format: error.to_string(), + variables: None, + send_telemetry: None, + show_user: None, + url: None, + url_label: None, + }), + })?), + ), + }; + + parent_session + .update(&mut cx, |session, cx| { + session.respond_to_client( + request_seq, + success, + StartDebugging::COMMAND.to_string(), + body, + cx, + ) + })? + .await + }) + } + + fn handle_run_in_terminal_request( + &mut self, + session_id: SessionId, + request: dap::messages::Request, + cx: &mut Context, + ) -> Task> { + let Some(session) = self.session_by_id(session_id) else { + return Task::ready(Err(anyhow!("Session not found"))); + }; + + let request_args = serde_json::from_value::( + request.arguments.unwrap_or_default(), + ) + .expect("To parse StartDebuggingRequestArguments"); + + let seq = request.seq; + + let cwd = PathBuf::from(request_args.cwd); + match cwd.try_exists() { + Ok(true) => (), + Ok(false) | Err(_) => { + return session.update(cx, |session, cx| { + session.respond_to_client( + seq, + false, + RunInTerminal::COMMAND.to_string(), + serde_json::to_value(dap::ErrorResponse { + error: Some(dap::Message { + id: seq, + format: format!("Received invalid/unknown cwd: {cwd:?}"), + variables: None, + send_telemetry: None, + show_user: None, + url: None, + url_label: None, + }), + }) + .ok(), + cx, + ) + }) + } + } + + let mut args = request_args.args.clone(); + + // Handle special case for NodeJS debug adapter + // If only the Node binary path is provided, we set the command to None + // This prevents the NodeJS REPL from appearing, which is not the desired behavior + // The expected usage is for users to provide their own Node command, e.g., `node test.js` + // This allows the NodeJS debug client to attach correctly + let command = if args.len() > 1 { + Some(args.remove(0)) + } else { + None + }; + + let mut envs: HashMap = Default::default(); + if let Some(Value::Object(env)) = request_args.env { + for (key, value) in env { + let value_str = match (key.as_str(), value) { + (_, Value::String(value)) => value, + _ => continue, + }; + + envs.insert(key, value_str); + } + } + + let (tx, mut rx) = mpsc::channel::>(1); + + cx.emit(DapStoreEvent::RunInTerminal { + session_id, + title: request_args.title, + cwd, + command, + args, + envs, + sender: tx, + }); + cx.notify(); + + let session = session.downgrade(); + cx.spawn(|_, mut cx| async move { + let (success, body) = match rx.next().await { + Some(Ok(pid)) => ( + true, + serde_json::to_value(dap::RunInTerminalResponse { + process_id: None, + shell_process_id: Some(pid as u64), + }) + .ok(), + ), + Some(Err(error)) => ( + false, + serde_json::to_value(dap::ErrorResponse { + error: Some(dap::Message { + id: seq, + format: error.to_string(), + variables: None, + send_telemetry: None, + show_user: None, + url: None, + url_label: None, + }), + }) + .ok(), + ), + None => ( + false, + serde_json::to_value(dap::ErrorResponse { + error: Some(dap::Message { + id: seq, + format: "failed to receive response from spawn terminal".to_string(), + variables: None, + send_telemetry: None, + show_user: None, + url: None, + url_label: None, + }), + }) + .ok(), + ), + }; + + session + .update(&mut cx, |session, cx| { + session.respond_to_client( + seq, + success, + RunInTerminal::COMMAND.to_string(), + body, + cx, + ) + })? + .await + }) + } + + pub fn evaluate( + &self, + session_id: &SessionId, + stack_frame_id: u64, + expression: String, + context: EvaluateArgumentsContext, + source: Option, + cx: &mut Context, + ) -> Task> { + let Some(client) = self + .session_by_id(session_id) + .and_then(|client| client.read(cx).adapter_client()) + else { + return Task::ready(Err(anyhow!("Could not find client: {:?}", session_id))); + }; + + cx.background_executor().spawn(async move { + client + .request::(EvaluateArguments { + expression: expression.clone(), + frame_id: Some(stack_frame_id), + context: Some(context), + format: None, + line: None, + column: None, + source, + }) + .await + }) + } + + pub fn completions( + &self, + session_id: &SessionId, + stack_frame_id: u64, + text: String, + completion_column: u64, + cx: &mut Context, + ) -> Task>> { + let Some(client) = self + .session_by_id(session_id) + .and_then(|client| client.read(cx).adapter_client()) + else { + return Task::ready(Err(anyhow!("Could not find client: {:?}", session_id))); + }; + + cx.background_executor().spawn(async move { + Ok(client + .request::(CompletionsArguments { + frame_id: Some(stack_frame_id), + line: None, + text, + column: completion_column, + }) + .await? + .targets) + }) + } + + #[allow(clippy::too_many_arguments)] + pub fn set_variable_value( + &self, + session_id: &SessionId, + stack_frame_id: u64, + variables_reference: u64, + name: String, + value: String, + evaluate_name: Option, + cx: &mut Context, + ) -> Task> { + let Some(client) = self + .session_by_id(session_id) + .and_then(|client| client.read(cx).adapter_client()) + else { + return Task::ready(Err(anyhow!("Could not find client: {:?}", session_id))); + }; + + let supports_set_expression = self + .capabilities_by_id(session_id, cx) + .and_then(|caps| caps.supports_set_expression) + .unwrap_or_default(); + + cx.background_executor().spawn(async move { + if let Some(evaluate_name) = supports_set_expression.then(|| evaluate_name).flatten() { + client + .request::(SetExpressionArguments { + expression: evaluate_name, + value, + frame_id: Some(stack_frame_id), + format: None, + }) + .await?; + } else { + client + .request::(SetVariableArguments { + variables_reference, + name, + value, + format: None, + }) + .await?; + } + + Ok(()) + }) + } + + // .. get the client and what not + // let _ = client.modules(); // This can fire a request to a dap adapter or be a cheap getter. + // client.wait_for_request(request::Modules); // This ensures that the request that we've fired off runs to completions + // let returned_value = client.modules(); // this is a cheap getter. + + pub fn shutdown_sessions(&mut self, cx: &mut Context) -> Task<()> { + let mut tasks = vec![]; + for session_id in self.sessions.keys().cloned().collect::>() { + tasks.push(self.shutdown_session(session_id, cx)); + } + + cx.background_executor().spawn(async move { + futures::future::join_all(tasks).await; + }) + } + + pub fn shutdown_session( + &mut self, + session_id: SessionId, + cx: &mut Context, + ) -> Task> { + let Some(_) = self.as_local_mut() else { + return Task::ready(Err(anyhow!("Cannot shutdown session on remote side"))); + }; + + let Some(session) = self.sessions.remove(&session_id) else { + return Task::ready(Err(anyhow!("Could not find session: {:?}", session_id))); + }; + + let shutdown_parent_task = session + .read(cx) + .parent_id() + .map(|parent_id| self.shutdown_session(parent_id, cx)); + let shutdown_task = session.update(cx, |this, cx| this.shutdown(cx)); + + cx.background_spawn(async move { + shutdown_task.await; + + if let Some(parent_task) = shutdown_parent_task { + parent_task.await?; + } + + Ok(()) + }) + } + + pub fn shared( + &mut self, + project_id: u64, + downstream_client: AnyProtoClient, + _: &mut Context, + ) { + self.downstream_client = Some((downstream_client.clone(), project_id)); + } + + pub fn unshared(&mut self, cx: &mut Context) { + self.downstream_client.take(); + + cx.notify(); + } +} + +#[derive(Clone)] +pub struct DapAdapterDelegate { + fs: Arc, + worktree_id: WorktreeId, + node_runtime: NodeRuntime, + http_client: Arc, + language_registry: Arc, + toolchain_store: Arc, + updated_adapters: Arc>>, + load_shell_env_task: Shared>>>, +} + +impl DapAdapterDelegate { + pub fn new( + fs: Arc, + worktree_id: WorktreeId, + node_runtime: NodeRuntime, + http_client: Arc, + language_registry: Arc, + toolchain_store: Arc, + load_shell_env_task: Shared>>>, + ) -> Self { + Self { + fs, + worktree_id, + http_client, + node_runtime, + toolchain_store, + language_registry, + load_shell_env_task, + updated_adapters: Default::default(), + } + } +} + +#[async_trait(?Send)] +impl dap::adapters::DapDelegate for DapAdapterDelegate { + fn worktree_id(&self) -> WorktreeId { + self.worktree_id + } + + fn http_client(&self) -> Arc { + self.http_client.clone() + } + + fn node_runtime(&self) -> NodeRuntime { + self.node_runtime.clone() + } + + fn fs(&self) -> Arc { + self.fs.clone() + } + + fn updated_adapters(&self) -> Arc>> { + self.updated_adapters.clone() + } + + fn update_status(&self, dap_name: DebugAdapterName, status: dap::adapters::DapStatus) { + let name = SharedString::from(dap_name.to_string()); + let status = match status { + DapStatus::None => BinaryStatus::None, + DapStatus::Downloading => BinaryStatus::Downloading, + DapStatus::Failed { error } => BinaryStatus::Failed { error }, + DapStatus::CheckingForUpdate => BinaryStatus::CheckingForUpdate, + }; + + self.language_registry + .update_dap_status(LanguageServerName(name), status); + } + + fn which(&self, command: &OsStr) -> Option { + which::which(command).ok() + } + + async fn shell_env(&self) -> HashMap { + let task = self.load_shell_env_task.clone(); + task.await.unwrap_or_default() + } + + fn toolchain_store(&self) -> Arc { + self.toolchain_store.clone() + } +} diff --git a/crates/project/src/debugger/session.rs b/crates/project/src/debugger/session.rs new file mode 100644 index 0000000000..931aba5551 --- /dev/null +++ b/crates/project/src/debugger/session.rs @@ -0,0 +1,1762 @@ +use crate::project_settings::ProjectSettings; + +use super::breakpoint_store::{BreakpointStore, BreakpointStoreEvent, BreakpointUpdatedReason}; +use super::dap_command::{ + self, Attach, ConfigurationDone, ContinueCommand, DapCommand, DisconnectCommand, + EvaluateCommand, Initialize, Launch, LoadedSourcesCommand, LocalDapCommand, LocationsCommand, + ModulesCommand, NextCommand, PauseCommand, RestartCommand, RestartStackFrameCommand, + ScopesCommand, SetVariableValueCommand, StackTraceCommand, StepBackCommand, StepCommand, + StepInCommand, StepOutCommand, TerminateCommand, TerminateThreadsCommand, ThreadsCommand, + VariablesCommand, +}; +use super::dap_store::DapAdapterDelegate; +use anyhow::{anyhow, Result}; +use collections::{HashMap, IndexMap, IndexSet}; +use dap::adapters::{DebugAdapter, DebugAdapterBinary}; +use dap::messages::Response; +use dap::OutputEventCategory; +use dap::{ + adapters::{DapDelegate, DapStatus}, + client::{DebugAdapterClient, SessionId}, + messages::{Events, Message}, + Capabilities, ContinueArguments, EvaluateArgumentsContext, Module, Source, StackFrameId, + SteppingGranularity, StoppedEvent, VariableReference, +}; +use dap_adapters::build_adapter; +use futures::channel::oneshot; +use futures::{future::Shared, FutureExt}; +use gpui::{ + App, AppContext, AsyncApp, BackgroundExecutor, Context, Entity, EventEmitter, Task, WeakEntity, +}; +use rpc::AnyProtoClient; +use serde_json::{json, Value}; +use settings::Settings; +use smol::stream::StreamExt; +use std::any::TypeId; +use std::path::PathBuf; +use std::u64; +use std::{ + any::Any, + collections::hash_map::Entry, + hash::{Hash, Hasher}, + path::Path, + sync::Arc, +}; +use task::DebugAdapterConfig; +use text::{PointUtf16, ToPointUtf16}; +use util::{merge_json_value_into, ResultExt}; + +#[derive(Debug, Copy, Clone, Hash, PartialEq, PartialOrd, Ord, Eq)] +#[repr(transparent)] +pub struct ThreadId(pub u64); + +impl ThreadId { + pub const MIN: ThreadId = ThreadId(u64::MIN); + pub const MAX: ThreadId = ThreadId(u64::MAX); +} + +impl From for ThreadId { + fn from(id: u64) -> Self { + Self(id) + } +} + +#[derive(Clone, Debug)] +pub struct StackFrame { + pub dap: dap::StackFrame, + pub scopes: Vec, +} + +impl From for StackFrame { + fn from(stack_frame: dap::StackFrame) -> Self { + Self { + scopes: vec![], + dap: stack_frame, + } + } +} + +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] +pub enum ThreadStatus { + #[default] + Running, + Stopped, + Stepping, + Exited, + Ended, +} + +impl ThreadStatus { + pub fn label(&self) -> &'static str { + match self { + ThreadStatus::Running => "Running", + ThreadStatus::Stopped => "Stopped", + ThreadStatus::Stepping => "Stepping", + ThreadStatus::Exited => "Exited", + ThreadStatus::Ended => "Ended", + } + } +} + +#[derive(Debug)] +pub struct Thread { + dap: dap::Thread, + stack_frame_ids: IndexSet, + _has_stopped: bool, +} + +impl From for Thread { + fn from(dap: dap::Thread) -> Self { + Self { + dap, + stack_frame_ids: Default::default(), + _has_stopped: false, + } + } +} + +type UpstreamProjectId = u64; + +struct RemoteConnection { + _client: AnyProtoClient, + _upstream_project_id: UpstreamProjectId, +} + +impl RemoteConnection { + fn send_proto_client_request( + &self, + _request: R, + _session_id: SessionId, + cx: &mut App, + ) -> Task> { + // let message = request.to_proto(session_id, self.upstream_project_id); + // let upstream_client = self.client.clone(); + cx.background_executor().spawn(async move { + // debugger(todo): Properly send messages when we wrap dap_commands in envelopes again + // let response = upstream_client.request(message).await?; + // request.response_from_proto(response) + Err(anyhow!("Sending dap commands over RPC isn't supported yet")) + }) + } + + fn request( + &self, + request: R, + session_id: SessionId, + cx: &mut App, + ) -> Task> + where + ::Response: 'static, + ::Arguments: 'static + Send, + { + return self.send_proto_client_request::(request, session_id, cx); + } +} + +enum Mode { + Local(LocalMode), + Remote(RemoteConnection), +} + +#[derive(Clone)] +pub struct LocalMode { + client: Arc, + config: DebugAdapterConfig, + adapter: Arc, + breakpoint_store: Entity, +} + +fn client_source(abs_path: &Path) -> dap::Source { + dap::Source { + name: abs_path + .file_name() + .map(|filename| filename.to_string_lossy().to_string()), + path: Some(abs_path.to_string_lossy().to_string()), + source_reference: None, + presentation_hint: None, + origin: None, + sources: None, + adapter_data: None, + checksums: None, + } +} + +impl LocalMode { + fn new( + session_id: SessionId, + parent_session: Option>, + breakpoint_store: Entity, + config: DebugAdapterConfig, + delegate: DapAdapterDelegate, + messages_tx: futures::channel::mpsc::UnboundedSender, + cx: AsyncApp, + ) -> Task> { + cx.spawn(move |mut cx| async move { + let (adapter, binary) = Self::get_adapter_binary(&config, &delegate, &mut cx).await?; + + let message_handler = Box::new(move |message| { + messages_tx.unbounded_send(message).ok(); + }); + + let client = Arc::new( + if let Some(client) = parent_session + .and_then(|session| cx.update(|cx| session.read(cx).adapter_client()).ok()) + .flatten() + { + client + .reconnect(session_id, binary, message_handler, cx.clone()) + .await? + } else { + DebugAdapterClient::start( + session_id, + adapter.name(), + binary, + message_handler, + cx.clone(), + ) + .await? + }, + ); + + let adapter_id = adapter.name().to_string().to_owned(); + let session = Self { + client, + adapter, + breakpoint_store, + config: config.clone(), + }; + + #[cfg(any(test, feature = "test-support"))] + { + let dap::DebugAdapterKind::Fake((fail, caps)) = session.config.kind.clone() else { + panic!("Only fake debug adapter configs should be used in tests"); + }; + + session + .client + .on_request::(move |_, _| Ok(caps.clone())) + .await; + + match config.request.clone() { + dap::DebugRequestType::Launch if fail => { + session + .client + .on_request::(move |_, _| { + Err(dap::ErrorResponse { + error: Some(dap::Message { + id: 1, + format: "error".into(), + variables: None, + send_telemetry: None, + show_user: None, + url: None, + url_label: None, + }), + }) + }) + .await; + } + dap::DebugRequestType::Launch => { + session + .client + .on_request::(move |_, _| Ok(())) + .await; + } + dap::DebugRequestType::Attach(_) if fail => { + session + .client + .on_request::(move |_, _| { + Err(dap::ErrorResponse { + error: Some(dap::Message { + id: 1, + format: "error".into(), + variables: None, + send_telemetry: None, + show_user: None, + url: None, + url_label: None, + }), + }) + }) + .await; + } + dap::DebugRequestType::Attach(attach_config) => { + session + .client + .on_request::(move |_, args| { + assert_eq!( + json!({"request": "attach", "process_id": attach_config.process_id.unwrap()}), + args.raw + ); + + Ok(()) + }) + .await; + } + } + + session.client.on_request::(move |_, _| Ok(())).await; + session.client.fake_event(Events::Initialized(None)).await; + } + + let capabilities = session + .request(Initialize { adapter_id }, cx.background_executor().clone()) + .await?; + + Ok((session, capabilities)) + }) + } + + fn send_breakpoints_from_path( + &self, + abs_path: Arc, + reason: BreakpointUpdatedReason, + cx: &mut App, + ) -> Task<()> { + let breakpoints = self + .breakpoint_store + .read_with(cx, |store, cx| store.breakpoints_from_path(&abs_path, cx)) + .into_iter() + .map(Into::into) + .collect(); + + let task = self.request( + dap_command::SetBreakpoints { + source: client_source(&abs_path), + source_modified: Some(matches!(reason, BreakpointUpdatedReason::FileSaved)), + breakpoints, + }, + cx.background_executor().clone(), + ); + + cx.background_spawn(async move { + match task.await { + Ok(_) => {} + Err(err) => log::warn!("Set breakpoints request failed for path: {}", err), + } + }) + } + + fn send_all_breakpoints(&self, ignore_breakpoints: bool, cx: &App) -> Task<()> { + let mut breakpoint_tasks = Vec::new(); + let breakpoints = self + .breakpoint_store + .read_with(cx, |store, cx| store.all_breakpoints(cx)); + + for (path, breakpoints) in breakpoints { + let breakpoints = if ignore_breakpoints { + vec![] + } else { + breakpoints.into_iter().map(Into::into).collect() + }; + + breakpoint_tasks.push(self.request( + dap_command::SetBreakpoints { + source: client_source(&path), + source_modified: Some(false), + breakpoints, + }, + cx.background_executor().clone(), + )); + } + + cx.background_spawn(async move { + futures::future::join_all(breakpoint_tasks) + .await + .iter() + .for_each(|res| match res { + Ok(_) => {} + Err(err) => { + log::warn!("Set breakpoints request failed: {}", err); + } + }); + }) + } + + async fn get_adapter_binary( + config: &DebugAdapterConfig, + delegate: &DapAdapterDelegate, + cx: &mut AsyncApp, + ) -> Result<(Arc, DebugAdapterBinary)> { + let adapter = build_adapter(&config.kind).await?; + + let binary = cx.update(|cx| { + ProjectSettings::get_global(cx) + .dap + .get(&adapter.name()) + .and_then(|s| s.binary.as_ref().map(PathBuf::from)) + })?; + + let binary = match adapter.get_binary(delegate, &config, binary, cx).await { + Err(error) => { + delegate.update_status( + adapter.name(), + DapStatus::Failed { + error: error.to_string(), + }, + ); + + return Err(error); + } + Ok(mut binary) => { + delegate.update_status(adapter.name(), DapStatus::None); + + let shell_env = delegate.shell_env().await; + let mut envs = binary.envs.unwrap_or_default(); + envs.extend(shell_env); + binary.envs = Some(envs); + + binary + } + }; + + Ok((adapter, binary)) + } + + pub fn initialize_sequence( + &self, + capabilities: &Capabilities, + initialized_rx: oneshot::Receiver<()>, + cx: &App, + ) -> Task> { + let mut raw = self.adapter.request_args(&self.config); + merge_json_value_into( + self.config.initialize_args.clone().unwrap_or(json!({})), + &mut raw, + ); + + // Of relevance: https://github.com/microsoft/vscode/issues/4902#issuecomment-368583522 + let launch = match &self.config.request { + dap::DebugRequestType::Launch => { + self.request(Launch { raw }, cx.background_executor().clone()) + } + dap::DebugRequestType::Attach(_) => { + self.request(Attach { raw }, cx.background_executor().clone()) + } + }; + + let configuration_done_supported = ConfigurationDone::is_supported(capabilities); + + let configuration_sequence = cx.spawn({ + let this = self.clone(); + move |cx| async move { + initialized_rx.await?; + // todo(debugger) figure out if we want to handle a breakpoint response error + // This will probably consist of letting a user know that breakpoints failed to be set + cx.update(|cx| this.send_all_breakpoints(false, cx))?.await; + + if configuration_done_supported { + this.request(ConfigurationDone, cx.background_executor().clone()) + } else { + Task::ready(Ok(())) + } + .await + } + }); + + cx.background_spawn(async move { + futures::future::try_join(launch, configuration_sequence).await?; + Ok(()) + }) + } + + fn request( + &self, + request: R, + executor: BackgroundExecutor, + ) -> Task> + where + ::Response: 'static, + ::Arguments: 'static + Send, + { + let request = Arc::new(request); + + let request_clone = request.clone(); + let connection = self.client.clone(); + let request_task = executor.spawn(async move { + let args = request_clone.to_dap(); + connection.request::(args).await + }); + + executor.spawn(async move { + let response = request.response_from_dap(request_task.await?); + response + }) + } +} +impl From for Mode { + fn from(value: RemoteConnection) -> Self { + Self::Remote(value) + } +} + +impl Mode { + fn request_dap( + &self, + session_id: SessionId, + request: R, + cx: &mut Context, + ) -> Task> + where + ::Response: 'static, + ::Arguments: 'static + Send, + { + match self { + Mode::Local(debug_adapter_client) => { + debug_adapter_client.request(request, cx.background_executor().clone()) + } + Mode::Remote(remote_connection) => remote_connection.request(request, session_id, cx), + } + } +} + +#[derive(Default)] +struct ThreadStates { + global_state: Option, + known_thread_states: IndexMap, +} + +impl ThreadStates { + fn stop_all_threads(&mut self) { + self.global_state = Some(ThreadStatus::Stopped); + self.known_thread_states.clear(); + } + + fn continue_all_threads(&mut self) { + self.global_state = Some(ThreadStatus::Running); + self.known_thread_states.clear(); + } + + fn stop_thread(&mut self, thread_id: ThreadId) { + self.known_thread_states + .insert(thread_id, ThreadStatus::Stopped); + } + + fn continue_thread(&mut self, thread_id: ThreadId) { + self.known_thread_states + .insert(thread_id, ThreadStatus::Running); + } + + fn process_step(&mut self, thread_id: ThreadId) { + self.known_thread_states + .insert(thread_id, ThreadStatus::Stepping); + } + + fn thread_status(&self, thread_id: ThreadId) -> ThreadStatus { + self.thread_state(thread_id) + .unwrap_or(ThreadStatus::Running) + } + + fn thread_state(&self, thread_id: ThreadId) -> Option { + self.known_thread_states + .get(&thread_id) + .copied() + .or(self.global_state) + } + + fn exit_thread(&mut self, thread_id: ThreadId) { + self.known_thread_states + .insert(thread_id, ThreadStatus::Exited); + } + + fn any_stopped_thread(&self) -> bool { + self.global_state + .is_some_and(|state| state == ThreadStatus::Stopped) + || self + .known_thread_states + .values() + .any(|status| *status == ThreadStatus::Stopped) + } +} +const MAX_TRACKED_OUTPUT_EVENTS: usize = 5000; + +#[derive(Copy, Clone, Default, Debug, PartialEq, PartialOrd, Eq, Ord)] +pub struct OutputToken(pub usize); +/// Represents a current state of a single debug adapter and provides ways to mutate it. +pub struct Session { + mode: Mode, + pub(super) capabilities: Capabilities, + id: SessionId, + parent_id: Option, + ignore_breakpoints: bool, + modules: Vec, + loaded_sources: Vec, + output_token: OutputToken, + output: Box>, + threads: IndexMap, + thread_states: ThreadStates, + variables: HashMap>, + stack_frames: IndexMap, + locations: HashMap, + is_session_terminated: bool, + requests: HashMap>>>>, + _background_tasks: Vec>, +} + +trait CacheableCommand: 'static + Send + Sync { + fn as_any(&self) -> &dyn Any; + fn dyn_eq(&self, rhs: &dyn CacheableCommand) -> bool; + fn dyn_hash(&self, hasher: &mut dyn Hasher); + fn as_any_arc(self: Arc) -> Arc; +} + +impl CacheableCommand for T +where + T: DapCommand + PartialEq + Eq + Hash, +{ + fn as_any(&self) -> &dyn Any { + self + } + + fn dyn_eq(&self, rhs: &dyn CacheableCommand) -> bool { + rhs.as_any() + .downcast_ref::() + .map_or(false, |rhs| self == rhs) + } + + fn dyn_hash(&self, mut hasher: &mut dyn Hasher) { + T::hash(self, &mut hasher); + } + + fn as_any_arc(self: Arc) -> Arc { + self + } +} + +pub(crate) struct RequestSlot(Arc); + +impl From for RequestSlot { + fn from(request: T) -> Self { + Self(Arc::new(request)) + } +} + +impl PartialEq for RequestSlot { + fn eq(&self, other: &Self) -> bool { + self.0.dyn_eq(other.0.as_ref()) + } +} + +impl Eq for RequestSlot {} + +impl Hash for RequestSlot { + fn hash(&self, state: &mut H) { + self.0.dyn_hash(state); + self.0.as_any().type_id().hash(state) + } +} + +#[derive(Debug, Clone, Hash, PartialEq, Eq)] +pub struct CompletionsQuery { + pub query: String, + pub column: u64, + pub line: Option, + pub frame_id: Option, +} + +impl CompletionsQuery { + pub fn new( + buffer: &language::Buffer, + cursor_position: language::Anchor, + frame_id: Option, + ) -> Self { + let PointUtf16 { row, column } = cursor_position.to_point_utf16(&buffer.snapshot()); + Self { + query: buffer.text(), + column: column as u64, + frame_id, + line: Some(row as u64), + } + } +} + +pub enum SessionEvent { + Modules, + LoadedSources, + Stopped(Option), + StackTrace, + Variables, + Threads, +} + +impl EventEmitter for Session {} + +// local session will send breakpoint updates to DAP for all new breakpoints +// remote side will only send breakpoint updates when it is a breakpoint created by that peer +// BreakpointStore notifies session on breakpoint changes +impl Session { + pub(crate) fn local( + breakpoint_store: Entity, + session_id: SessionId, + parent_session: Option>, + delegate: DapAdapterDelegate, + config: DebugAdapterConfig, + start_debugging_requests_tx: futures::channel::mpsc::UnboundedSender<(SessionId, Message)>, + initialized_tx: oneshot::Sender<()>, + cx: &mut App, + ) -> Task>> { + let (message_tx, mut message_rx) = futures::channel::mpsc::unbounded(); + + cx.spawn(move |mut cx| async move { + let (mode, capabilities) = LocalMode::new( + session_id, + parent_session.clone(), + breakpoint_store.clone(), + config.clone(), + delegate, + message_tx, + cx.clone(), + ) + .await?; + + cx.new(|cx| { + let _background_tasks = + vec![cx.spawn(move |this: WeakEntity, mut cx| async move { + let mut initialized_tx = Some(initialized_tx); + while let Some(message) = message_rx.next().await { + if let Message::Event(event) = message { + if let Events::Initialized(_) = *event { + if let Some(tx) = initialized_tx.take() { + tx.send(()).ok(); + } + } else { + let Ok(_) = this.update(&mut cx, |session, cx| { + session.handle_dap_event(event, cx); + }) else { + break; + }; + } + } else { + let Ok(_) = start_debugging_requests_tx + .unbounded_send((session_id, message)) + else { + break; + }; + } + } + })]; + + cx.subscribe(&breakpoint_store, |this, _, event, cx| match event { + BreakpointStoreEvent::BreakpointsUpdated(path, reason) => { + if let Some(local) = (!this.ignore_breakpoints) + .then(|| this.as_local_mut()) + .flatten() + { + local + .send_breakpoints_from_path(path.clone(), *reason, cx) + .detach(); + }; + } + BreakpointStoreEvent::ActiveDebugLineChanged => {} + }) + .detach(); + + Self { + mode: Mode::Local(mode), + id: session_id, + parent_id: parent_session.map(|session| session.read(cx).id), + variables: Default::default(), + capabilities, + thread_states: ThreadStates::default(), + output_token: OutputToken(0), + ignore_breakpoints: false, + output: circular_buffer::CircularBuffer::boxed(), + requests: HashMap::default(), + modules: Vec::default(), + loaded_sources: Vec::default(), + threads: IndexMap::default(), + stack_frames: IndexMap::default(), + locations: Default::default(), + _background_tasks, + is_session_terminated: false, + } + }) + }) + } + + pub(crate) fn remote( + session_id: SessionId, + client: AnyProtoClient, + upstream_project_id: u64, + ignore_breakpoints: bool, + ) -> Self { + Self { + mode: Mode::Remote(RemoteConnection { + _client: client, + _upstream_project_id: upstream_project_id, + }), + id: session_id, + parent_id: None, + capabilities: Capabilities::default(), + ignore_breakpoints, + variables: Default::default(), + stack_frames: Default::default(), + thread_states: ThreadStates::default(), + + output_token: OutputToken(0), + output: circular_buffer::CircularBuffer::boxed(), + requests: HashMap::default(), + modules: Vec::default(), + loaded_sources: Vec::default(), + threads: IndexMap::default(), + _background_tasks: Vec::default(), + locations: Default::default(), + is_session_terminated: false, + } + } + + pub fn session_id(&self) -> SessionId { + self.id + } + + pub fn parent_id(&self) -> Option { + self.parent_id + } + + pub fn capabilities(&self) -> &Capabilities { + &self.capabilities + } + + pub fn configuration(&self) -> Option { + if let Mode::Local(local_mode) = &self.mode { + Some(local_mode.config.clone()) + } else { + None + } + } + + pub fn is_terminated(&self) -> bool { + self.is_session_terminated + } + + pub fn is_local(&self) -> bool { + matches!(self.mode, Mode::Local(_)) + } + + pub fn as_local_mut(&mut self) -> Option<&mut LocalMode> { + match &mut self.mode { + Mode::Local(local_mode) => Some(local_mode), + Mode::Remote(_) => None, + } + } + + pub fn as_local(&self) -> Option<&LocalMode> { + match &self.mode { + Mode::Local(local_mode) => Some(local_mode), + Mode::Remote(_) => None, + } + } + + pub(super) fn initialize_sequence( + &mut self, + initialize_rx: oneshot::Receiver<()>, + cx: &mut Context, + ) -> Task> { + match &self.mode { + Mode::Local(local_mode) => { + local_mode.initialize_sequence(&self.capabilities, initialize_rx, cx) + } + Mode::Remote(_) => Task::ready(Err(anyhow!("cannot initialize remote session"))), + } + } + + pub fn output( + &self, + since: OutputToken, + ) -> (impl Iterator, OutputToken) { + if self.output_token.0 == 0 { + return (self.output.range(0..0), OutputToken(0)); + }; + + let events_since = self.output_token.0.checked_sub(since.0).unwrap_or(0); + + let clamped_events_since = events_since.clamp(0, self.output.len()); + ( + self.output + .range(self.output.len() - clamped_events_since..), + self.output_token, + ) + } + + pub fn respond_to_client( + &self, + request_seq: u64, + success: bool, + command: String, + body: Option, + cx: &mut Context, + ) -> Task> { + let Some(local_session) = self.as_local().cloned() else { + unreachable!("Cannot respond to remote client"); + }; + + cx.background_spawn(async move { + local_session + .client + .send_message(Message::Response(Response { + body, + success, + command, + seq: request_seq + 1, + request_seq, + message: None, + })) + .await + }) + } + + fn handle_stopped_event(&mut self, event: StoppedEvent, cx: &mut Context) { + if event.all_threads_stopped.unwrap_or_default() || event.thread_id.is_none() { + self.thread_states.stop_all_threads(); + + self.invalidate_command_type::(); + } + + // Event if we stopped all threads we still need to insert the thread_id + // to our own data + if let Some(thread_id) = event.thread_id { + self.thread_states.stop_thread(ThreadId(thread_id)); + + self.invalidate_state( + &StackTraceCommand { + thread_id, + start_frame: None, + levels: None, + } + .into(), + ); + } + + self.invalidate_generic(); + self.threads.clear(); + self.variables.clear(); + cx.emit(SessionEvent::Stopped( + event + .thread_id + .map(Into::into) + .filter(|_| !event.preserve_focus_hint.unwrap_or(false)), + )); + cx.notify(); + } + + pub(crate) fn handle_dap_event(&mut self, event: Box, cx: &mut Context) { + match *event { + Events::Initialized(_) => { + debug_assert!( + false, + "Initialized event should have been handled in LocalMode" + ); + } + Events::Stopped(event) => self.handle_stopped_event(event, cx), + Events::Continued(event) => { + if event.all_threads_continued.unwrap_or_default() { + self.thread_states.continue_all_threads(); + } else { + self.thread_states + .continue_thread(ThreadId(event.thread_id)); + } + // todo(debugger): We should be able to get away with only invalidating generic if all threads were continued + self.invalidate_generic(); + } + Events::Exited(_event) => { + self.clear_active_debug_line(cx); + } + Events::Terminated(_) => { + self.is_session_terminated = true; + self.clear_active_debug_line(cx); + } + Events::Thread(event) => { + let thread_id = ThreadId(event.thread_id); + + match event.reason { + dap::ThreadEventReason::Started => { + self.thread_states.continue_thread(thread_id); + } + dap::ThreadEventReason::Exited => { + self.thread_states.exit_thread(thread_id); + } + reason => { + log::error!("Unhandled thread event reason {:?}", reason); + } + } + self.invalidate_state(&ThreadsCommand.into()); + cx.notify(); + } + Events::Output(event) => { + if event + .category + .as_ref() + .is_some_and(|category| *category == OutputEventCategory::Telemetry) + { + return; + } + + self.output.push_back(event); + self.output_token.0 += 1; + cx.notify(); + } + Events::Breakpoint(_) => {} + Events::Module(event) => { + match event.reason { + dap::ModuleEventReason::New => { + self.modules.push(event.module); + } + dap::ModuleEventReason::Changed => { + if let Some(module) = self + .modules + .iter_mut() + .find(|other| event.module.id == other.id) + { + *module = event.module; + } + } + dap::ModuleEventReason::Removed => { + self.modules.retain(|other| event.module.id != other.id); + } + } + + // todo(debugger): We should only send the invalidate command to downstream clients. + // self.invalidate_state(&ModulesCommand.into()); + } + Events::LoadedSource(_) => { + self.invalidate_state(&LoadedSourcesCommand.into()); + } + Events::Capabilities(event) => { + self.capabilities = self.capabilities.merge(event.capabilities); + cx.notify(); + } + Events::Memory(_) => {} + Events::Process(_) => {} + Events::ProgressEnd(_) => {} + Events::ProgressStart(_) => {} + Events::ProgressUpdate(_) => {} + Events::Invalidated(_) => {} + Events::Other(_) => {} + } + } + + /// Ensure that there's a request in flight for the given command, and if not, send it. Use this to run requests that are idempotent. + fn fetch( + &mut self, + request: T, + process_result: impl FnOnce(&mut Self, Result, &mut Context) -> Option + + 'static, + cx: &mut Context, + ) { + const { + assert!( + T::CACHEABLE, + "Only requests marked as cacheable should invoke `fetch`" + ); + } + + if !self.thread_states.any_stopped_thread() + && request.type_id() != TypeId::of::() + { + return; + } + + let request_map = self + .requests + .entry(std::any::TypeId::of::()) + .or_default(); + + if let Entry::Vacant(vacant) = request_map.entry(request.into()) { + let command = vacant.key().0.clone().as_any_arc().downcast::().unwrap(); + + let task = Self::request_inner::>( + &self.capabilities, + self.id, + &self.mode, + command, + process_result, + cx, + ); + let task = cx + .background_executor() + .spawn(async move { + let _ = task.await?; + Some(()) + }) + .shared(); + + vacant.insert(task); + cx.notify(); + } + } + + fn request_inner( + capabilities: &Capabilities, + session_id: SessionId, + mode: &Mode, + request: T, + process_result: impl FnOnce(&mut Self, Result, &mut Context) -> Option + + 'static, + cx: &mut Context, + ) -> Task> { + if !T::is_supported(&capabilities) { + log::warn!( + "Attempted to send a DAP request that isn't supported: {:?}", + request + ); + let error = Err(anyhow::Error::msg( + "Couldn't complete request because it's not supported", + )); + return cx.spawn(|this, mut cx| async move { + this.update(&mut cx, |this, cx| process_result(this, error, cx)) + .log_err() + .flatten() + }); + } + + let request = mode.request_dap(session_id, request, cx); + cx.spawn(|this, mut cx| async move { + let result = request.await; + this.update(&mut cx, |this, cx| process_result(this, result, cx)) + .log_err() + .flatten() + }) + } + + fn request( + &self, + request: T, + process_result: impl FnOnce(&mut Self, Result, &mut Context) -> Option + + 'static, + cx: &mut Context, + ) -> Task> { + Self::request_inner( + &self.capabilities, + self.id, + &self.mode, + request, + process_result, + cx, + ) + } + + fn invalidate_command_type(&mut self) { + self.requests.remove(&std::any::TypeId::of::()); + } + + fn invalidate_generic(&mut self) { + self.invalidate_command_type::(); + self.invalidate_command_type::(); + self.invalidate_command_type::(); + } + + fn invalidate_state(&mut self, key: &RequestSlot) { + self.requests + .entry(key.0.as_any().type_id()) + .and_modify(|request_map| { + request_map.remove(&key); + }); + } + + pub fn thread_status(&self, thread_id: ThreadId) -> ThreadStatus { + self.thread_states.thread_status(thread_id) + } + + pub fn threads(&mut self, cx: &mut Context) -> Vec<(dap::Thread, ThreadStatus)> { + self.fetch( + dap_command::ThreadsCommand, + |this, result, cx| { + let result = result.log_err()?; + + this.threads = result + .iter() + .map(|thread| (ThreadId(thread.id), Thread::from(thread.clone()))) + .collect(); + + this.invalidate_command_type::(); + cx.emit(SessionEvent::Threads); + cx.notify(); + + Some(result) + }, + cx, + ); + + self.threads + .values() + .map(|thread| { + ( + thread.dap.clone(), + self.thread_states.thread_status(ThreadId(thread.dap.id)), + ) + }) + .collect() + } + + pub fn modules(&mut self, cx: &mut Context) -> &[Module] { + self.fetch( + dap_command::ModulesCommand, + |this, result, cx| { + let result = result.log_err()?; + + this.modules = result.iter().cloned().collect(); + cx.emit(SessionEvent::Modules); + cx.notify(); + + Some(result) + }, + cx, + ); + + &self.modules + } + + pub fn ignore_breakpoints(&self) -> bool { + self.ignore_breakpoints + } + + pub fn toggle_ignore_breakpoints(&mut self, cx: &mut App) -> Task<()> { + self.set_ignore_breakpoints(!self.ignore_breakpoints, cx) + } + + pub(crate) fn set_ignore_breakpoints(&mut self, ignore: bool, cx: &mut App) -> Task<()> { + if self.ignore_breakpoints == ignore { + return Task::ready(()); + } + + self.ignore_breakpoints = ignore; + + if let Some(local) = self.as_local() { + local.send_all_breakpoints(ignore, cx) + } else { + // todo(debugger): We need to propagate this change to downstream sessions and send a message to upstream sessions + unimplemented!() + } + } + + pub fn breakpoints_enabled(&self) -> bool { + self.ignore_breakpoints + } + + pub fn loaded_sources(&mut self, cx: &mut Context) -> &[Source] { + self.fetch( + dap_command::LoadedSourcesCommand, + |this, result, cx| { + let result = result.log_err()?; + this.loaded_sources = result.iter().cloned().collect(); + cx.emit(SessionEvent::LoadedSources); + cx.notify(); + Some(result) + }, + cx, + ); + + &self.loaded_sources + } + + fn empty_response(&mut self, res: Result<()>, _cx: &mut Context) -> Option<()> { + res.log_err()?; + Some(()) + } + + fn on_step_response( + thread_id: ThreadId, + ) -> impl FnOnce(&mut Self, Result, &mut Context) -> Option + 'static + { + move |this, response, cx| match response.log_err() { + Some(response) => Some(response), + None => { + this.thread_states.stop_thread(thread_id); + cx.notify(); + None + } + } + } + + fn clear_active_debug_line_response( + &mut self, + response: Result<()>, + cx: &mut Context<'_, Session>, + ) -> Option<()> { + response.log_err()?; + self.clear_active_debug_line(cx); + Some(()) + } + + fn clear_active_debug_line(&mut self, cx: &mut Context) { + self.as_local() + .expect("Message handler will only run in local mode") + .breakpoint_store + .update(cx, |store, cx| { + store.remove_active_position(Some(self.id), cx) + }); + } + + pub fn pause_thread(&mut self, thread_id: ThreadId, cx: &mut Context) { + self.request( + PauseCommand { + thread_id: thread_id.0, + }, + Self::empty_response, + cx, + ) + .detach(); + } + + pub fn restart_stack_frame(&mut self, stack_frame_id: u64, cx: &mut Context) { + self.request( + RestartStackFrameCommand { stack_frame_id }, + Self::empty_response, + cx, + ) + .detach(); + } + + pub fn restart(&mut self, args: Option, cx: &mut Context) { + if self.capabilities.supports_restart_request.unwrap_or(false) { + self.request( + RestartCommand { + raw: args.unwrap_or(Value::Null), + }, + Self::empty_response, + cx, + ) + .detach(); + } else { + self.request( + DisconnectCommand { + restart: Some(false), + terminate_debuggee: Some(true), + suspend_debuggee: Some(false), + }, + Self::empty_response, + cx, + ) + .detach(); + } + } + + pub fn shutdown(&mut self, cx: &mut Context) -> Task<()> { + let task = if self + .capabilities + .supports_terminate_request + .unwrap_or_default() + { + self.request( + TerminateCommand { + restart: Some(false), + }, + Self::clear_active_debug_line_response, + cx, + ) + } else { + self.request( + DisconnectCommand { + restart: Some(false), + terminate_debuggee: Some(true), + suspend_debuggee: Some(false), + }, + Self::clear_active_debug_line_response, + cx, + ) + }; + + cx.background_spawn(async move { + let _ = task.await; + }) + } + + pub fn completions( + &mut self, + query: CompletionsQuery, + cx: &mut Context, + ) -> Task>> { + let task = self.request(query, |_, result, _| result.log_err(), cx); + + cx.background_executor().spawn(async move { + anyhow::Ok( + task.await + .map(|response| response.targets) + .ok_or_else(|| anyhow!("failed to fetch completions"))?, + ) + }) + } + + pub fn continue_thread(&mut self, thread_id: ThreadId, cx: &mut Context) { + self.thread_states.continue_thread(thread_id); + self.request( + ContinueCommand { + args: ContinueArguments { + thread_id: thread_id.0, + single_thread: Some(true), + }, + }, + Self::on_step_response::(thread_id), + cx, + ) + .detach(); + } + + pub fn adapter_client(&self) -> Option> { + match self.mode { + Mode::Local(ref local) => Some(local.client.clone()), + Mode::Remote(_) => None, + } + } + + pub fn step_over( + &mut self, + thread_id: ThreadId, + granularity: SteppingGranularity, + cx: &mut Context, + ) { + let supports_single_thread_execution_requests = + self.capabilities.supports_single_thread_execution_requests; + let supports_stepping_granularity = self + .capabilities + .supports_stepping_granularity + .unwrap_or_default(); + + let command = NextCommand { + inner: StepCommand { + thread_id: thread_id.0, + granularity: supports_stepping_granularity.then(|| granularity), + single_thread: supports_single_thread_execution_requests, + }, + }; + + self.thread_states.process_step(thread_id); + self.request( + command, + Self::on_step_response::(thread_id), + cx, + ) + .detach(); + } + + pub fn step_in( + &mut self, + thread_id: ThreadId, + granularity: SteppingGranularity, + cx: &mut Context, + ) { + let supports_single_thread_execution_requests = + self.capabilities.supports_single_thread_execution_requests; + let supports_stepping_granularity = self + .capabilities + .supports_stepping_granularity + .unwrap_or_default(); + + let command = StepInCommand { + inner: StepCommand { + thread_id: thread_id.0, + granularity: supports_stepping_granularity.then(|| granularity), + single_thread: supports_single_thread_execution_requests, + }, + }; + + self.thread_states.process_step(thread_id); + self.request( + command, + Self::on_step_response::(thread_id), + cx, + ) + .detach(); + } + + pub fn step_out( + &mut self, + thread_id: ThreadId, + granularity: SteppingGranularity, + cx: &mut Context, + ) { + let supports_single_thread_execution_requests = + self.capabilities.supports_single_thread_execution_requests; + let supports_stepping_granularity = self + .capabilities + .supports_stepping_granularity + .unwrap_or_default(); + + let command = StepOutCommand { + inner: StepCommand { + thread_id: thread_id.0, + granularity: supports_stepping_granularity.then(|| granularity), + single_thread: supports_single_thread_execution_requests, + }, + }; + + self.thread_states.process_step(thread_id); + self.request( + command, + Self::on_step_response::(thread_id), + cx, + ) + .detach(); + } + + pub fn step_back( + &mut self, + thread_id: ThreadId, + granularity: SteppingGranularity, + cx: &mut Context, + ) { + let supports_single_thread_execution_requests = + self.capabilities.supports_single_thread_execution_requests; + let supports_stepping_granularity = self + .capabilities + .supports_stepping_granularity + .unwrap_or_default(); + + let command = StepBackCommand { + inner: StepCommand { + thread_id: thread_id.0, + granularity: supports_stepping_granularity.then(|| granularity), + single_thread: supports_single_thread_execution_requests, + }, + }; + + self.thread_states.process_step(thread_id); + + self.request( + command, + Self::on_step_response::(thread_id), + cx, + ) + .detach(); + } + + pub fn stack_frames(&mut self, thread_id: ThreadId, cx: &mut Context) -> Vec { + if self.thread_states.thread_status(thread_id) == ThreadStatus::Stopped + && self.requests.contains_key(&ThreadsCommand.type_id()) + && self.threads.contains_key(&thread_id) + // ^ todo(debugger): We need a better way to check that we're not querying stale data + // We could still be using an old thread id and have sent a new thread's request + // This isn't the biggest concern right now because it hasn't caused any issues outside of tests + // But it very well could cause a minor bug in the future that is hard to track down + { + self.fetch( + super::dap_command::StackTraceCommand { + thread_id: thread_id.0, + start_frame: None, + levels: None, + }, + move |this, stack_frames, cx| { + let stack_frames = stack_frames.log_err()?; + + let entry = this.threads.entry(thread_id).and_modify(|thread| { + thread.stack_frame_ids = + stack_frames.iter().map(|frame| frame.id).collect(); + }); + debug_assert!( + matches!(entry, indexmap::map::Entry::Occupied(_)), + "Sent request for thread_id that doesn't exist" + ); + + this.stack_frames.extend( + stack_frames + .iter() + .cloned() + .map(|frame| (frame.id, StackFrame::from(frame))), + ); + + this.invalidate_command_type::(); + this.invalidate_command_type::(); + + cx.emit(SessionEvent::StackTrace); + cx.notify(); + Some(stack_frames) + }, + cx, + ); + } + + self.threads + .get(&thread_id) + .map(|thread| { + thread + .stack_frame_ids + .iter() + .filter_map(|id| self.stack_frames.get(id)) + .cloned() + .collect() + }) + .unwrap_or_default() + } + + pub fn scopes(&mut self, stack_frame_id: u64, cx: &mut Context) -> &[dap::Scope] { + if self.requests.contains_key(&TypeId::of::()) + && self + .requests + .contains_key(&TypeId::of::()) + { + self.fetch( + ScopesCommand { stack_frame_id }, + move |this, scopes, cx| { + let scopes = scopes.log_err()?; + + for scope in scopes .iter(){ + this.variables(scope.variables_reference, cx); + } + + let entry = this + .stack_frames + .entry(stack_frame_id) + .and_modify(|stack_frame| { + stack_frame.scopes = scopes.clone(); + }); + + cx.emit(SessionEvent::Variables); + + debug_assert!( + matches!(entry, indexmap::map::Entry::Occupied(_)), + "Sent scopes request for stack_frame_id that doesn't exist or hasn't been fetched" + ); + + Some(scopes) + }, + cx, + ); + } + + self.stack_frames + .get(&stack_frame_id) + .map(|frame| frame.scopes.as_slice()) + .unwrap_or_default() + } + + pub fn variables( + &mut self, + variables_reference: VariableReference, + cx: &mut Context, + ) -> Vec { + let command = VariablesCommand { + variables_reference, + filter: None, + start: None, + count: None, + format: None, + }; + + self.fetch( + command, + move |this, variables, cx| { + let variables = variables.log_err()?; + this.variables + .insert(variables_reference, variables.clone()); + + cx.emit(SessionEvent::Variables); + Some(variables) + }, + cx, + ); + + self.variables + .get(&variables_reference) + .cloned() + .unwrap_or_default() + } + + pub fn set_variable_value( + &mut self, + variables_reference: u64, + name: String, + value: String, + cx: &mut Context, + ) { + if self.capabilities.supports_set_variable.unwrap_or_default() { + self.request( + SetVariableValueCommand { + name, + value, + variables_reference, + }, + move |this, response, cx| { + let response = response.log_err()?; + this.invalidate_command_type::(); + cx.notify(); + Some(response) + }, + cx, + ) + .detach() + } + } + + pub fn evaluate( + &mut self, + expression: String, + context: Option, + frame_id: Option, + source: Option, + cx: &mut Context, + ) { + self.request( + EvaluateCommand { + expression, + context, + frame_id, + source, + }, + |this, response, cx| { + let response = response.log_err()?; + this.output.push_back(dap::OutputEvent { + category: None, + output: response.result.clone(), + group: None, + variables_reference: Some(response.variables_reference), + source: None, + line: None, + column: None, + data: None, + location_reference: None, + }); + + this.invalidate_command_type::(); + cx.notify(); + Some(response) + }, + cx, + ) + .detach(); + } + + pub fn location( + &mut self, + reference: u64, + cx: &mut Context, + ) -> Option { + self.fetch( + LocationsCommand { reference }, + move |this, response, _| { + let response = response.log_err()?; + this.locations.insert(reference, response.clone()); + Some(response) + }, + cx, + ); + self.locations.get(&reference).cloned() + } + pub fn disconnect_client(&mut self, cx: &mut Context) { + let command = DisconnectCommand { + restart: Some(false), + terminate_debuggee: Some(true), + suspend_debuggee: Some(false), + }; + + self.request(command, Self::empty_response, cx).detach() + } + + pub fn terminate_threads(&mut self, thread_ids: Option>, cx: &mut Context) { + if self + .capabilities + .supports_terminate_threads_request + .unwrap_or_default() + { + self.request( + TerminateThreadsCommand { + thread_ids: thread_ids.map(|ids| ids.into_iter().map(|id| id.0).collect()), + }, + Self::clear_active_debug_line_response, + cx, + ) + .detach(); + } else { + self.shutdown(cx).detach(); + } + } +} diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index 17b8b7f4bb..b791e15f23 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -40,10 +40,10 @@ use language::{ }, point_to_lsp, proto::{deserialize_anchor, deserialize_version, serialize_anchor, serialize_version}, - range_from_lsp, range_to_lsp, Bias, Buffer, BufferSnapshot, CachedLspAdapter, CodeLabel, - Diagnostic, DiagnosticEntry, DiagnosticSet, Diff, File as _, Language, LanguageRegistry, - LanguageServerBinaryStatus, LanguageToolchainStore, LocalFile, LspAdapter, LspAdapterDelegate, - Patch, PointUtf16, TextBufferSnapshot, ToOffset, ToPointUtf16, Transaction, Unclipped, + range_from_lsp, range_to_lsp, Bias, BinaryStatus, Buffer, BufferSnapshot, CachedLspAdapter, + CodeLabel, Diagnostic, DiagnosticEntry, DiagnosticSet, Diff, File as _, Language, + LanguageRegistry, LanguageToolchainStore, LocalFile, LspAdapter, LspAdapterDelegate, Patch, + PointUtf16, TextBufferSnapshot, ToOffset, ToPointUtf16, Transaction, Unclipped, }; use lsp::{ notification::DidRenameFiles, CodeActionKind, CompletionContext, DiagnosticSeverity, @@ -361,7 +361,7 @@ impl LocalLspStore { let log = stderr_capture.lock().take().unwrap_or_default(); delegate.update_status( adapter.name(), - LanguageServerBinaryStatus::Failed { + BinaryStatus::Failed { error: format!("{err}\n-- stderr--\n{}", log), }, ); @@ -437,7 +437,7 @@ impl LocalLspStore { ) .await; - delegate.update_status(adapter.name.clone(), LanguageServerBinaryStatus::None); + delegate.update_status(adapter.name.clone(), BinaryStatus::None); let mut binary = binary_result?; if let Some(arguments) = settings.and_then(|b| b.arguments) { @@ -9434,11 +9434,7 @@ impl LspAdapterDelegate for LocalLspAdapterDelegate { )) } - fn update_status( - &self, - server_name: LanguageServerName, - status: language::LanguageServerBinaryStatus, - ) { + fn update_status(&self, server_name: LanguageServerName, status: language::BinaryStatus) { self.language_registry .update_lsp_status(server_name, status); } diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index f39381b44a..451a0d101e 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -2,6 +2,7 @@ pub mod buffer_store; mod color_extractor; pub mod connection_manager; pub mod debounced_delay; +pub mod debugger; pub mod git; pub mod image_store; pub mod lsp_command; @@ -28,14 +29,23 @@ pub mod search_history; mod yarn; use crate::git::GitStore; + use anyhow::{anyhow, Context as _, Result}; use buffer_store::{BufferStore, BufferStoreEvent}; use client::{ proto, Client, Collaborator, PendingEntitySubscription, ProjectId, TypedEnvelope, UserStore, }; use clock::ReplicaId; + +use dap::{client::DebugAdapterClient, DebugAdapterConfig}; + use collections::{BTreeSet, HashMap, HashSet}; use debounced_delay::DebouncedDelay; +use debugger::{ + breakpoint_store::BreakpointStore, + dap_store::{DapStore, DapStoreEvent}, + session::Session, +}; pub use environment::ProjectEnvironment; use futures::{ channel::mpsc::{self, UnboundedReceiver}, @@ -47,8 +57,8 @@ use image_store::{ImageItemEvent, ImageStoreEvent}; use ::git::{blame::Blame, repository::GitRepository, status::FileStatus}; use gpui::{ - AnyEntity, App, AppContext as _, AsyncApp, BorrowAppContext, Context, Entity, EventEmitter, - Hsla, SharedString, Task, WeakEntity, Window, + AnyEntity, App, AppContext, AsyncApp, BorrowAppContext, Context, Entity, EventEmitter, Hsla, + SharedString, Task, WeakEntity, Window, }; use itertools::Itertools; use language::{ @@ -86,11 +96,13 @@ use std::{ sync::Arc, time::Duration, }; + use task_store::TaskStore; use terminals::Terminals; use text::{Anchor, BufferId}; use toolchain_store::EmptyToolchainStore; use util::{ + maybe, paths::{compare_paths, SanitizedPath}, ResultExt as _, }; @@ -149,6 +161,8 @@ pub struct Project { active_entry: Option, buffer_ordered_messages_tx: mpsc::UnboundedSender, languages: Arc, + dap_store: Entity, + breakpoint_store: Entity, client: Arc, join_project_response_message_id: u32, task_store: Entity, @@ -286,6 +300,11 @@ pub enum Event { ExpandedAllForEntry(WorktreeId, ProjectEntryId), } +pub enum DebugAdapterClientState { + Starting(Task>>), + Running(Arc), +} + #[derive(Clone, Debug, Eq, PartialEq, Hash, PartialOrd, Ord)] pub struct ProjectPath { pub worktree_id: WorktreeId, @@ -669,6 +688,7 @@ enum EntitySubscription { WorktreeStore(PendingEntitySubscription), LspStore(PendingEntitySubscription), SettingsObserver(PendingEntitySubscription), + DapStore(PendingEntitySubscription), } #[derive(Debug, Clone)] @@ -775,6 +795,8 @@ impl Project { SettingsObserver::init(&client); TaskStore::init(Some(&client)); ToolchainStore::init(&client); + DapStore::init(&client); + BreakpointStore::init(&client); } pub fn local( @@ -795,10 +817,38 @@ impl Project { cx.subscribe(&worktree_store, Self::on_worktree_store_event) .detach(); + let environment = ProjectEnvironment::new(&worktree_store, env, cx); + let toolchain_store = cx.new(|cx| { + ToolchainStore::local( + languages.clone(), + worktree_store.clone(), + environment.clone(), + cx, + ) + }); + let buffer_store = cx.new(|cx| BufferStore::local(worktree_store.clone(), cx)); cx.subscribe(&buffer_store, Self::on_buffer_store_event) .detach(); + let breakpoint_store = + cx.new(|_| BreakpointStore::local(worktree_store.clone(), buffer_store.clone())); + + let dap_store = cx.new(|cx| { + DapStore::new_local( + client.http_client(), + node.clone(), + fs.clone(), + languages.clone(), + environment.clone(), + toolchain_store.read(cx).as_language_toolchain_store(), + breakpoint_store.clone(), + worktree_store.clone(), + cx, + ) + }); + cx.subscribe(&dap_store, Self::on_dap_store_event).detach(); + let image_store = cx.new(|cx| ImageStore::local(worktree_store.clone(), cx)); cx.subscribe(&image_store, Self::on_image_store_event) .detach(); @@ -813,15 +863,6 @@ impl Project { ) }); - let environment = ProjectEnvironment::new(&worktree_store, env, cx); - let toolchain_store = cx.new(|cx| { - ToolchainStore::local( - languages.clone(), - worktree_store.clone(), - environment.clone(), - cx, - ) - }); let task_store = cx.new(|cx| { TaskStore::local( fs.clone(), @@ -891,6 +932,8 @@ impl Project { settings_observer, fs, ssh_client: None, + breakpoint_store, + dap_store, buffers_needing_diff: Default::default(), git_diff_debouncer: DebouncedDelay::new(), terminals: Terminals { @@ -986,6 +1029,17 @@ impl Project { }); cx.subscribe(&lsp_store, Self::on_lsp_store_event).detach(); + let breakpoint_store = + cx.new(|_| BreakpointStore::remote(SSH_PROJECT_ID, client.clone().into())); + + let dap_store = cx.new(|_| { + DapStore::new_remote( + SSH_PROJECT_ID, + client.clone().into(), + breakpoint_store.clone(), + ) + }); + let git_store = cx.new(|cx| { GitStore::ssh( &worktree_store, @@ -1005,6 +1059,8 @@ impl Project { buffer_store, image_store, lsp_store, + breakpoint_store, + dap_store, join_project_response_message_id: 0, client_state: ProjectClientState::Local, git_store, @@ -1056,6 +1112,7 @@ impl Project { ssh.subscribe_to_entity(SSH_PROJECT_ID, &this.buffer_store); ssh.subscribe_to_entity(SSH_PROJECT_ID, &this.worktree_store); ssh.subscribe_to_entity(SSH_PROJECT_ID, &this.lsp_store); + ssh.subscribe_to_entity(SSH_PROJECT_ID, &this.dap_store); ssh.subscribe_to_entity(SSH_PROJECT_ID, &this.settings_observer); ssh.subscribe_to_entity(SSH_PROJECT_ID, &this.git_store); @@ -1071,6 +1128,7 @@ impl Project { SettingsObserver::init(&ssh_proto); TaskStore::init(Some(&ssh_proto)); ToolchainStore::init(&ssh_proto); + DapStore::init(&ssh_proto); GitStore::init(&ssh_proto); this @@ -1116,6 +1174,7 @@ impl Project { EntitySubscription::SettingsObserver( client.subscribe_to_entity::(remote_id)?, ), + EntitySubscription::DapStore(client.subscribe_to_entity::(remote_id)?), ]; let response = client .request_envelope(proto::JoinProject { @@ -1137,7 +1196,7 @@ impl Project { async fn from_join_project_response( response: TypedEnvelope, - subscriptions: [EntitySubscription; 6], + subscriptions: [EntitySubscription; 7], client: Arc, run_tasks: bool, user_store: Entity, @@ -1158,6 +1217,15 @@ impl Project { ImageStore::remote(worktree_store.clone(), client.clone().into(), remote_id, cx) })?; + let environment = cx.update(|cx| ProjectEnvironment::new(&worktree_store, None, cx))?; + + let breakpoint_store = + cx.new(|_| BreakpointStore::remote(remote_id, client.clone().into()))?; + + let dap_store = cx.new(|_cx| { + DapStore::new_remote(remote_id, client.clone().into(), breakpoint_store.clone()) + })?; + let lsp_store = cx.new(|cx| { let mut lsp_store = LspStore::new_remote( buffer_store.clone(), @@ -1229,6 +1297,8 @@ impl Project { cx.subscribe(&settings_observer, Self::on_settings_observer_event) .detach(); + cx.subscribe(&dap_store, Self::on_dap_store_event).detach(); + let mut this = Self { buffer_ordered_messages_tx: tx, buffer_store: buffer_store.clone(), @@ -1254,6 +1324,8 @@ impl Project { remote_id, replica_id, }, + breakpoint_store, + dap_store: dap_store.clone(), git_store: git_store.clone(), buffers_needing_diff: Default::default(), git_diff_debouncer: DebouncedDelay::new(), @@ -1264,7 +1336,7 @@ impl Project { search_history: Self::new_search_history(), search_included_history: Self::new_search_history(), search_excluded_history: Self::new_search_history(), - environment: ProjectEnvironment::new(&worktree_store, None, cx), + environment, remotely_created_models: Arc::new(Mutex::new(RemotelyCreatedModels::default())), toolchain_store: None, }; @@ -1296,6 +1368,9 @@ impl Project { EntitySubscription::LspStore(subscription) => { subscription.set_entity(&lsp_store, &mut cx) } + EntitySubscription::DapStore(subscription) => { + subscription.set_entity(&dap_store, &mut cx) + } }) .collect::>(); @@ -1353,6 +1428,30 @@ impl Project { } } + pub fn start_debug_session( + &mut self, + config: DebugAdapterConfig, + cx: &mut Context, + ) -> Task>> { + let worktree = maybe!({ + if let Some(cwd) = &config.cwd { + Some(self.find_worktree(cwd.as_path(), cx)?.0) + } else { + self.worktrees(cx).next() + } + }); + + let Some(worktree) = &worktree else { + return Task::ready(Err(anyhow!("Failed to find a worktree"))); + }; + + self.dap_store + .update(cx, |dap_store, cx| { + dap_store.new_session(config, worktree, None, cx) + }) + .1 + } + #[cfg(any(test, feature = "test-support"))] pub async fn example( root_paths: impl IntoIterator, @@ -1434,6 +1533,14 @@ impl Project { project } + pub fn dap_store(&self) -> Entity { + self.dap_store.clone() + } + + pub fn breakpoint_store(&self) -> Entity { + self.breakpoint_store.clone() + } + pub fn lsp_store(&self) -> Entity { self.lsp_store.clone() } @@ -1857,6 +1964,12 @@ impl Project { self.client .subscribe_to_entity(project_id)? .set_entity(&self.settings_observer, &mut cx.to_async()), + self.client + .subscribe_to_entity(project_id)? + .set_entity(&self.dap_store, &mut cx.to_async()), + self.client + .subscribe_to_entity(project_id)? + .set_entity(&self.breakpoint_store, &mut cx.to_async()), self.client .subscribe_to_entity(project_id)? .set_entity(&self.git_store, &mut cx.to_async()), @@ -1871,6 +1984,12 @@ impl Project { self.lsp_store.update(cx, |lsp_store, cx| { lsp_store.shared(project_id, self.client.clone().into(), cx) }); + self.breakpoint_store.update(cx, |breakpoint_store, _| { + breakpoint_store.shared(project_id, self.client.clone().into()) + }); + self.dap_store.update(cx, |dap_store, cx| { + dap_store.shared(project_id, self.client.clone().into(), cx); + }); self.task_store.update(cx, |task_store, cx| { task_store.shared(project_id, self.client.clone().into(), cx); }); @@ -1958,6 +2077,12 @@ impl Project { self.task_store.update(cx, |task_store, cx| { task_store.unshared(cx); }); + self.breakpoint_store.update(cx, |breakpoint_store, cx| { + breakpoint_store.unshared(cx); + }); + self.dap_store.update(cx, |dap_store, cx| { + dap_store.unshared(cx); + }); self.settings_observer.update(cx, |settings_observer, cx| { settings_observer.unshared(cx); }); @@ -2105,7 +2230,7 @@ impl Project { cx: &mut Context, ) -> Task, AnyEntity)>> { let task = self.open_buffer(path.clone(), cx); - cx.spawn(move |_, cx| async move { + cx.spawn(move |_project, cx| async move { let buffer = task.await?; let project_entry_id = buffer.read_with(&cx, |buffer, cx| { File::from_dyn(buffer.file()).and_then(|file| file.project_entry_id(cx)) @@ -2469,6 +2594,23 @@ impl Project { } } + fn on_dap_store_event( + &mut self, + _: Entity, + event: &DapStoreEvent, + cx: &mut Context, + ) { + match event { + DapStoreEvent::Notification(message) => { + cx.emit(Event::Toast { + notification_id: "dap".into(), + message: message.clone(), + }); + } + _ => {} + } + } + fn on_lsp_store_event( &mut self, _: Entity, @@ -3875,6 +4017,29 @@ impl Project { None } + pub fn project_path_for_absolute_path(&self, abs_path: &Path, cx: &App) -> Option { + self.find_local_worktree(abs_path, cx) + .map(|(worktree, relative_path)| ProjectPath { + worktree_id: worktree.read(cx).id(), + path: relative_path.into(), + }) + } + + pub fn find_local_worktree( + &self, + abs_path: &Path, + cx: &App, + ) -> Option<(Entity, PathBuf)> { + let trees = self.worktrees(cx); + + for tree in trees { + if let Some(relative_path) = abs_path.strip_prefix(tree.read(cx).abs_path()).ok() { + return Some((tree.clone(), relative_path.into())); + } + } + None + } + pub fn get_workspace_root(&self, project_path: &ProjectPath, cx: &App) -> Option { Some( self.worktree_for_id(project_path.worktree_id, cx)? @@ -3943,6 +4108,7 @@ impl Project { this.buffer_store.update(cx, |buffer_store, _| { buffer_store.forget_shared_buffers_for(&collaborator.peer_id); }); + this.breakpoint_store.read(cx).broadcast(); cx.emit(Event::CollaboratorJoined(collaborator.peer_id)); this.collaborators .insert(collaborator.peer_id, collaborator); diff --git a/crates/project/src/project_settings.rs b/crates/project/src/project_settings.rs index d9fc6cefee..43b17a2d8d 100644 --- a/crates/project/src/project_settings.rs +++ b/crates/project/src/project_settings.rs @@ -1,11 +1,12 @@ use anyhow::Context as _; use collections::HashMap; +use dap::adapters::DebugAdapterName; use fs::Fs; use gpui::{App, AsyncApp, BorrowAppContext, Context, Entity, EventEmitter}; use lsp::LanguageServerName; use paths::{ - local_settings_file_relative_path, local_tasks_file_relative_path, - local_vscode_tasks_file_relative_path, EDITORCONFIG_NAME, + local_debug_file_relative_path, local_settings_file_relative_path, + local_tasks_file_relative_path, local_vscode_tasks_file_relative_path, EDITORCONFIG_NAME, }; use rpc::{ proto::{self, FromProto, ToProto}, @@ -15,7 +16,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use settings::{ parse_json_with_comments, InvalidSettingsError, LocalSettingsKind, Settings, SettingsLocation, - SettingsSources, SettingsStore, + SettingsSources, SettingsStore, TaskKind, }; use std::{path::Path, sync::Arc, time::Duration}; use task::{TaskTemplates, VsCodeTaskFile}; @@ -40,6 +41,10 @@ pub struct ProjectSettings { #[serde(default)] pub lsp: HashMap, + /// Configuration for Debugger-related features + #[serde(default)] + pub dap: HashMap, + /// Configuration for Diagnostics-related features. #[serde(default)] pub diagnostics: DiagnosticsSettings, @@ -61,6 +66,12 @@ pub struct ProjectSettings { pub session: SessionSettings, } +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub struct DapSettings { + pub binary: Option, +} + #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct NodeBinarySettings { /// The path to the Node binary. @@ -483,7 +494,7 @@ impl SettingsObserver { ) .unwrap(), ); - (settings_dir, LocalSettingsKind::Tasks) + (settings_dir, LocalSettingsKind::Tasks(TaskKind::Script)) } else if path.ends_with(local_vscode_tasks_file_relative_path()) { let settings_dir = Arc::::from( path.ancestors() @@ -495,7 +506,19 @@ impl SettingsObserver { ) .unwrap(), ); - (settings_dir, LocalSettingsKind::Tasks) + (settings_dir, LocalSettingsKind::Tasks(TaskKind::Script)) + } else if path.ends_with(local_debug_file_relative_path()) { + let settings_dir = Arc::::from( + path.ancestors() + .nth( + local_debug_file_relative_path() + .components() + .count() + .saturating_sub(1), + ) + .unwrap(), + ); + (settings_dir, LocalSettingsKind::Tasks(TaskKind::Debug)) } else if path.ends_with(EDITORCONFIG_NAME) { let Some(settings_dir) = path.parent().map(Arc::from) else { continue; @@ -616,7 +639,7 @@ impl SettingsObserver { } } }), - LocalSettingsKind::Tasks => task_store.update(cx, |task_store, cx| { + LocalSettingsKind::Tasks(task_kind) => task_store.update(cx, |task_store, cx| { task_store .update_user_tasks( Some(SettingsLocation { @@ -624,6 +647,7 @@ impl SettingsObserver { path: directory.as_ref(), }), file_content.as_deref(), + task_kind, cx, ) .log_err(); @@ -648,7 +672,7 @@ impl SettingsObserver { pub fn local_settings_kind_from_proto(kind: proto::LocalSettingsKind) -> LocalSettingsKind { match kind { proto::LocalSettingsKind::Settings => LocalSettingsKind::Settings, - proto::LocalSettingsKind::Tasks => LocalSettingsKind::Tasks, + proto::LocalSettingsKind::Tasks => LocalSettingsKind::Tasks(TaskKind::Script), proto::LocalSettingsKind::Editorconfig => LocalSettingsKind::Editorconfig, } } @@ -656,7 +680,7 @@ pub fn local_settings_kind_from_proto(kind: proto::LocalSettingsKind) -> LocalSe pub fn local_settings_kind_to_proto(kind: LocalSettingsKind) -> proto::LocalSettingsKind { match kind { LocalSettingsKind::Settings => proto::LocalSettingsKind::Settings, - LocalSettingsKind::Tasks => proto::LocalSettingsKind::Tasks, + LocalSettingsKind::Tasks(_) => proto::LocalSettingsKind::Tasks, LocalSettingsKind::Editorconfig => proto::LocalSettingsKind::Editorconfig, } } diff --git a/crates/project/src/project_tests.rs b/crates/project/src/project_tests.rs index ae99675363..ece3b43801 100644 --- a/crates/project/src/project_tests.rs +++ b/crates/project/src/project_tests.rs @@ -341,6 +341,7 @@ async fn test_managing_project_specific_settings(cx: &mut gpui::TestAppContext) }]) .to_string(), ), + settings::TaskKind::Script, ) .unwrap(); }); diff --git a/crates/project/src/task_inventory.rs b/crates/project/src/task_inventory.rs index b1705242a9..57ca548479 100644 --- a/crates/project/src/task_inventory.rs +++ b/crates/project/src/task_inventory.rs @@ -13,9 +13,10 @@ use collections::{HashMap, HashSet, VecDeque}; use gpui::{App, AppContext as _, Entity, SharedString, Task}; use itertools::Itertools; use language::{ContextProvider, File, Language, LanguageToolchainStore, Location}; -use settings::{parse_json_with_comments, SettingsLocation}; +use settings::{parse_json_with_comments, SettingsLocation, TaskKind}; use task::{ - ResolvedTask, TaskContext, TaskId, TaskTemplate, TaskTemplates, TaskVariables, VariableName, + DebugTaskDefinition, ResolvedTask, TaskContext, TaskId, TaskTemplate, TaskTemplates, + TaskVariables, VariableName, }; use text::{Point, ToPoint}; use util::{paths::PathExt as _, post_inc, NumericPrefixWithSuffix, ResultExt as _}; @@ -33,7 +34,7 @@ pub struct Inventory { #[derive(Debug, Default)] struct ParsedTemplates { global: Vec, - worktree: HashMap, Vec>>, + worktree: HashMap, TaskKind), Vec>>, } /// Kind of a source the tasks are fetched from, used to display more source information in the UI. @@ -328,8 +329,14 @@ impl Inventory { .map(|template| { ( TaskSourceKind::AbsPath { - id_base: Cow::Borrowed("global tasks.json"), - abs_path: paths::tasks_file().clone(), + id_base: match template.task_type { + task::TaskType::Script => Cow::Borrowed("global tasks.json"), + task::TaskType::Debug(_) => Cow::Borrowed("global debug.json"), + }, + abs_path: match template.task_type { + task::TaskType::Script => paths::tasks_file().clone(), + task::TaskType::Debug(_) => paths::debug_tasks_file().clone(), + }, }, template, ) @@ -349,7 +356,7 @@ impl Inventory { .flat_map(|(directory, templates)| { templates.iter().map(move |template| (directory, template)) }) - .map(move |(directory, template)| { + .map(move |((directory, _task_kind), template)| { ( TaskSourceKind::Worktree { id: worktree, @@ -372,13 +379,19 @@ impl Inventory { &mut self, location: Option>, raw_tasks_json: Option<&str>, + task_kind: TaskKind, ) -> anyhow::Result<()> { let raw_tasks = parse_json_with_comments::>(raw_tasks_json.unwrap_or("[]")) .context("parsing tasks file content as a JSON array")?; - let new_templates = raw_tasks.into_iter().filter_map(|raw_template| { - serde_json::from_value::(raw_template).log_err() - }); + let new_templates = raw_tasks + .into_iter() + .filter_map(|raw_template| match &task_kind { + TaskKind::Script => serde_json::from_value::(raw_template).log_err(), + TaskKind::Debug => serde_json::from_value::(raw_template) + .log_err() + .and_then(|content| content.to_zed_format().log_err()), + }); let parsed_templates = &mut self.templates_from_settings; match location { @@ -388,14 +401,14 @@ impl Inventory { if let Some(worktree_tasks) = parsed_templates.worktree.get_mut(&location.worktree_id) { - worktree_tasks.remove(location.path); + worktree_tasks.remove(&(Arc::from(location.path), task_kind)); } } else { parsed_templates .worktree .entry(location.worktree_id) .or_default() - .insert(Arc::from(location.path), new_templates); + .insert((Arc::from(location.path), task_kind), new_templates); } } None => parsed_templates.global = new_templates.collect(), @@ -675,6 +688,7 @@ mod tests { Some(&mock_tasks_from_names( expected_initial_state.iter().map(|name| name.as_str()), )), + settings::TaskKind::Script, ) .unwrap(); }); @@ -730,6 +744,7 @@ mod tests { .into_iter() .chain(expected_initial_state.iter().map(|name| name.as_str())), )), + settings::TaskKind::Script, ) .unwrap(); }); @@ -854,6 +869,7 @@ mod tests { .iter() .map(|(_, name)| name.as_str()), )), + settings::TaskKind::Script, ) .unwrap(); inventory @@ -865,6 +881,7 @@ mod tests { Some(&mock_tasks_from_names( worktree_1_tasks.iter().map(|(_, name)| name.as_str()), )), + settings::TaskKind::Script, ) .unwrap(); inventory @@ -876,6 +893,7 @@ mod tests { Some(&mock_tasks_from_names( worktree_2_tasks.iter().map(|(_, name)| name.as_str()), )), + settings::TaskKind::Script, ) .unwrap(); }); diff --git a/crates/project/src/task_store.rs b/crates/project/src/task_store.rs index 925575c033..0d1ccc270d 100644 --- a/crates/project/src/task_store.rs +++ b/crates/project/src/task_store.rs @@ -10,7 +10,7 @@ use language::{ ContextProvider as _, LanguageToolchainStore, Location, }; use rpc::{proto, AnyProtoClient, TypedEnvelope}; -use settings::{watch_config_file, SettingsLocation}; +use settings::{watch_config_file, SettingsLocation, TaskKind}; use task::{TaskContext, TaskVariables, VariableName}; use text::{BufferId, OffsetRangeExt}; use util::ResultExt; @@ -32,7 +32,7 @@ pub struct StoreState { buffer_store: WeakEntity, worktree_store: Entity, toolchain_store: Arc, - _global_task_config_watcher: Task<()>, + _global_task_config_watchers: (Task<()>, Task<()>), } enum StoreMode { @@ -168,7 +168,20 @@ impl TaskStore { buffer_store, toolchain_store, worktree_store, - _global_task_config_watcher: Self::subscribe_to_global_task_file_changes(fs, cx), + _global_task_config_watchers: ( + Self::subscribe_to_global_task_file_changes( + fs.clone(), + TaskKind::Script, + paths::tasks_file().clone(), + cx, + ), + Self::subscribe_to_global_task_file_changes( + fs.clone(), + TaskKind::Debug, + paths::debug_tasks_file().clone(), + cx, + ), + ), }) } @@ -190,7 +203,20 @@ impl TaskStore { buffer_store, toolchain_store, worktree_store, - _global_task_config_watcher: Self::subscribe_to_global_task_file_changes(fs, cx), + _global_task_config_watchers: ( + Self::subscribe_to_global_task_file_changes( + fs.clone(), + TaskKind::Script, + paths::tasks_file().clone(), + cx, + ), + Self::subscribe_to_global_task_file_changes( + fs.clone(), + TaskKind::Debug, + paths::debug_tasks_file().clone(), + cx, + ), + ), }) } @@ -262,6 +288,7 @@ impl TaskStore { &self, location: Option>, raw_tasks_json: Option<&str>, + task_type: TaskKind, cx: &mut Context<'_, Self>, ) -> anyhow::Result<()> { let task_inventory = match self { @@ -273,22 +300,23 @@ impl TaskStore { .filter(|json| !json.is_empty()); task_inventory.update(cx, |inventory, _| { - inventory.update_file_based_tasks(location, raw_tasks_json) + inventory.update_file_based_tasks(location, raw_tasks_json, task_type) }) } fn subscribe_to_global_task_file_changes( fs: Arc, + task_kind: TaskKind, + file_path: PathBuf, cx: &mut Context<'_, Self>, ) -> Task<()> { - let mut user_tasks_file_rx = - watch_config_file(&cx.background_executor(), fs, paths::tasks_file().clone()); + let mut user_tasks_file_rx = watch_config_file(&cx.background_executor(), fs, file_path); let user_tasks_content = cx.background_executor().block(user_tasks_file_rx.next()); cx.spawn(move |task_store, mut cx| async move { if let Some(user_tasks_content) = user_tasks_content { let Ok(_) = task_store.update(&mut cx, |task_store, cx| { task_store - .update_user_tasks(None, Some(&user_tasks_content), cx) + .update_user_tasks(None, Some(&user_tasks_content), task_kind, cx) .log_err(); }) else { return; @@ -296,12 +324,17 @@ impl TaskStore { } while let Some(user_tasks_content) = user_tasks_file_rx.next().await { let Ok(()) = task_store.update(&mut cx, |task_store, cx| { - let result = task_store.update_user_tasks(None, Some(&user_tasks_content), cx); + let result = task_store.update_user_tasks( + None, + Some(&user_tasks_content), + task_kind, + cx, + ); if let Err(err) = &result { - log::error!("Failed to load user tasks: {err}"); + log::error!("Failed to load user {:?} tasks: {err}", task_kind); cx.emit(crate::Event::Toast { - notification_id: "load-user-tasks".into(), - message: format!("Invalid global tasks file\n{err}"), + notification_id: format!("load-user-{:?}-tasks", task_kind).into(), + message: format!("Invalid global {:?} tasks file\n{err}", task_kind), }); } cx.refresh_windows(); diff --git a/crates/project/src/terminals.rs b/crates/project/src/terminals.rs index 66f625a50f..e34a2381d8 100644 --- a/crates/project/src/terminals.rs +++ b/crates/project/src/terminals.rs @@ -35,6 +35,14 @@ pub enum TerminalKind { Shell(Option), /// Run a task. Task(SpawnInTerminal), + /// Run a debug terminal. + Debug { + command: Option, + args: Vec, + envs: HashMap, + cwd: PathBuf, + title: Option, + }, } /// SshCommand describes how to connect to a remote server @@ -93,6 +101,7 @@ impl Project { self.active_project_directory(cx) } } + TerminalKind::Debug { cwd, .. } => Some(Arc::from(cwd.as_path())), }; let mut settings_location = None; @@ -196,6 +205,7 @@ impl Project { this.active_project_directory(cx) } } + TerminalKind::Debug { cwd, .. } => Some(Arc::from(cwd.as_path())), }; let ssh_details = this.ssh_details(cx); @@ -229,6 +239,7 @@ impl Project { }; let mut python_venv_activate_command = None; + let debug_terminal = matches!(kind, TerminalKind::Debug { .. }); let (spawn_task, shell) = match kind { TerminalKind::Shell(_) => { @@ -324,6 +335,27 @@ impl Project { } } } + TerminalKind::Debug { + command, + args, + envs, + title, + .. + } => { + env.extend(envs); + + let shell = if let Some(program) = command { + Shell::WithArguments { + program, + args, + title_override: Some(title.unwrap_or("Debug Terminal".into()).into()), + } + } else { + settings.shell.clone() + }; + + (None, shell) + } }; TerminalBuilder::new( local_path.map(|path| path.to_path_buf()), @@ -337,6 +369,7 @@ impl Project { ssh_details.is_some(), window, completion_tx, + debug_terminal, cx, ) .map(|builder| { diff --git a/crates/proto/build.rs b/crates/proto/build.rs index d94d80082a..b16aad1b69 100644 --- a/crates/proto/build.rs +++ b/crates/proto/build.rs @@ -2,6 +2,9 @@ fn main() { let mut build = prost_build::Config::new(); build .type_attribute(".", "#[derive(serde::Serialize, serde::Deserialize)]") + .type_attribute("ProjectPath", "#[derive(Hash, Eq)]") + .type_attribute("Breakpoint", "#[derive(Hash, Eq)]") + .type_attribute("Anchor", "#[derive(Hash, Eq)]") .compile_protos(&["proto/zed.proto"], &["proto"]) .unwrap(); } diff --git a/crates/proto/proto/zed.proto b/crates/proto/proto/zed.proto index f4b8366cd0..f6030e19d4 100644 --- a/crates/proto/proto/zed.proto +++ b/crates/proto/proto/zed.proto @@ -315,13 +315,15 @@ message Envelope { OpenUncommittedDiff open_uncommitted_diff = 297; OpenUncommittedDiffResponse open_uncommitted_diff_response = 298; + + SetIndexText set_index_text = 299; + GitShow git_show = 300; GitReset git_reset = 301; GitCommitDetails git_commit_details = 302; - - SetIndexText set_index_text = 299; GitCheckoutFiles git_checkout_files = 303; + Push push = 304; Fetch fetch = 305; GetRemotes get_remotes = 306; @@ -345,13 +347,17 @@ message Envelope { GitDiff git_diff = 319; GitDiffResponse git_diff_response = 320; - GitInit git_init = 321; CodeLens code_lens = 322; GetCodeLens get_code_lens = 323; GetCodeLensResponse get_code_lens_response = 324; - RefreshCodeLens refresh_code_lens = 325; // current max + RefreshCodeLens refresh_code_lens = 325; + + ToggleBreakpoint toggle_breakpoint = 326; + BreakpointsForFile breakpoints_for_file = 327; // current max + + } reserved 87 to 88; @@ -1798,6 +1804,7 @@ message UpdateActiveView { enum PanelId { AssistantPanel = 0; + DebugPanel = 1; } message UpdateView { @@ -1852,6 +1859,7 @@ message View { } } + message Collaborator { PeerId peer_id = 1; uint32 replica_id = 2; @@ -2592,6 +2600,531 @@ message GetLlmTokenResponse { message RefreshLlmToken {} +// Remote debugging + +enum BreakpointKind { + Standard = 0; + Log = 1; +} + + +message Breakpoint { + Anchor position = 1; + BreakpointKind kind = 3; + optional string message = 4; +} + +message BreakpointsForFile { + uint64 project_id = 1; + string path = 2; + repeated Breakpoint breakpoints = 3; +} + +message ToggleBreakpoint { + uint64 project_id = 1; + string path = 2; + Breakpoint breakpoint = 3; +} + +enum DebuggerThreadItem { + Console = 0; + LoadedSource = 1; + Modules = 2; + Variables = 3; +} + +message DebuggerSetVariableState { + string name = 1; + DapScope scope = 2; + string value = 3; + uint64 stack_frame_id = 4; + optional string evaluate_name = 5; + uint64 parent_variables_reference = 6; +} + +message VariableListOpenEntry { + oneof entry { + DebuggerOpenEntryScope scope = 1; + DebuggerOpenEntryVariable variable = 2; + } +} + +message DebuggerOpenEntryScope { + string name = 1; +} + +message DebuggerOpenEntryVariable { + string scope_name = 1; + string name = 2; + uint64 depth = 3; +} + +message VariableListEntrySetState { + uint64 depth = 1; + DebuggerSetVariableState state = 2; +} + +message VariableListEntryVariable { + uint64 depth = 1; + DapScope scope = 2; + DapVariable variable = 3; + bool has_children = 4; + uint64 container_reference = 5; +} + +message DebuggerScopeVariableIndex { + repeated uint64 fetched_ids = 1; + repeated DebuggerVariableContainer variables = 2; +} + +message DebuggerVariableContainer { + uint64 container_reference = 1; + DapVariable variable = 2; + uint64 depth = 3; +} + +enum DapThreadStatus { + Running = 0; + Stopped = 1; + Exited = 2; + Ended = 3; +} + +message VariableListScopes { + uint64 stack_frame_id = 1; + repeated DapScope scopes = 2; +} + +message VariableListVariables { + uint64 stack_frame_id = 1; + uint64 scope_id = 2; + DebuggerScopeVariableIndex variables = 3; +} + + +enum VariablesArgumentsFilter { + Indexed = 0; + Named = 1; +} + +message ValueFormat { + optional bool hex = 1; +} + +message VariablesRequest { + uint64 project_id = 1; + uint64 client_id = 2; + uint64 variables_reference = 3; + optional VariablesArgumentsFilter filter = 4; + optional uint64 start = 5; + optional uint64 count = 6; + optional ValueFormat format = 7; +} + +enum SteppingGranularity { + Statement = 0; + Line = 1; + Instruction = 2; +} + +message DapLocationsRequest { + uint64 project_id = 1; + uint64 session_id = 2; + uint64 location_reference = 3; +} + +message DapLocationsResponse { + DapSource source = 1; + uint64 line = 2; + optional uint64 column = 3; + optional uint64 end_line = 4; + optional uint64 end_column = 5; +} + +enum DapEvaluateContext { + Repl = 0; + Watch = 1; + Hover = 2; + Clipboard = 3; + EvaluateVariables = 4; + EvaluateUnknown = 5; +} + +message DapEvaluateRequest { + uint64 project_id = 1; + uint64 client_id = 2; + string expression = 3; + optional uint64 frame_id = 4; + optional DapEvaluateContext context = 5; +} + +message DapEvaluateResponse { + string result = 1; + optional string evaluate_type = 2; + uint64 variable_reference = 3; + optional uint64 named_variables = 4; + optional uint64 indexed_variables = 5; + optional string memory_reference = 6; +} + + +message DapCompletionRequest { + uint64 project_id = 1; + uint64 client_id = 2; + string query = 3; + optional uint64 frame_id = 4; + optional uint64 line = 5; + uint64 column = 6; +} + +enum DapCompletionItemType { + Method = 0; + Function = 1; + Constructor = 2; + Field = 3; + Variable = 4; + Class = 5; + Interface = 6; + Module = 7; + Property = 8; + Unit = 9; + Value = 10; + Enum = 11; + Keyword = 12; + Snippet = 13; + Text = 14; + Color = 15; + CompletionItemFile = 16; + Reference = 17; + Customcolor = 19; +} + +message DapCompletionItem { + string label = 1; + optional string text = 2; + optional string sort_text = 3; + optional string detail = 4; + optional DapCompletionItemType typ = 5; + optional uint64 start = 6; + optional uint64 length = 7; + optional uint64 selection_start = 8; + optional uint64 selection_length = 9; +} + +message DapCompletionResponse { + uint64 client_id = 1; + repeated DapCompletionItem completions = 2; +} + +message DapScopesRequest { + uint64 project_id = 1; + uint64 client_id = 2; + uint64 stack_frame_id = 3; +} + +message DapScopesResponse { + repeated DapScope scopes = 1; +} + +message DapSetVariableValueRequest { + uint64 project_id = 1; + uint64 client_id = 2; + string name = 3; + string value = 4; + uint64 variables_reference = 5; +} + +message DapSetVariableValueResponse { + uint64 client_id = 1; + string value = 2; + optional string variable_type = 3; + optional uint64 variables_reference = 4; + optional uint64 named_variables = 5; + optional uint64 indexed_variables = 6; + optional string memory_reference = 7; +} + +message DapPauseRequest { + uint64 project_id = 1; + uint64 client_id = 2; + uint64 thread_id = 3; +} + +message DapDisconnectRequest { + uint64 project_id = 1; + uint64 client_id = 2; + optional bool restart = 3; + optional bool terminate_debuggee = 4; + optional bool suspend_debuggee = 5; +} + +message DapTerminateThreadsRequest { + uint64 project_id = 1; + uint64 client_id = 2; + repeated uint64 thread_ids = 3; +} + +message DapThreadsRequest { + uint64 project_id = 1; + uint64 client_id = 2; +} + +message DapThreadsResponse { + repeated DapThread threads = 1; +} + +message DapTerminateRequest { + uint64 project_id = 1; + uint64 client_id = 2; + optional bool restart = 3; +} + +message DapRestartRequest { + uint64 project_id = 1; + uint64 client_id = 2; + bytes raw_args = 3; +} + +message DapRestartStackFrameRequest { + uint64 project_id = 1; + uint64 client_id = 2; + uint64 stack_frame_id = 3; +} + +message ToggleIgnoreBreakpoints { + uint64 project_id = 1; + uint32 session_id = 2; +} + +message IgnoreBreakpointState { + uint64 project_id = 1; + uint64 session_id = 2; + bool ignore = 3; +} + +message DapNextRequest { + uint64 project_id = 1; + uint64 client_id = 2; + uint64 thread_id = 3; + optional bool single_thread = 4; + optional SteppingGranularity granularity = 5; +} + +message DapStepInRequest { + uint64 project_id = 1; + uint64 client_id = 2; + uint64 thread_id = 3; + optional uint64 target_id = 4; + optional bool single_thread = 5; + optional SteppingGranularity granularity = 6; +} + +message DapStepOutRequest { + uint64 project_id = 1; + uint64 client_id = 2; + uint64 thread_id = 3; + optional bool single_thread = 4; + optional SteppingGranularity granularity = 5; +} + +message DapStepBackRequest { + uint64 project_id = 1; + uint64 client_id = 2; + uint64 thread_id = 3; + optional bool single_thread = 4; + optional SteppingGranularity granularity = 5; +} + +message DapContinueRequest { + uint64 project_id = 1; + uint64 client_id = 2; + uint64 thread_id = 3; + optional bool single_thread = 4; +} + +message DapContinueResponse { + uint64 client_id = 1; + optional bool all_threads_continued = 2; +} + +message DapModulesRequest { + uint64 project_id = 1; + uint64 client_id = 2; +} + +message DapModulesResponse { + uint64 client_id = 1; + repeated DapModule modules = 2; +} + +message DapLoadedSourcesRequest { + uint64 project_id = 1; + uint64 client_id = 2; +} + +message DapLoadedSourcesResponse { + uint64 client_id = 1; + repeated DapSource sources = 2; +} + +message DapStackTraceRequest { + uint64 project_id = 1; + uint64 client_id = 2; + uint64 thread_id = 3; + optional uint64 start_frame = 4; + optional uint64 stack_trace_levels = 5; +} + +message DapStackTraceResponse { + repeated DapStackFrame frames = 1; +} + +message DapStackFrame { + uint64 id = 1; + string name = 2; + optional DapSource source = 3; + uint64 line = 4; + uint64 column = 5; + optional uint64 end_line = 6; + optional uint64 end_column = 7; + optional bool can_restart = 8; + optional string instruction_pointer_reference = 9; + optional DapModuleId module_id = 10; + optional DapStackPresentationHint presentation_hint = 11; +} + +message DebuggerLoadedSourceList { + uint64 client_id = 1; + repeated DapSource sources = 2; +} + +message DapVariables { + uint64 client_id = 1; + repeated DapVariable variables = 2; +} + +// Remote Debugging: Dap Types +message DapVariable { + string name = 1; + string value = 2; + optional string type = 3; + // optional DapVariablePresentationHint presentation_hint = 4; + optional string evaluate_name = 5; + uint64 variables_reference = 6; + optional uint64 named_variables = 7; + optional uint64 indexed_variables = 8; + optional string memory_reference = 9; +} + +message DapThread { + uint64 id = 1; + string name = 2; +} + +message DapScope { + string name = 1; + optional DapScopePresentationHint presentation_hint = 2; + uint64 variables_reference = 3; + optional uint64 named_variables = 4; + optional uint64 indexed_variables = 5; + bool expensive = 6; + optional DapSource source = 7; + optional uint64 line = 8; + optional uint64 column = 9; + optional uint64 end_line = 10; + optional uint64 end_column = 11; +} + +message DapSource { + optional string name = 1; + optional string path = 2; + optional uint64 source_reference = 3; + optional DapSourcePresentationHint presentation_hint = 4; + optional string origin = 5; + repeated DapSource sources = 6; + optional bytes adapter_data = 7; + repeated DapChecksum checksums = 8; +} + +enum DapOutputCategory { + ConsoleOutput = 0; + Important = 1; + Stdout = 2; + Stderr = 3; + Unknown = 4; +} + +enum DapOutputEventGroup { + Start = 0; + StartCollapsed = 1; + End = 2; +} + +message DapOutputEvent { + string output = 1; + optional DapOutputCategory category = 2; + optional uint64 variables_reference = 3; + optional DapOutputEventGroup group = 4; + optional DapSource source = 5; + optional uint32 line = 6; + optional uint32 column = 7; +} + +enum DapChecksumAlgorithm { + CHECKSUM_ALGORITHM_UNSPECIFIED = 0; + MD5 = 1; + SHA1 = 2; + SHA256 = 3; + TIMESTAMP = 4; +} + +message DapChecksum { + DapChecksumAlgorithm algorithm = 1; + string checksum = 2; +} + +enum DapScopePresentationHint { + Arguments = 0; + Locals = 1; + Registers = 2; + ReturnValue = 3; + ScopeUnknown = 4; +} + +enum DapSourcePresentationHint { + SourceNormal = 0; + Emphasize = 1; + Deemphasize = 2; + SourceUnknown = 3; +} + +enum DapStackPresentationHint { + StackNormal = 0; + Label = 1; + Subtle = 2; + StackUnknown = 3; +} + +message DapModule { + DapModuleId id = 1; + string name = 2; + optional string path = 3; + optional bool is_optimized = 4; + optional bool is_user_code = 5; + optional string version = 6; + optional string symbol_status = 7; + optional string symbol_file_path = 8; + optional string date_time_stamp = 9; + optional string address_range = 10; +} + +message DapModuleId { + oneof id { + uint32 number = 1; + string string = 2; + } +} + // Remote FS message AddWorktree { diff --git a/crates/proto/src/proto.rs b/crates/proto/src/proto.rs index 53e1a8f653..a612dcaac5 100644 --- a/crates/proto/src/proto.rs +++ b/crates/proto/src/proto.rs @@ -200,38 +200,54 @@ messages!( (Ack, Foreground), (AckBufferOperation, Background), (AckChannelMessage, Background), + (ActivateToolchain, Foreground), + (ActiveToolchain, Foreground), + (ActiveToolchainResponse, Foreground), (AddNotification, Foreground), (AddProjectCollaborator, Foreground), + (AddWorktree, Foreground), + (AddWorktreeResponse, Foreground), + (AdvertiseContexts, Foreground), (ApplyCodeAction, Background), (ApplyCodeActionResponse, Background), (ApplyCompletionAdditionalEdits, Background), (ApplyCompletionAdditionalEditsResponse, Background), + (BlameBuffer, Foreground), + (BlameBufferResponse, Foreground), (BufferReloaded, Foreground), (BufferSaved, Foreground), (Call, Foreground), (CallCanceled, Foreground), (CancelCall, Foreground), + (CancelLanguageServerWork, Foreground), (ChannelMessageSent, Foreground), (ChannelMessageUpdate, Foreground), + (CloseBuffer, Foreground), (Commit, Background), (ComputeEmbeddings, Background), (ComputeEmbeddingsResponse, Background), (CopyProjectEntry, Foreground), + (CountLanguageModelTokens, Background), + (CountLanguageModelTokensResponse, Background), (CreateBufferForPeer, Foreground), (CreateChannel, Foreground), (CreateChannelResponse, Foreground), + (CreateContext, Foreground), + (CreateContextResponse, Foreground), (CreateProjectEntry, Foreground), (CreateRoom, Foreground), (CreateRoomResponse, Foreground), (DeclineCall, Foreground), (DeleteChannel, Foreground), (DeleteNotification, Foreground), - (UpdateNotification, Foreground), (DeleteProjectEntry, Foreground), (EndStream, Foreground), (Error, Foreground), (ExpandProjectEntry, Foreground), (ExpandProjectEntryResponse, Foreground), + (FindSearchCandidatesResponse, Background), + (FindSearchCandidates, Background), + (FlushBufferedMessages, Foreground), (ExpandAllForProjectEntry, Foreground), (ExpandAllForProjectEntryResponse, Foreground), (Follow, Foreground), @@ -252,16 +268,22 @@ messages!( (GetCodeActionsResponse, Background), (GetCompletions, Background), (GetCompletionsResponse, Background), - (GetDefinition, Background), - (GetDefinitionResponse, Background), (GetDeclaration, Background), (GetDeclarationResponse, Background), + (GetDefinition, Background), + (GetDefinitionResponse, Background), (GetDocumentHighlights, Background), (GetDocumentHighlightsResponse, Background), (GetHover, Background), (GetHoverResponse, Background), (GetNotifications, Foreground), (GetNotificationsResponse, Foreground), + (GetPanicFiles, Background), + (GetPanicFilesResponse, Background), + (GetPathMetadata, Background), + (GetPathMetadataResponse, Background), + (GetPermalinkToLine, Foreground), + (GetPermalinkToLineResponse, Foreground), (GetPrivateUserInfo, Foreground), (GetPrivateUserInfoResponse, Foreground), (GetProjectSymbols, Background), @@ -283,10 +305,14 @@ messages!( (OpenUncommittedDiff, Foreground), (OpenUncommittedDiffResponse, Foreground), (GetUsers, Foreground), + (GitGetBranches, Background), + (GitBranchesResponse, Background), (Hello, Foreground), + (HideToast, Background), (IncomingCall, Foreground), (InlayHints, Background), (InlayHintsResponse, Background), + (InstallExtension, Background), (InviteChannelMember, Foreground), (JoinChannel, Foreground), (JoinChannelBuffer, Foreground), @@ -297,12 +323,29 @@ messages!( (JoinProjectResponse, Foreground), (JoinRoom, Foreground), (JoinRoomResponse, Foreground), + (LanguageServerLog, Foreground), + (LanguageServerPromptRequest, Foreground), + (LanguageServerPromptResponse, Foreground), (LeaveChannelBuffer, Background), (LeaveChannelChat, Foreground), (LeaveProject, Foreground), (LeaveRoom, Foreground), + (LinkedEditingRange, Background), + (LinkedEditingRangeResponse, Background), + (ListRemoteDirectory, Background), + (ListRemoteDirectoryResponse, Background), + (ListToolchains, Foreground), + (ListToolchainsResponse, Foreground), + (LspExtExpandMacro, Background), + (LspExtExpandMacroResponse, Background), + (LspExtOpenDocs, Background), + (LspExtOpenDocsResponse, Background), + (LspExtSwitchSourceHeader, Background), + (LspExtSwitchSourceHeaderResponse, Background), (MarkNotificationRead, Foreground), (MoveChannel, Foreground), + (MultiLspQuery, Background), + (MultiLspQueryResponse, Background), (OnTypeFormatting, Background), (OnTypeFormattingResponse, Background), (OpenBufferById, Background), @@ -311,27 +354,32 @@ messages!( (OpenBufferForSymbolResponse, Background), (OpenBufferResponse, Background), (OpenCommitMessageBuffer, Background), + (OpenContext, Foreground), + (OpenContextResponse, Foreground), + (OpenNewBuffer, Foreground), + (OpenServerSettings, Foreground), (PerformRename, Background), (PerformRenameResponse, Background), (Ping, Foreground), (PrepareRename, Background), (PrepareRenameResponse, Background), (ProjectEntryResponse, Foreground), - (CountLanguageModelTokens, Background), - (CountLanguageModelTokensResponse, Background), - (RefreshLlmToken, Background), (RefreshInlayHints, Foreground), + (RefreshLlmToken, Background), + (RegisterBufferWithLanguageServers, Background), (RejoinChannelBuffers, Foreground), (RejoinChannelBuffersResponse, Foreground), + (RejoinRemoteProjects, Foreground), + (RejoinRemoteProjectsResponse, Foreground), (RejoinRoom, Foreground), (RejoinRoomResponse, Foreground), (ReloadBuffers, Foreground), (ReloadBuffersResponse, Foreground), (RemoveChannelMember, Foreground), (RemoveChannelMessage, Foreground), - (UpdateChannelMessage, Foreground), (RemoveContact, Foreground), (RemoveProjectCollaborator, Foreground), + (RemoveWorktree, Foreground), (RenameChannel, Foreground), (RenameChannelResponse, Foreground), (RenameProjectEntry, Foreground), @@ -345,23 +393,33 @@ messages!( (GetCodeLensResponse, Background), (RespondToChannelInvite, Foreground), (RespondToContactRequest, Foreground), + (RestartLanguageServers, Foreground), (RoomUpdated, Foreground), (SaveBuffer, Foreground), - (SetChannelMemberRole, Foreground), - (SetChannelVisibility, Foreground), (SendChannelMessage, Background), (SendChannelMessageResponse, Background), + (SetChannelMemberRole, Foreground), + (SetChannelVisibility, Foreground), + (SetRoomParticipantRole, Foreground), (ShareProject, Foreground), (ShareProjectResponse, Foreground), (ShowContacts, Foreground), + (ShutdownRemoteServer, Foreground), (Stage, Background), (StartLanguageServer, Foreground), (SubscribeToChannels, Foreground), + (SyncExtensions, Background), + (SyncExtensionsResponse, Background), + (BreakpointsForFile, Background), + (ToggleBreakpoint, Foreground), (SynchronizeBuffers, Foreground), (SynchronizeBuffersResponse, Foreground), - (TaskContextForLocation, Background), + (SynchronizeContexts, Foreground), + (SynchronizeContextsResponse, Foreground), (TaskContext, Background), + (TaskContextForLocation, Background), (Test, Foreground), + (Toast, Background), (Unfollow, Foreground), (UnshareProject, Foreground), (Unstage, Background), @@ -369,81 +427,25 @@ messages!( (UpdateBufferFile, Foreground), (UpdateChannelBuffer, Foreground), (UpdateChannelBufferCollaborators, Foreground), + (UpdateChannelMessage, Foreground), (UpdateChannels, Foreground), - (UpdateUserChannels, Foreground), (UpdateContacts, Foreground), + (UpdateContext, Foreground), (UpdateDiagnosticSummary, Foreground), (UpdateDiffBases, Foreground), (UpdateFollowers, Foreground), + (UpdateGitBranch, Background), (UpdateInviteInfo, Foreground), (UpdateLanguageServer, Foreground), + (UpdateNotification, Foreground), (UpdateParticipantLocation, Foreground), (UpdateProject, Foreground), (UpdateProjectCollaborator, Foreground), + (UpdateUserChannels, Foreground), (UpdateUserPlan, Foreground), (UpdateWorktree, Foreground), (UpdateWorktreeSettings, Foreground), (UsersResponse, Foreground), - (LspExtExpandMacro, Background), - (LspExtExpandMacroResponse, Background), - (LspExtOpenDocs, Background), - (LspExtOpenDocsResponse, Background), - (SetRoomParticipantRole, Foreground), - (BlameBuffer, Foreground), - (BlameBufferResponse, Foreground), - (RejoinRemoteProjects, Foreground), - (RejoinRemoteProjectsResponse, Foreground), - (MultiLspQuery, Background), - (MultiLspQueryResponse, Background), - (ListRemoteDirectory, Background), - (ListRemoteDirectoryResponse, Background), - (OpenNewBuffer, Foreground), - (RestartLanguageServers, Foreground), - (LinkedEditingRange, Background), - (LinkedEditingRangeResponse, Background), - (AdvertiseContexts, Foreground), - (OpenContext, Foreground), - (OpenContextResponse, Foreground), - (CreateContext, Foreground), - (CreateContextResponse, Foreground), - (UpdateContext, Foreground), - (SynchronizeContexts, Foreground), - (SynchronizeContextsResponse, Foreground), - (LspExtSwitchSourceHeader, Background), - (LspExtSwitchSourceHeaderResponse, Background), - (AddWorktree, Foreground), - (AddWorktreeResponse, Foreground), - (FindSearchCandidates, Background), - (FindSearchCandidatesResponse, Background), - (CloseBuffer, Foreground), - (ShutdownRemoteServer, Foreground), - (RemoveWorktree, Foreground), - (LanguageServerLog, Foreground), - (Toast, Background), - (HideToast, Background), - (OpenServerSettings, Foreground), - (GetPermalinkToLine, Foreground), - (GetPermalinkToLineResponse, Foreground), - (FlushBufferedMessages, Foreground), - (LanguageServerPromptRequest, Foreground), - (LanguageServerPromptResponse, Foreground), - (GitGetBranches, Background), - (GitBranchesResponse, Background), - (UpdateGitBranch, Background), - (ListToolchains, Foreground), - (ListToolchainsResponse, Foreground), - (ActivateToolchain, Foreground), - (ActiveToolchain, Foreground), - (ActiveToolchainResponse, Foreground), - (GetPathMetadata, Background), - (GetPathMetadataResponse, Background), - (GetPanicFiles, Background), - (GetPanicFilesResponse, Background), - (CancelLanguageServerWork, Foreground), - (SyncExtensions, Background), - (SyncExtensionsResponse, Background), - (InstallExtension, Background), - (RegisterBufferWithLanguageServers, Background), (GitReset, Background), (GitCheckoutFiles, Background), (GitShow, Background), @@ -614,6 +616,7 @@ request_messages!( (CheckForPushedCommits, CheckForPushedCommitsResponse), (GitDiff, GitDiffResponse), (GitInit, Ack), + (ToggleBreakpoint, Ack), ); entity_messages!( @@ -713,6 +716,7 @@ entity_messages!( GitReset, GitCheckoutFiles, SetIndexText, + Push, Fetch, GetRemotes, @@ -723,6 +727,8 @@ entity_messages!( CheckForPushedCommits, GitDiff, GitInit, + BreakpointsForFile, + ToggleBreakpoint, ); entity_messages!( diff --git a/crates/recent_projects/Cargo.toml b/crates/recent_projects/Cargo.toml index fa611e958a..5e7725b767 100644 --- a/crates/recent_projects/Cargo.toml +++ b/crates/recent_projects/Cargo.toml @@ -44,9 +44,10 @@ workspace.workspace = true zed_actions.workspace = true [dev-dependencies] +dap = { workspace = true } editor = { workspace = true, features = ["test-support"] } language = { workspace = true, features = ["test-support"] } project = { workspace = true, features = ["test-support"] } -settings = { workspace = true, features = ["test-support"] } serde_json.workspace = true +settings = { workspace = true, features = ["test-support"] } workspace = { workspace = true, features = ["test-support"] } diff --git a/crates/recent_projects/src/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs index b7238a7e8d..5fef444d06 100644 --- a/crates/recent_projects/src/recent_projects.rs +++ b/crates/recent_projects/src/recent_projects.rs @@ -591,6 +591,7 @@ impl Render for MatchTooltip { mod tests { use std::path::PathBuf; + use dap::debugger_settings::DebuggerSettings; use editor::Editor; use gpui::{TestAppContext, UpdateGlobal, WindowHandle}; use project::{project_settings::ProjectSettings, Project}; @@ -739,6 +740,7 @@ mod tests { crate::init(cx); editor::init(cx); workspace::init_settings(cx); + DebuggerSettings::register(cx); Project::init_settings(cx); state }) diff --git a/crates/remote_server/src/headless_project.rs b/crates/remote_server/src/headless_project.rs index e922fdb5e1..848bdaad55 100644 --- a/crates/remote_server/src/headless_project.rs +++ b/crates/remote_server/src/headless_project.rs @@ -9,6 +9,7 @@ use language::{proto::serialize_operation, Buffer, BufferEvent, LanguageRegistry use node_runtime::NodeRuntime; use project::{ buffer_store::{BufferStore, BufferStoreEvent}, + debugger::{breakpoint_store::BreakpointStore, dap_store::DapStore}, git::GitStore, project_settings::SettingsObserver, search::SearchQuery, @@ -81,13 +82,40 @@ impl HeadlessProject { store }); + let environment = project::ProjectEnvironment::new(&worktree_store, None, cx); + + let toolchain_store = cx.new(|cx| { + ToolchainStore::local( + languages.clone(), + worktree_store.clone(), + environment.clone(), + cx, + ) + }); + let buffer_store = cx.new(|cx| { let mut buffer_store = BufferStore::local(worktree_store.clone(), cx); buffer_store.shared(SSH_PROJECT_ID, session.clone().into(), cx); buffer_store }); - let environment = project::ProjectEnvironment::new(&worktree_store, None, cx); + let breakpoint_store = + cx.new(|_| BreakpointStore::local(worktree_store.clone(), buffer_store.clone())); + + let dap_store = cx.new(|cx| { + DapStore::new_local( + http_client.clone(), + node_runtime.clone(), + fs.clone(), + languages.clone(), + environment.clone(), + toolchain_store.read(cx).as_language_toolchain_store(), + breakpoint_store.clone(), + worktree_store.clone(), + cx, + ) + }); + let git_store = cx.new(|cx| { let mut store = GitStore::local( &worktree_store, @@ -99,6 +127,7 @@ impl HeadlessProject { store.shared(SSH_PROJECT_ID, session.clone().into(), cx); store }); + let prettier_store = cx.new(|cx| { PrettierStore::new( node_runtime.clone(), @@ -108,14 +137,6 @@ impl HeadlessProject { cx, ) }); - let toolchain_store = cx.new(|cx| { - ToolchainStore::local( - languages.clone(), - worktree_store.clone(), - environment.clone(), - cx, - ) - }); let task_store = cx.new(|cx| { let mut task_store = TaskStore::local( @@ -187,6 +208,7 @@ impl HeadlessProject { session.subscribe_to_entity(SSH_PROJECT_ID, &lsp_store); session.subscribe_to_entity(SSH_PROJECT_ID, &task_store); session.subscribe_to_entity(SSH_PROJECT_ID, &toolchain_store); + session.subscribe_to_entity(SSH_PROJECT_ID, &dap_store); session.subscribe_to_entity(SSH_PROJECT_ID, &settings_observer); session.subscribe_to_entity(SSH_PROJECT_ID, &git_store); @@ -221,6 +243,9 @@ impl HeadlessProject { LspStore::init(&client); TaskStore::init(Some(&client)); ToolchainStore::init(&client); + DapStore::init(&client); + // todo(debugger): Re init breakpoint store when we set it up for collab + // BreakpointStore::init(&client); GitStore::init(&client); HeadlessProject { diff --git a/crates/settings/src/settings.rs b/crates/settings/src/settings.rs index cfec7f2d16..ea6aba84f6 100644 --- a/crates/settings/src/settings.rs +++ b/crates/settings/src/settings.rs @@ -19,7 +19,7 @@ pub use keymap_file::{ pub use settings_file::*; pub use settings_store::{ parse_json_with_comments, InvalidSettingsError, LocalSettingsKind, Settings, SettingsLocation, - SettingsSources, SettingsStore, + SettingsSources, SettingsStore, TaskKind, }; #[derive(Copy, Clone, PartialEq, Eq, Debug, Hash, PartialOrd, Ord)] @@ -109,3 +109,7 @@ pub fn initial_keymap_content() -> Cow<'static, str> { pub fn initial_tasks_content() -> Cow<'static, str> { asset_str::("settings/initial_tasks.json") } + +pub fn initial_debug_tasks_content() -> Cow<'static, str> { + asset_str::("settings/initial_debug_tasks.json") +} diff --git a/crates/settings/src/settings_store.rs b/crates/settings/src/settings_store.rs index 924b25fabe..8b60fd88d7 100644 --- a/crates/settings/src/settings_store.rs +++ b/crates/settings/src/settings_store.rs @@ -210,10 +210,16 @@ impl FromStr for Editorconfig { #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] pub enum LocalSettingsKind { Settings, - Tasks, + Tasks(TaskKind), Editorconfig, } +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] +pub enum TaskKind { + Debug, + Script, +} + impl Global for SettingsStore {} #[derive(Debug)] @@ -604,7 +610,7 @@ impl SettingsStore { .map(|content| content.trim()) .filter(|content| !content.is_empty()), ) { - (LocalSettingsKind::Tasks, _) => { + (LocalSettingsKind::Tasks(_), _) => { return Err(InvalidSettingsError::Tasks { message: "Attempted to submit tasks into the settings store".to_string(), }) diff --git a/crates/sqlez/src/bindable.rs b/crates/sqlez/src/bindable.rs index 3e1acc0797..9370389ff4 100644 --- a/crates/sqlez/src/bindable.rs +++ b/crates/sqlez/src/bindable.rs @@ -8,12 +8,14 @@ use util::paths::PathExt; use crate::statement::{SqlType, Statement}; +/// Define the number of columns that a type occupies in a query/database pub trait StaticColumnCount { fn column_count() -> usize { 1 } } +/// Bind values of different types to placeholders in a prepared SQL statement. pub trait Bind { fn bind(&self, statement: &Statement, start_index: i32) -> Result; } diff --git a/crates/sqlez/src/statement.rs b/crates/sqlez/src/statement.rs index 75be504e7b..af703e13e6 100644 --- a/crates/sqlez/src/statement.rs +++ b/crates/sqlez/src/statement.rs @@ -9,9 +9,15 @@ use crate::bindable::{Bind, Column}; use crate::connection::Connection; pub struct Statement<'a> { + /// vector of pointers to the raw SQLite statement objects. + /// it holds the actual prepared statements that will be executed. raw_statements: Vec<*mut sqlite3_stmt>, + /// Index of the current statement being executed from the `raw_statements` vector. current_statement: usize, + /// A reference to the database connection. + /// This is used to execute the statements and check for errors. connection: &'a Connection, + ///Indicates that the `Statement` struct is tied to the lifetime of the SQLite statement phantom: PhantomData, } diff --git a/crates/task/Cargo.toml b/crates/task/Cargo.toml index 6ce7d4ca82..4fdb194a63 100644 --- a/crates/task/Cargo.toml +++ b/crates/task/Cargo.toml @@ -5,18 +5,26 @@ edition.workspace = true publish.workspace = true license = "GPL-3.0-or-later" +[features] +test-support = [ + "gpui/test-support", + "util/test-support" +] + [lints] workspace = true [dependencies] anyhow.workspace = true collections.workspace = true +dap-types.workspace = true futures.workspace = true gpui.workspace = true hex.workspace = true parking_lot.workspace = true schemars.workspace = true serde.workspace = true +serde_json.workspace = true serde_json_lenient.workspace = true sha2.workspace = true shellexpand.workspace = true diff --git a/crates/task/src/debug_format.rs b/crates/task/src/debug_format.rs new file mode 100644 index 0000000000..2a600c24da --- /dev/null +++ b/crates/task/src/debug_format.rs @@ -0,0 +1,227 @@ +use schemars::{gen::SchemaSettings, JsonSchema}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::net::Ipv4Addr; +use std::path::PathBuf; +use util::ResultExt; + +use crate::{TaskTemplate, TaskTemplates, TaskType}; + +impl Default for DebugConnectionType { + fn default() -> Self { + DebugConnectionType::TCP(TCPHost::default()) + } +} + +/// Represents the host information of the debug adapter +#[derive(Default, Deserialize, Serialize, PartialEq, Eq, JsonSchema, Clone, Debug)] +pub struct TCPHost { + /// The port that the debug adapter is listening on + /// + /// Default: We will try to find an open port + pub port: Option, + /// The host that the debug adapter is listening too + /// + /// Default: 127.0.0.1 + pub host: Option, + /// The max amount of time in milliseconds to connect to a tcp DAP before returning an error + /// + /// Default: 2000ms + pub timeout: Option, +} + +impl TCPHost { + /// Get the host or fallback to the default host + pub fn host(&self) -> Ipv4Addr { + self.host.unwrap_or_else(|| Ipv4Addr::new(127, 0, 0, 1)) + } +} + +/// Represents the attach request information of the debug adapter +#[derive(Default, Deserialize, Serialize, PartialEq, Eq, JsonSchema, Clone, Debug)] +pub struct AttachConfig { + /// The processId to attach to, if left empty we will show a process picker + #[serde(default)] + pub process_id: Option, +} + +/// Represents the type that will determine which request to call on the debug adapter +#[derive(Default, Deserialize, Serialize, PartialEq, Eq, JsonSchema, Clone, Debug)] +#[serde(rename_all = "lowercase")] +pub enum DebugRequestType { + /// Call the `launch` request on the debug adapter + #[default] + Launch, + /// Call the `attach` request on the debug adapter + Attach(AttachConfig), +} + +/// The Debug adapter to use +#[derive(Deserialize, Serialize, PartialEq, Eq, JsonSchema, Clone, Debug)] +#[serde(rename_all = "lowercase", tag = "adapter")] +pub enum DebugAdapterKind { + /// Manually setup starting a debug adapter + /// The argument within is used to start the DAP + Custom(CustomArgs), + /// Use debugpy + Python(TCPHost), + /// Use vscode-php-debug + Php(TCPHost), + /// Use vscode-js-debug + Javascript(TCPHost), + /// Use delve + Go(TCPHost), + /// Use lldb + Lldb, + /// Use GDB's built-in DAP support + #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] + Gdb, + /// Used for integration tests + #[cfg(any(test, feature = "test-support"))] + #[serde(skip)] + Fake((bool, dap_types::Capabilities)), +} + +impl DebugAdapterKind { + /// Returns the display name for the adapter kind + pub fn display_name(&self) -> &str { + match self { + Self::Custom(_) => "Custom", + Self::Python(_) => "Python", + Self::Php(_) => "PHP", + Self::Javascript(_) => "JavaScript", + Self::Lldb => "LLDB", + #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] + Self::Gdb => "GDB", + Self::Go(_) => "Go", + #[cfg(any(test, feature = "test-support"))] + Self::Fake(_) => "Fake", + } + } +} + +/// Custom arguments used to setup a custom debugger +#[derive(Deserialize, Serialize, PartialEq, Eq, JsonSchema, Clone, Debug)] +pub struct CustomArgs { + /// The connection that a custom debugger should use + #[serde(flatten)] + pub connection: DebugConnectionType, + /// The cli command used to start the debug adapter e.g. `python3`, `node` or the adapter binary + pub command: String, + /// The cli arguments used to start the debug adapter + pub args: Option>, + /// The cli envs used to start the debug adapter + pub envs: Option>, +} + +/// Represents the configuration for the debug adapter +#[derive(Deserialize, Serialize, PartialEq, Eq, JsonSchema, Clone, Debug)] +#[serde(rename_all = "snake_case")] +pub struct DebugAdapterConfig { + /// Name of the debug task + pub label: String, + /// The type of adapter you want to use + #[serde(flatten)] + pub kind: DebugAdapterKind, + /// The type of request that should be called on the debug adapter + #[serde(default)] + pub request: DebugRequestType, + /// The program that you trying to debug + pub program: Option, + /// The current working directory of your project + pub cwd: Option, + /// Additional initialization arguments to be sent on DAP initialization + pub initialize_args: Option, + /// Whether the debug adapter supports attaching to a running process. + pub supports_attach: bool, +} + +/// Represents the type of the debugger adapter connection +#[derive(Deserialize, Serialize, PartialEq, Eq, JsonSchema, Clone, Debug)] +#[serde(rename_all = "lowercase", tag = "connection")] +pub enum DebugConnectionType { + /// Connect to the debug adapter via TCP + TCP(TCPHost), + /// Connect to the debug adapter via STDIO + STDIO, +} + +/// This struct represent a user created debug task +#[derive(Deserialize, Serialize, PartialEq, Eq, JsonSchema, Clone, Debug)] +#[serde(rename_all = "snake_case")] +pub struct DebugTaskDefinition { + /// The adapter to run + #[serde(flatten)] + kind: DebugAdapterKind, + /// The type of request that should be called on the debug adapter + #[serde(default)] + request: DebugRequestType, + /// Name of the debug task + label: String, + /// Program to run the debugger on + program: Option, + /// The current working directory of your project + cwd: Option, + /// Additional initialization arguments to be sent on DAP initialization + initialize_args: Option, +} + +impl DebugTaskDefinition { + /// Translate from debug definition to a task template + pub fn to_zed_format(self) -> anyhow::Result { + let command = "".to_string(); + let cwd = self.cwd.clone().map(PathBuf::from).take_if(|p| p.exists()); + + let task_type = TaskType::Debug(DebugAdapterConfig { + label: self.label.clone(), + kind: self.kind, + request: self.request, + program: self.program, + cwd: cwd.clone(), + initialize_args: self.initialize_args, + supports_attach: true, + }); + + let args: Vec = Vec::new(); + + Ok(TaskTemplate { + label: self.label, + command, + args, + task_type, + cwd: self.cwd, + ..Default::default() + }) + } +} + +/// A group of Debug Tasks defined in a JSON file. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(transparent)] +pub struct DebugTaskFile(pub Vec); + +impl DebugTaskFile { + /// Generates JSON schema of Tasks JSON template format. + pub fn generate_json_schema() -> serde_json_lenient::Value { + let schema = SchemaSettings::draft07() + .with(|settings| settings.option_add_null_type = false) + .into_generator() + .into_root_schema_for::(); + + serde_json_lenient::to_value(schema).unwrap() + } +} + +impl TryFrom for TaskTemplates { + type Error = anyhow::Error; + + fn try_from(value: DebugTaskFile) -> Result { + let templates = value + .0 + .into_iter() + .filter_map(|debug_definition| debug_definition.to_zed_format().log_err()) + .collect(); + + Ok(Self(templates)) + } +} diff --git a/crates/task/src/lib.rs b/crates/task/src/lib.rs index 70e6597a43..d198514596 100644 --- a/crates/task/src/lib.rs +++ b/crates/task/src/lib.rs @@ -1,6 +1,7 @@ //! Baseline interface of Tasks in Zed: all tasks in Zed are intended to use those for implementing their own logic. #![deny(missing_docs)] +mod debug_format; pub mod static_source; mod task_template; mod vscode_format; @@ -13,7 +14,13 @@ use std::borrow::Cow; use std::path::PathBuf; use std::str::FromStr; -pub use task_template::{HideStrategy, RevealStrategy, TaskTemplate, TaskTemplates}; +pub use debug_format::{ + AttachConfig, CustomArgs, DebugAdapterConfig, DebugAdapterKind, DebugConnectionType, + DebugRequestType, DebugTaskDefinition, DebugTaskFile, TCPHost, +}; +pub use task_template::{ + HideStrategy, RevealStrategy, TaskModal, TaskTemplate, TaskTemplates, TaskType, +}; pub use vscode_format::VsCodeTaskFile; pub use zed_actions::RevealTarget; @@ -54,6 +61,8 @@ pub struct SpawnInTerminal { pub hide: HideStrategy, /// Which shell to use when spawning the task. pub shell: Shell, + /// Tells debug tasks which program to debug + pub program: Option, /// Whether to show the task summary line in the task output (sucess/failure). pub show_summary: bool, /// Whether to show the command line in the task output. @@ -88,6 +97,28 @@ impl ResolvedTask { &self.original_task } + /// Get the task type that determines what this task is used for + /// And where is it shown in the UI + pub fn task_type(&self) -> TaskType { + self.original_task.task_type.clone() + } + + /// Get the configuration for the debug adapter that should be used for this task. + pub fn resolved_debug_adapter_config(&self) -> Option { + match self.original_task.task_type.clone() { + TaskType::Script => None, + TaskType::Debug(mut adapter_config) => { + if let Some(resolved) = &self.resolved { + adapter_config.label = resolved.label.clone(); + adapter_config.program = resolved.program.clone().or(adapter_config.program); + adapter_config.cwd = resolved.cwd.clone().or(adapter_config.cwd); + } + + Some(adapter_config) + } + } + } + /// Variables that were substituted during the task template resolution. pub fn substituted_variables(&self) -> &HashSet { &self.substituted_variables diff --git a/crates/task/src/task_template.rs b/crates/task/src/task_template.rs index 84f762638c..aeba2ac098 100644 --- a/crates/task/src/task_template.rs +++ b/crates/task/src/task_template.rs @@ -9,8 +9,8 @@ use sha2::{Digest, Sha256}; use util::{truncate_and_remove_front, ResultExt}; use crate::{ - ResolvedTask, RevealTarget, Shell, SpawnInTerminal, TaskContext, TaskId, VariableName, - ZED_VARIABLE_NAME_PREFIX, + debug_format::DebugAdapterConfig, ResolvedTask, RevealTarget, Shell, SpawnInTerminal, + TaskContext, TaskId, VariableName, ZED_VARIABLE_NAME_PREFIX, }; /// A template definition of a Zed task to run. @@ -58,6 +58,9 @@ pub struct TaskTemplate { /// * `on_success` — hide the terminal tab on task success only, otherwise behaves similar to `always`. #[serde(default)] pub hide: HideStrategy, + /// If this task should start a debugger or not + #[serde(default, skip)] + pub task_type: TaskType, /// Represents the tags which this template attaches to. Adding this removes this task from other UI. #[serde(default)] pub tags: Vec, @@ -72,6 +75,72 @@ pub struct TaskTemplate { pub show_command: bool, } +/// Represents the type of task that is being ran +#[derive(Default, Deserialize, Serialize, Eq, PartialEq, JsonSchema, Clone, Debug)] +#[serde(rename_all = "snake_case", tag = "type")] +#[expect(clippy::large_enum_variant)] +pub enum TaskType { + /// Act like a typically task that runs commands + #[default] + Script, + /// This task starts the debugger for a language + Debug(DebugAdapterConfig), +} + +#[cfg(test)] +mod deserialization_tests { + use crate::{DebugAdapterKind, TCPHost}; + + use super::*; + use serde_json::json; + + #[test] + fn deserialize_task_type_script() { + let json = json!({"type": "script"}); + + let task_type: TaskType = + serde_json::from_value(json).expect("Failed to deserialize TaskType::Script"); + assert_eq!(task_type, TaskType::Script); + } + + #[test] + fn deserialize_task_type_debug() { + let adapter_config = DebugAdapterConfig { + label: "test config".into(), + kind: DebugAdapterKind::Python(TCPHost::default()), + request: crate::DebugRequestType::Launch, + program: Some("main".to_string()), + supports_attach: false, + cwd: None, + initialize_args: None, + }; + let json = json!({ + "label": "test config", + "type": "debug", + "adapter": "python", + "program": "main", + "supports_attach": false, + }); + + let task_type: TaskType = + serde_json::from_value(json).expect("Failed to deserialize TaskType::Debug"); + if let TaskType::Debug(config) = task_type { + assert_eq!(config, adapter_config); + } else { + panic!("Expected TaskType::Debug"); + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +/// The type of task modal to spawn +pub enum TaskModal { + /// Show regular tasks + ScriptModal, + /// Show debug tasks + DebugModal, +} + /// What to do with the terminal pane and tab, after the command was started. #[derive(Default, Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] @@ -122,7 +191,9 @@ impl TaskTemplate { /// Every [`ResolvedTask`] gets a [`TaskId`], based on the `id_base` (to avoid collision with various task sources), /// and hashes of its template and [`TaskContext`], see [`ResolvedTask`] fields' documentation for more details. pub fn resolve_task(&self, id_base: &str, cx: &TaskContext) -> Option { - if self.label.trim().is_empty() || self.command.trim().is_empty() { + if self.label.trim().is_empty() + || (self.command.trim().is_empty() && matches!(self.task_type, TaskType::Script)) + { return None; } @@ -198,6 +269,22 @@ impl TaskTemplate { &mut substituted_variables, )?; + let program = match &self.task_type { + TaskType::Script => None, + TaskType::Debug(adapter_config) => { + if let Some(program) = &adapter_config.program { + Some(substitute_all_template_variables_in_str( + program, + &task_variables, + &variable_names, + &mut substituted_variables, + )?) + } else { + None + } + } + }; + let task_hash = to_hex_hash(self) .context("hashing task template") .log_err()?; @@ -253,6 +340,7 @@ impl TaskTemplate { reveal_target: self.reveal_target, hide: self.hide, shell: self.shell.clone(), + program, show_summary: self.show_summary, show_command: self.show_command, show_rerun: true, diff --git a/crates/tasks_ui/Cargo.toml b/crates/tasks_ui/Cargo.toml index 3799b82d97..6970492cbc 100644 --- a/crates/tasks_ui/Cargo.toml +++ b/crates/tasks_ui/Cargo.toml @@ -13,9 +13,11 @@ path = "src/tasks_ui.rs" [dependencies] anyhow.workspace = true +debugger_ui.workspace = true editor.workspace = true file_icons.workspace = true fuzzy.workspace = true +feature_flags.workspace = true gpui.workspace = true menu.workspace = true picker.workspace = true diff --git a/crates/tasks_ui/src/modal.rs b/crates/tasks_ui/src/modal.rs index c0dc456db1..b80c551dda 100644 --- a/crates/tasks_ui/src/modal.rs +++ b/crates/tasks_ui/src/modal.rs @@ -9,7 +9,9 @@ use gpui::{ }; use picker::{highlighted_match_with_paths::HighlightedMatch, Picker, PickerDelegate}; use project::{task_store::TaskStore, TaskSourceKind}; -use task::{ResolvedTask, RevealTarget, TaskContext, TaskTemplate}; +use task::{ + DebugRequestType, ResolvedTask, RevealTarget, TaskContext, TaskModal, TaskTemplate, TaskType, +}; use ui::{ div, h_flex, v_flex, ActiveTheme, Button, ButtonCommon, ButtonSize, Clickable, Color, FluentBuilder as _, Icon, IconButton, IconButtonShape, IconName, IconSize, IntoElement, @@ -32,6 +34,8 @@ pub(crate) struct TasksModalDelegate { prompt: String, task_contexts: TaskContexts, placeholder_text: Arc, + /// If this delegate is responsible for running a scripting task or a debugger + task_modal_type: TaskModal, } /// Task template amendments to do before resolving the context. @@ -46,6 +50,7 @@ impl TasksModalDelegate { task_store: Entity, task_contexts: TaskContexts, task_overrides: Option, + task_modal_type: TaskModal, workspace: WeakEntity, ) -> Self { let placeholder_text = if let Some(TaskOverrides { @@ -66,6 +71,7 @@ impl TasksModalDelegate { selected_index: 0, prompt: String::default(), task_contexts, + task_modal_type, task_overrides, placeholder_text, } @@ -130,12 +136,19 @@ impl TasksModal { task_contexts: TaskContexts, task_overrides: Option, workspace: WeakEntity, + task_modal_type: TaskModal, window: &mut Window, cx: &mut Context, ) -> Self { let picker = cx.new(|cx| { Picker::uniform_list( - TasksModalDelegate::new(task_store, task_contexts, task_overrides, workspace), + TasksModalDelegate::new( + task_store, + task_contexts, + task_overrides, + task_modal_type, + workspace, + ), window, cx, ) @@ -203,11 +216,12 @@ impl PickerDelegate for TasksModalDelegate { window: &mut Window, cx: &mut Context>, ) -> Task<()> { + let task_type = self.task_modal_type.clone(); cx.spawn_in(window, move |picker, mut cx| async move { let Some(candidates) = picker .update(&mut cx, |picker, cx| { match &mut picker.delegate.candidates { - Some(candidates) => string_match_candidates(candidates.iter()), + Some(candidates) => string_match_candidates(candidates.iter(), task_type), None => { let Some(task_inventory) = picker .delegate @@ -232,7 +246,8 @@ impl PickerDelegate for TasksModalDelegate { let mut new_candidates = used; new_candidates.extend(current); - let match_candidates = string_match_candidates(new_candidates.iter()); + let match_candidates = + string_match_candidates(new_candidates.iter(), task_type); let _ = picker.delegate.candidates.insert(new_candidates); match_candidates } @@ -281,7 +296,7 @@ impl PickerDelegate for TasksModalDelegate { fn confirm( &mut self, omit_history_entry: bool, - _: &mut Window, + window: &mut Window, cx: &mut Context>, ) { let current_match_index = self.selected_index(); @@ -308,7 +323,43 @@ impl PickerDelegate for TasksModalDelegate { self.workspace .update(cx, |workspace, cx| { - schedule_resolved_task(workspace, task_source_kind, task, omit_history_entry, cx); + match task.task_type() { + TaskType::Script => schedule_resolved_task( + workspace, + task_source_kind, + task, + omit_history_entry, + cx, + ), + TaskType::Debug(_) => { + let Some(config) = task.resolved_debug_adapter_config() else { + return; + }; + let project = workspace.project().clone(); + + match &config.request { + DebugRequestType::Attach(attach_config) + if attach_config.process_id.is_none() => + { + workspace.toggle_modal(window, cx, |window, cx| { + debugger_ui::attach_modal::AttachModal::new( + project, + config.clone(), + window, + cx, + ) + }); + } + _ => { + project.update(cx, |project, cx| { + project + .start_debug_session(config, cx) + .detach_and_log_err(cx); + }); + } + } + } + }; }) .ok(); cx.emit(DismissEvent); @@ -462,7 +513,22 @@ impl PickerDelegate for TasksModalDelegate { } self.workspace .update(cx, |workspace, cx| { - schedule_resolved_task(workspace, task_source_kind, task, omit_history_entry, cx); + match task.task_type() { + TaskType::Script => schedule_resolved_task( + workspace, + task_source_kind, + task, + omit_history_entry, + cx, + ), + // TODO: Should create a schedule_resolved_debug_task function + // This would allow users to access to debug history and other issues + TaskType::Debug(_) => workspace.project().update(cx, |project, cx| { + project + .start_debug_session(task.resolved_debug_adapter_config().unwrap(), cx) + .detach_and_log_err(cx); + }), + }; }) .ok(); cx.emit(DismissEvent); @@ -584,9 +650,14 @@ impl PickerDelegate for TasksModalDelegate { fn string_match_candidates<'a>( candidates: impl Iterator + 'a, + task_modal_type: TaskModal, ) -> Vec { candidates .enumerate() + .filter(|(_, (_, candidate))| match candidate.task_type() { + TaskType::Script => task_modal_type == TaskModal::ScriptModal, + TaskType::Debug(_) => task_modal_type == TaskModal::DebugModal, + }) .map(|(index, (_, candidate))| StringMatchCandidate::new(index, candidate.display_label())) .collect() } diff --git a/crates/tasks_ui/src/tasks_ui.rs b/crates/tasks_ui/src/tasks_ui.rs index f2f53957bd..9ad810f212 100644 --- a/crates/tasks_ui/src/tasks_ui.rs +++ b/crates/tasks_ui/src/tasks_ui.rs @@ -3,12 +3,13 @@ use std::path::Path; use ::settings::Settings; use editor::Editor; +use feature_flags::{Debugger, FeatureFlagViewExt}; use gpui::{App, AppContext as _, Context, Entity, Task, Window}; use modal::{TaskOverrides, TasksModal}; use project::{Location, TaskContexts, Worktree}; -use task::{RevealTarget, TaskContext, TaskId, TaskVariables, VariableName}; +use task::{RevealTarget, TaskContext, TaskId, TaskModal, TaskVariables, VariableName}; use workspace::tasks::schedule_task; -use workspace::{tasks::schedule_resolved_task, Workspace}; +use workspace::{tasks::schedule_resolved_task, Start, Workspace}; mod modal; mod settings; @@ -18,7 +19,7 @@ pub use modal::{Rerun, Spawn}; pub fn init(cx: &mut App) { settings::TaskSettings::register(cx); cx.observe_new( - |workspace: &mut Workspace, _window: Option<&mut Window>, _: &mut Context| { + |workspace: &mut Workspace, window: Option<&mut Window>, cx: &mut Context| { workspace .register_action(spawn_task_or_modal) .register_action(move |workspace, action: &modal::Rerun, window, cx| { @@ -85,9 +86,20 @@ pub fn init(cx: &mut App) { ); } } else { - toggle_modal(workspace, None, window, cx).detach(); + toggle_modal(workspace, None, TaskModal::ScriptModal, window, cx).detach(); }; }); + + let Some(window) = window else { + return; + }; + + cx.when_flag_enabled::(window, |workspace, _, _| { + workspace.register_action(|workspace: &mut Workspace, _: &Start, window, cx| { + crate::toggle_modal(workspace, None, task::TaskModal::DebugModal, window, cx) + .detach(); + }); + }); }, ) .detach(); @@ -109,15 +121,21 @@ fn spawn_task_or_modal( }); spawn_task_with_name(task_name.clone(), overrides, window, cx).detach_and_log_err(cx) } - Spawn::ViaModal { reveal_target } => { - toggle_modal(workspace, *reveal_target, window, cx).detach() - } + Spawn::ViaModal { reveal_target } => toggle_modal( + workspace, + *reveal_target, + TaskModal::ScriptModal, + window, + cx, + ) + .detach(), } } -fn toggle_modal( +pub fn toggle_modal( workspace: &mut Workspace, reveal_target: Option, + task_type: TaskModal, window: &mut Window, cx: &mut Context, ) -> Task<()> { @@ -140,6 +158,7 @@ fn toggle_modal( reveal_target: Some(target), }), workspace_handle, + task_type, window, cx, ) diff --git a/crates/terminal/src/pty_info.rs b/crates/terminal/src/pty_info.rs index 80627b4673..d9515afbf7 100644 --- a/crates/terminal/src/pty_info.rs +++ b/crates/terminal/src/pty_info.rs @@ -151,4 +151,8 @@ impl PtyProcessInfo { } has_changed } + + pub fn pid(&self) -> Option { + self.pid_getter.pid() + } } diff --git a/crates/terminal/src/terminal.rs b/crates/terminal/src/terminal.rs index 5472148dad..b2c9b5d35b 100644 --- a/crates/terminal/src/terminal.rs +++ b/crates/terminal/src/terminal.rs @@ -337,6 +337,7 @@ impl TerminalBuilder { is_ssh_terminal: bool, window: AnyWindowHandle, completion_tx: Sender<()>, + debug_terminal: bool, cx: &App, ) -> Result { // If the parent environment doesn't have a locale set @@ -473,6 +474,7 @@ impl TerminalBuilder { url_regex: RegexSearch::new(URL_REGEX).unwrap(), word_regex: RegexSearch::new(WORD_REGEX).unwrap(), vi_mode_enabled: false, + debug_terminal, is_ssh_terminal, python_venv_directory, }; @@ -629,6 +631,7 @@ pub struct Terminal { word_regex: RegexSearch, task: Option, vi_mode_enabled: bool, + debug_terminal: bool, is_ssh_terminal: bool, } @@ -1803,6 +1806,10 @@ impl Terminal { self.task.as_ref() } + pub fn debug_terminal(&self) -> bool { + self.debug_terminal + } + pub fn wait_for_completed_task(&self, cx: &App) -> Task<()> { if let Some(task) = self.task() { if task.status == TaskStatus::Running { diff --git a/crates/terminal_view/src/terminal_panel.rs b/crates/terminal_view/src/terminal_panel.rs index 4237d27349..f3f89281d7 100644 --- a/crates/terminal_view/src/terminal_panel.rs +++ b/crates/terminal_view/src/terminal_panel.rs @@ -693,7 +693,7 @@ impl TerminalPanel { }) } - fn add_terminal( + pub fn add_terminal( &mut self, kind: TerminalKind, reveal_strategy: RevealStrategy, diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index 60e43ccafb..393aeb10ad 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -1349,6 +1349,9 @@ impl Item for TerminalView { } } }, + None if self.terminal.read(cx).debug_terminal() => { + (IconName::Debug, Color::Muted, None) + } None => (IconName::Terminal, Color::Muted, None), }; @@ -1507,7 +1510,7 @@ impl SerializableItem for TerminalView { cx: &mut Context, ) -> Option>> { let terminal = self.terminal().read(cx); - if terminal.task().is_some() { + if terminal.task().is_some() || terminal.debug_terminal() { return None; } diff --git a/crates/text/src/text.rs b/crates/text/src/text.rs index 51d47d85d9..21fa3b3940 100644 --- a/crates/text/src/text.rs +++ b/crates/text/src/text.rs @@ -2205,7 +2205,7 @@ impl BufferSnapshot { }) } - fn summary_for_anchor(&self, anchor: &Anchor) -> D + pub fn summary_for_anchor(&self, anchor: &Anchor) -> D where D: TextDimension, { diff --git a/crates/theme/src/default_colors.rs b/crates/theme/src/default_colors.rs index 1801a84294..251f597eaf 100644 --- a/crates/theme/src/default_colors.rs +++ b/crates/theme/src/default_colors.rs @@ -68,6 +68,7 @@ impl ThemeColors { icon_disabled: neutral().light().step_9(), icon_placeholder: neutral().light().step_10(), icon_accent: blue().light().step_11(), + debugger_accent: red().light().step_10(), status_bar_background: neutral().light().step_2(), title_bar_background: neutral().light().step_2(), title_bar_inactive_background: neutral().light().step_3(), @@ -94,6 +95,7 @@ impl ThemeColors { editor_subheader_background: neutral().light().step_2(), editor_active_line_background: neutral().light_alpha().step_3(), editor_highlighted_line_background: neutral().light_alpha().step_3(), + editor_debugger_active_line_background: yellow().dark_alpha().step_3(), editor_line_number: neutral().light().step_10(), editor_hover_line_number: neutral().light().step_12(), editor_active_line_number: neutral().light().step_11(), @@ -181,6 +183,7 @@ impl ThemeColors { icon_disabled: neutral().dark().step_9(), icon_placeholder: neutral().dark().step_10(), icon_accent: blue().dark().step_11(), + debugger_accent: red().light().step_10(), status_bar_background: neutral().dark().step_2(), title_bar_background: neutral().dark().step_2(), title_bar_inactive_background: neutral().dark().step_3(), @@ -206,7 +209,8 @@ impl ThemeColors { editor_gutter_background: neutral().dark().step_1(), editor_subheader_background: neutral().dark().step_3(), editor_active_line_background: neutral().dark_alpha().step_3(), - editor_highlighted_line_background: neutral().dark_alpha().step_4(), + editor_highlighted_line_background: yellow().dark_alpha().step_4(), + editor_debugger_active_line_background: yellow().dark_alpha().step_3(), editor_line_number: neutral().dark_alpha().step_10(), editor_hover_line_number: neutral().dark_alpha().step_12(), editor_active_line_number: neutral().dark_alpha().step_11(), diff --git a/crates/theme/src/fallback_themes.rs b/crates/theme/src/fallback_themes.rs index 92f7215741..e4ce1d669f 100644 --- a/crates/theme/src/fallback_themes.rs +++ b/crates/theme/src/fallback_themes.rs @@ -114,6 +114,7 @@ pub(crate) fn zed_default_dark() -> Theme { icon_disabled: hsla(220.0 / 360., 6.4 / 100., 45.7 / 100., 1.0), icon_placeholder: hsla(220.0 / 360., 6.4 / 100., 45.7 / 100., 1.0), icon_accent: blue, + debugger_accent: red, status_bar_background: bg, title_bar_background: bg, title_bar_inactive_background: bg, @@ -128,6 +129,12 @@ pub(crate) fn zed_default_dark() -> Theme { editor_subheader_background: bg, editor_active_line_background: hsla(222.9 / 360., 13.5 / 100., 20.4 / 100., 1.0), editor_highlighted_line_background: hsla(207.8 / 360., 81. / 100., 66. / 100., 0.1), + editor_debugger_active_line_background: hsla( + 207.8 / 360., + 81. / 100., + 66. / 100., + 0.2, + ), editor_line_number: hsla(222.0 / 360., 11.5 / 100., 34.1 / 100., 1.0), editor_active_line_number: hsla(216.0 / 360., 5.9 / 100., 49.6 / 100., 1.0), editor_hover_line_number: hsla(216.0 / 360., 5.9 / 100., 56.7 / 100., 1.0), diff --git a/crates/theme/src/schema.rs b/crates/theme/src/schema.rs index 69df3d0b79..d68a66e2a3 100644 --- a/crates/theme/src/schema.rs +++ b/crates/theme/src/schema.rs @@ -188,7 +188,7 @@ pub struct ThemeColorsContent { /// Background Color. Used for the active state of an element that should have a different background than the surface it's on. /// - /// Active states are triggered by the mouse button being pressed down on an element, or the Return button or other activator being pressd. + /// Active states are triggered by the mouse button being pressed down on an element, or the Return button or other activator being pressed. #[serde(rename = "element.active")] pub element_active: Option, @@ -226,7 +226,7 @@ pub struct ThemeColorsContent { /// Background Color. Used for the active state of a ghost element that should have the same background as the surface it's on. /// - /// Active states are triggered by the mouse button being pressed down on an element, or the Return button or other activator being pressd. + /// Active states are triggered by the mouse button being pressed down on an element, or the Return button or other activator being pressed. #[serde(rename = "ghost_element.active")] pub ghost_element_active: Option, @@ -292,6 +292,11 @@ pub struct ThemeColorsContent { #[serde(rename = "icon.accent")] pub icon_accent: Option, + /// Color used to accent some of the debuggers elements + /// Only accent breakpoint & breakpoint related symbols right now + #[serde(rename = "debugger.accent")] + pub debugger_accent: Option, + #[serde(rename = "status_bar.background")] pub status_bar_background: Option, @@ -382,6 +387,10 @@ pub struct ThemeColorsContent { #[serde(rename = "editor.highlighted_line.background")] pub editor_highlighted_line_background: Option, + /// Background of active line of debugger + #[serde(rename = "editor.debugger_active_line.background")] + pub editor_debugger_active_line_background: Option, + /// Text Color. Used for the text of the line number in the editor gutter. #[serde(rename = "editor.line_number")] pub editor_line_number: Option, @@ -707,6 +716,10 @@ impl ThemeColorsContent { .icon_accent .as_ref() .and_then(|color| try_parse_color(color).ok()), + debugger_accent: self + .debugger_accent + .as_ref() + .and_then(|color| try_parse_color(color).ok()), status_bar_background: self .status_bar_background .as_ref() @@ -817,6 +830,10 @@ impl ThemeColorsContent { .editor_highlighted_line_background .as_ref() .and_then(|color| try_parse_color(color).ok()), + editor_debugger_active_line_background: self + .editor_debugger_active_line_background + .as_ref() + .and_then(|color| try_parse_color(color).ok()), editor_line_number: self .editor_line_number .as_ref() diff --git a/crates/theme/src/styles/colors.rs b/crates/theme/src/styles/colors.rs index fa639a03a9..4e53341cde 100644 --- a/crates/theme/src/styles/colors.rs +++ b/crates/theme/src/styles/colors.rs @@ -43,7 +43,7 @@ pub struct ThemeColors { pub element_hover: Hsla, /// Background Color. Used for the active state of an element that should have a different background than the surface it's on. /// - /// Active states are triggered by the mouse button being pressed down on an element, or the Return button or other activator being pressd. + /// Active states are triggered by the mouse button being pressed down on an element, or the Return button or other activator being pressed. pub element_active: Hsla, /// Background Color. Used for the selected state of an element that should have a different background than the surface it's on. /// @@ -69,7 +69,7 @@ pub struct ThemeColors { pub ghost_element_hover: Hsla, /// Background Color. Used for the active state of a ghost element that should have the same background as the surface it's on. /// - /// Active states are triggered by the mouse button being pressed down on an element, or the Return button or other activator being pressd. + /// Active states are triggered by the mouse button being pressed down on an element, or the Return button or other activator being pressed. pub ghost_element_active: Hsla, /// Background Color. Used for the selected state of a ghost element that should have the same background as the surface it's on. /// @@ -109,6 +109,9 @@ pub struct ThemeColors { /// /// This might be used to show when a toggleable icon button is selected. pub icon_accent: Hsla, + /// Color used to accent some debugger elements + /// Is used by breakpoints + pub debugger_accent: Hsla, // === // UI Elements @@ -148,6 +151,8 @@ pub struct ThemeColors { pub editor_subheader_background: Hsla, pub editor_active_line_background: Hsla, pub editor_highlighted_line_background: Hsla, + /// Line color of the line a debugger is currently stopped at + pub editor_debugger_active_line_background: Hsla, /// Text Color. Used for the text of the line number in the editor gutter. pub editor_line_number: Hsla, /// Text Color. Used for the text of the line number in the editor gutter when the line is highlighted. diff --git a/crates/ui/src/components/button/button_like.rs b/crates/ui/src/components/button/button_like.rs index bb7ed025e1..e9ea85b01d 100644 --- a/crates/ui/src/components/button/button_like.rs +++ b/crates/ui/src/components/button/button_like.rs @@ -1,4 +1,4 @@ -use gpui::{relative, CursorStyle, DefiniteLength, MouseButton}; +use gpui::{relative, CursorStyle, DefiniteLength, MouseButton, MouseDownEvent, MouseUpEvent}; use gpui::{transparent_black, AnyElement, AnyView, ClickEvent, Hsla, Rems}; use smallvec::SmallVec; @@ -359,6 +359,7 @@ pub struct ButtonLike { tooltip: Option AnyView>>, cursor_style: CursorStyle, on_click: Option>, + on_right_click: Option>, children: SmallVec<[AnyElement; 2]>, } @@ -379,6 +380,7 @@ impl ButtonLike { children: SmallVec::new(), cursor_style: CursorStyle::PointingHand, on_click: None, + on_right_click: None, layer: None, } } @@ -405,6 +407,14 @@ impl ButtonLike { self.rounding = rounding.into(); self } + + pub fn on_right_click( + mut self, + handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_right_click = Some(Box::new(handler)); + self + } } impl Disableable for ButtonLike { @@ -528,6 +538,37 @@ impl RenderOnce for ButtonLike { .hover(|hover| hover.bg(style.hovered(self.layer, cx).background)) .active(|active| active.bg(style.active(cx).background)) }) + .when_some( + self.on_right_click.filter(|_| !self.disabled), + |this, on_right_click| { + this.on_mouse_down(MouseButton::Right, |_event, window, cx| { + window.prevent_default(); + cx.stop_propagation(); + }) + .on_mouse_up( + MouseButton::Right, + move |event, window, cx| { + cx.stop_propagation(); + let click_event = ClickEvent { + down: MouseDownEvent { + button: MouseButton::Right, + position: event.position, + modifiers: event.modifiers, + click_count: 1, + first_mouse: false, + }, + up: MouseUpEvent { + button: MouseButton::Right, + position: event.position, + modifiers: event.modifiers, + click_count: 1, + }, + }; + (on_right_click)(&click_event, window, cx) + }, + ) + }, + ) .when_some( self.on_click.filter(|_| !self.disabled), |this, on_click| { diff --git a/crates/ui/src/components/button/icon_button.rs b/crates/ui/src/components/button/icon_button.rs index 8adda25042..611a81f6d5 100644 --- a/crates/ui/src/components/button/icon_button.rs +++ b/crates/ui/src/components/button/icon_button.rs @@ -71,6 +71,14 @@ impl IconButton { self } + pub fn on_right_click( + mut self, + handler: impl Fn(&gpui::ClickEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.base = self.base.on_right_click(handler); + self + } + /// Sets the icon color used when the button is in a selected state. pub fn selected_icon_color(mut self, color: impl Into>) -> Self { self.selected_icon_color = color.into(); @@ -84,6 +92,7 @@ impl IconButton { pub fn indicator_border_color(mut self, color: Option) -> Self { self.indicator_border_color = color; + self } } diff --git a/crates/ui/src/components/dropdown_menu.rs b/crates/ui/src/components/dropdown_menu.rs index b5f1e78e62..b057e35249 100644 --- a/crates/ui/src/components/dropdown_menu.rs +++ b/crates/ui/src/components/dropdown_menu.rs @@ -44,7 +44,11 @@ impl RenderOnce for DropdownMenu { PopoverMenu::new(self.id) .full_width(self.full_width) .menu(move |_window, _cx| Some(self.menu.clone())) - .trigger(DropdownMenuTrigger::new(self.label).full_width(self.full_width)) + .trigger( + DropdownMenuTrigger::new(self.label) + .full_width(self.full_width) + .disabled(self.disabled), + ) .attach(Corner::BottomLeft) } } diff --git a/crates/ui/src/components/icon.rs b/crates/ui/src/components/icon.rs index d184be8784..19aaaf9c47 100644 --- a/crates/ui/src/components/icon.rs +++ b/crates/ui/src/components/icon.rs @@ -179,6 +179,19 @@ pub enum IconName { CountdownTimer, CursorIBeam, Dash, + DebugBreakpoint, + DebugIgnoreBreakpoints, + DebugPause, + DebugContinue, + DebugStepOver, + DebugStepInto, + DebugStepOut, + DebugStepBack, + DebugRestart, + Debug, + DebugStop, + DebugDisconnect, + DebugLogBreakpoint, DatabaseZap, Delete, Diff, diff --git a/crates/ui/src/components/list/list_item.rs b/crates/ui/src/components/list/list_item.rs index ac53284c05..25701b6ac0 100644 --- a/crates/ui/src/components/list/list_item.rs +++ b/crates/ui/src/components/list/list_item.rs @@ -37,6 +37,7 @@ pub struct ListItem { on_secondary_mouse_down: Option>, children: SmallVec<[AnyElement; 2]>, selectable: bool, + always_show_disclosure_icon: bool, outlined: bool, rounded: bool, overflow_x: bool, @@ -63,6 +64,7 @@ impl ListItem { tooltip: None, children: SmallVec::new(), selectable: true, + always_show_disclosure_icon: false, outlined: false, rounded: false, overflow_x: false, @@ -80,6 +82,11 @@ impl ListItem { self } + pub fn always_show_disclosure_icon(mut self, show: bool) -> Self { + self.always_show_disclosure_icon = show; + self + } + pub fn on_click( mut self, handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, @@ -282,7 +289,9 @@ impl RenderOnce for ListItem { .flex() .absolute() .left(rems(-1.)) - .when(is_open, |this| this.visible_on_hover("")) + .when(is_open && !self.always_show_disclosure_icon, |this| { + this.visible_on_hover("") + }) .child(Disclosure::new("toggle", is_open).on_toggle(self.on_toggle)) })) .child( diff --git a/crates/ui/src/styles/color.rs b/crates/ui/src/styles/color.rs index 0d234ad50d..7c632a711a 100644 --- a/crates/ui/src/styles/color.rs +++ b/crates/ui/src/styles/color.rs @@ -22,6 +22,8 @@ pub enum Color { /// /// A custom color specified by an HSLA value. Custom(Hsla), + /// A color used for all debugger UI elements. + Debugger, /// A color used to indicate a deleted item, such as a file removed from version control. Deleted, /// A color used for disabled UI elements or text, like a disabled button or menu item. @@ -70,6 +72,7 @@ impl Color { Color::Modified => cx.theme().status().modified, Color::Conflict => cx.theme().status().conflict, Color::Ignored => cx.theme().status().ignored, + Color::Debugger => cx.theme().colors().debugger_accent, Color::Deleted => cx.theme().status().deleted, Color::Disabled => cx.theme().colors().text_disabled, Color::Hidden => cx.theme().status().hidden, diff --git a/crates/ui/src/utils/search_input.rs b/crates/ui/src/utils/search_input.rs index c541a3b21f..0413e9d89a 100644 --- a/crates/ui/src/utils/search_input.rs +++ b/crates/ui/src/utils/search_input.rs @@ -3,7 +3,7 @@ use gpui::Pixels; pub struct SearchInputWidth; impl SearchInputWidth { - /// The containzer size in which the input stops filling the whole width. + /// The container size in which the input stops filling the whole width. pub const THRESHOLD_WIDTH: f32 = 1200.0; /// The maximum width for the search input when the container is larger than the threshold. diff --git a/crates/util/src/fs.rs b/crates/util/src/fs.rs index f235753e8b..d6baf9364b 100644 --- a/crates/util/src/fs.rs +++ b/crates/util/src/fs.rs @@ -1,8 +1,8 @@ -use std::path::Path; - use crate::ResultExt; +use anyhow::{bail, Result}; use async_fs as fs; use futures_lite::StreamExt; +use std::path::{Path, PathBuf}; /// Removes all files and directories matching the given predicate pub async fn remove_matching(dir: &Path, predicate: F) @@ -26,3 +26,68 @@ where } } } + +pub async fn collect_matching(dir: &Path, predicate: F) -> Vec +where + F: Fn(&Path) -> bool, +{ + let mut matching = vec![]; + + if let Some(mut entries) = fs::read_dir(dir).await.log_err() { + while let Some(entry) = entries.next().await { + if let Some(entry) = entry.log_err() { + if predicate(entry.path().as_path()) { + matching.push(entry.path()); + } + } + } + } + + matching +} + +pub async fn find_file_name_in_dir(dir: &Path, predicate: F) -> Option +where + F: Fn(&str) -> bool, +{ + if let Some(mut entries) = fs::read_dir(dir).await.log_err() { + while let Some(entry) = entries.next().await { + if let Some(entry) = entry.log_err() { + let entry_path = entry.path(); + + if let Some(file_name) = entry_path + .file_name() + .map(|file_name| file_name.to_string_lossy()) + { + if predicate(&file_name) { + return Some(entry_path); + } + } + } + } + } + + None +} + +pub async fn move_folder_files_to_folder>( + source_path: P, + target_path: P, +) -> Result<()> { + if !target_path.as_ref().is_dir() { + bail!("Folder not found or is not a directory"); + } + + let mut entries = fs::read_dir(source_path.as_ref()).await?; + while let Some(entry) = entries.next().await { + let entry = entry?; + let old_path = entry.path(); + let new_path = target_path.as_ref().join(entry.file_name()); + + fs::rename(&old_path, &new_path).await?; + } + + fs::remove_dir(source_path).await?; + + Ok(()) +} diff --git a/crates/vim/src/command.rs b/crates/vim/src/command.rs index d9253fe78d..9b8009a110 100644 --- a/crates/vim/src/command.rs +++ b/crates/vim/src/command.rs @@ -1439,6 +1439,7 @@ impl ShellExec { reveal_target: RevealTarget::Dock, hide: HideStrategy::Never, shell, + program: None, show_summary: false, show_command: false, show_rerun: false, diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index b155da3233..8888ae2a04 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -219,7 +219,7 @@ pub enum Event { idx: usize, }, RemovedItem { - item_id: EntityId, + item: Box, }, Split(SplitDirection), JoinAll, @@ -247,9 +247,9 @@ impl fmt::Debug for Event { .finish(), Event::Remove { .. } => f.write_str("Remove"), Event::RemoveItem { idx } => f.debug_struct("RemoveItem").field("idx", idx).finish(), - Event::RemovedItem { item_id } => f + Event::RemovedItem { item } => f .debug_struct("RemovedItem") - .field("item_id", item_id) + .field("item", &item.item_id()) .finish(), Event::Split(direction) => f .debug_struct("Split") @@ -315,6 +315,7 @@ pub struct Pane { display_nav_history_buttons: Option, double_click_dispatch_action: Box, save_modals_spawned: HashSet, + close_pane_if_empty: bool, pub new_item_context_menu_handle: PopoverMenuHandle, pub split_item_context_menu_handle: PopoverMenuHandle, pinned_tab_count: usize, @@ -519,6 +520,7 @@ impl Pane { _subscriptions: subscriptions, double_click_dispatch_action, save_modals_spawned: HashSet::default(), + close_pane_if_empty: true, split_item_context_menu_handle: Default::default(), new_item_context_menu_handle: Default::default(), pinned_tab_count: 0, @@ -706,6 +708,11 @@ impl Pane { self.can_split_predicate = can_split_predicate; } + pub fn set_close_pane_if_empty(&mut self, close_pane_if_empty: bool, cx: &mut Context) { + self.close_pane_if_empty = close_pane_if_empty; + cx.notify(); + } + pub fn set_can_navigate(&mut self, can_navigate: bool, cx: &mut Context) { self.toolbar.update(cx, |toolbar, cx| { toolbar.set_can_navigate(can_navigate, cx); @@ -1632,6 +1639,13 @@ impl Pane { // Remove the item from the pane. pane.update_in(&mut cx, |pane, window, cx| { + pane.remove_item( + item_to_close.item_id(), + false, + pane.close_pane_if_empty, + window, + cx, + ); pane.remove_item(item_to_close.item_id(), false, true, window, cx); }) .ok(); @@ -1739,13 +1753,9 @@ impl Pane { } } - cx.emit(Event::RemoveItem { idx: item_index }); - let item = self.items.remove(item_index); - cx.emit(Event::RemovedItem { - item_id: item.item_id(), - }); + cx.emit(Event::RemovedItem { item: item.clone() }); if self.items.is_empty() { item.deactivated(window, cx); if close_pane_if_empty { @@ -2779,7 +2789,7 @@ impl Pane { window.dispatch_action( this.double_click_dispatch_action.boxed_clone(), cx, - ) + ); } })), ), diff --git a/crates/workspace/src/persistence.rs b/crates/workspace/src/persistence.rs index 94c28855ba..d864c5bd8a 100644 --- a/crates/workspace/src/persistence.rs +++ b/crates/workspace/src/persistence.rs @@ -1,18 +1,25 @@ pub mod model; -use std::{path::Path, str::FromStr}; +use std::{ + borrow::Cow, + collections::BTreeMap, + path::{Path, PathBuf}, + str::FromStr, + sync::Arc, +}; use anyhow::{anyhow, bail, Context, Result}; use client::DevServerProjectId; use db::{define_connection, query, sqlez::connection::Connection, sqlez_macros::sql}; use gpui::{point, size, Axis, Bounds, WindowBounds, WindowId}; +use project::debugger::breakpoint_store::{BreakpointKind, SerializedBreakpoint}; use language::{LanguageName, Toolchain}; use project::WorktreeId; use remote::ssh_session::SshProjectId; use sqlez::{ bindable::{Bind, Column, StaticColumnCount}, - statement::Statement, + statement::{SqlType, Statement}, }; use ui::px; @@ -136,6 +143,125 @@ impl Column for SerializedWindowBounds { } } +#[derive(Debug)] +pub struct Breakpoint { + pub position: u32, + pub kind: BreakpointKind, +} + +/// Wrapper for DB type of a breakpoint +struct BreakpointKindWrapper<'a>(Cow<'a, BreakpointKind>); + +impl From for BreakpointKindWrapper<'static> { + fn from(kind: BreakpointKind) -> Self { + BreakpointKindWrapper(Cow::Owned(kind)) + } +} +impl StaticColumnCount for BreakpointKindWrapper<'_> { + fn column_count() -> usize { + 1 + } +} + +impl Bind for BreakpointKindWrapper<'_> { + fn bind(&self, statement: &Statement, start_index: i32) -> anyhow::Result { + let next_index = statement.bind(&self.0.to_int(), start_index)?; + + match self.0.as_ref() { + BreakpointKind::Standard => { + statement.bind_null(next_index)?; + Ok(next_index + 1) + } + BreakpointKind::Log(message) => statement.bind(&message.as_ref(), next_index), + } + } +} + +impl Column for BreakpointKindWrapper<'_> { + fn column(statement: &mut Statement, start_index: i32) -> anyhow::Result<(Self, i32)> { + let kind = statement.column_int(start_index)?; + + match kind { + 0 => Ok((BreakpointKind::Standard.into(), start_index + 2)), + 1 => { + let message = statement.column_text(start_index)?.to_string(); + Ok((BreakpointKind::Log(message.into()).into(), start_index + 1)) + } + _ => Err(anyhow::anyhow!("Invalid BreakpointKind discriminant")), + } + } +} + +/// This struct is used to implement traits on Vec +#[derive(Debug)] +#[allow(dead_code)] +struct Breakpoints(Vec); + +impl sqlez::bindable::StaticColumnCount for Breakpoint { + fn column_count() -> usize { + 1 + BreakpointKindWrapper::column_count() + } +} + +impl sqlez::bindable::Bind for Breakpoint { + fn bind( + &self, + statement: &sqlez::statement::Statement, + start_index: i32, + ) -> anyhow::Result { + let next_index = statement.bind(&self.position, start_index)?; + statement.bind( + &BreakpointKindWrapper(Cow::Borrowed(&self.kind)), + next_index, + ) + } +} + +impl Column for Breakpoint { + fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> { + let position = statement + .column_int(start_index) + .with_context(|| format!("Failed to read BreakPoint at index {start_index}"))? + as u32; + let (kind, next_index) = BreakpointKindWrapper::column(statement, start_index + 1)?; + + Ok(( + Breakpoint { + position, + kind: kind.0.into_owned(), + }, + next_index, + )) + } +} + +impl Column for Breakpoints { + fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> { + let mut breakpoints = Vec::new(); + let mut index = start_index; + + loop { + match statement.column_type(index) { + Ok(SqlType::Null) => break, + _ => { + let position = statement + .column_int(index) + .with_context(|| format!("Failed to read BreakPoint at index {index}"))? + as u32; + let (kind, next_index) = BreakpointKindWrapper::column(statement, index + 1)?; + + breakpoints.push(Breakpoint { + position, + kind: kind.0.into_owned(), + }); + index = next_index; + } + } + } + Ok((Breakpoints(breakpoints), index)) + } +} + #[derive(Clone, Debug, PartialEq)] struct SerializedPixels(gpui::Pixels); impl sqlez::bindable::StaticColumnCount for SerializedPixels {} @@ -205,6 +331,14 @@ define_connection! { // active: bool, // Indicates if this item is the active one in the pane // preview: bool // Indicates if this item is a preview item // ) + // + // CREATE TABLE breakpoints( + // workspace_id: usize Foreign Key, // References workspace table + // path: PathBuf, // The absolute path of the file that this breakpoint belongs to + // breakpoint_location: Vec, // A list of the locations of breakpoints + // kind: int, // The kind of breakpoint (standard, log) + // log_message: String, // log message for log breakpoints, otherwise it's Null + // ) pub static ref DB: WorkspaceDb<()> = &[ sql!( @@ -383,6 +517,18 @@ define_connection! { sql!( ALTER TABLE toolchains ADD COLUMN raw_json TEXT DEFAULT "{}"; ), + sql!( + CREATE TABLE breakpoints ( + workspace_id INTEGER NOT NULL, + path TEXT NOT NULL, + breakpoint_location INTEGER NOT NULL, + kind INTEGER NOT NULL, + log_message TEXT, + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) + ON DELETE CASCADE + ON UPDATE CASCADE + ); + ), ]; } @@ -470,6 +616,7 @@ impl WorkspaceDb { display, docks, session_id: None, + breakpoints: self.breakpoints(workspace_id), window_id, }) } @@ -523,6 +670,7 @@ impl WorkspaceDb { .log_err()?, window_bounds, centered_layout: centered_layout.unwrap_or(false), + breakpoints: self.breakpoints(workspace_id), display, docks, session_id: None, @@ -530,6 +678,46 @@ impl WorkspaceDb { }) } + fn breakpoints( + &self, + workspace_id: WorkspaceId, + ) -> BTreeMap, Vec> { + let breakpoints: Result> = self + .select_bound(sql! { + SELECT path, breakpoint_location, kind + FROM breakpoints + WHERE workspace_id = ? + }) + .and_then(|mut prepared_statement| (prepared_statement)(workspace_id)); + + match breakpoints { + Ok(bp) => { + if bp.is_empty() { + log::error!("Breakpoints are empty after querying database for them"); + } + + let mut map: BTreeMap, Vec> = Default::default(); + + for (path, breakpoint) in bp { + let path: Arc = path.into(); + map.entry(path.clone()) + .or_default() + .push(SerializedBreakpoint { + position: breakpoint.position, + path, + kind: breakpoint.kind, + }); + } + + map + } + Err(msg) => { + log::error!("Breakpoints query failed with msg: {msg}"); + Default::default() + } + } + } + /// Saves a workspace using the worktree roots. Will garbage collect any workspaces /// that used this workspace previously pub(crate) async fn save_workspace(&self, workspace: SerializedWorkspace) { @@ -540,6 +728,31 @@ impl WorkspaceDb { DELETE FROM pane_groups WHERE workspace_id = ?1; DELETE FROM panes WHERE workspace_id = ?1;))?(workspace.id) .context("Clearing old panes")?; + for (path, breakpoints) in workspace.breakpoints { + conn.exec_bound(sql!(DELETE FROM breakpoints WHERE workspace_id = ?1 AND path = ?2))?((workspace.id, path.as_ref())) + .context("Clearing old breakpoints")?; + for bp in breakpoints { + let kind = BreakpointKindWrapper::from(bp.kind); + match conn.exec_bound(sql!( + INSERT INTO breakpoints (workspace_id, path, breakpoint_location, kind, log_message) + VALUES (?1, ?2, ?3, ?4, ?5);))? + + (( + workspace.id, + path.as_ref(), + bp.position, + kind, + )) { + Ok(_) => {} + Err(err) => { + log::error!("{err}"); + continue; + } + } + } + + } + match workspace.location { SerializedWorkspaceLocation::Local(local_paths, local_paths_order) => { @@ -720,6 +933,21 @@ impl WorkspaceDb { } } + query! { + pub fn breakpoints_for_file(workspace_id: WorkspaceId, file_path: &Path) -> Result> { + SELECT breakpoint_location + FROM breakpoints + WHERE workspace_id= ?1 AND path = ?2 + } + } + + query! { + pub fn clear_breakpoints(file_path: &Path) -> Result<()> { + DELETE FROM breakpoints + WHERE file_path = ?2 + } + } + query! { fn ssh_projects() -> Result> { SELECT id, host, port, paths, user @@ -1165,6 +1393,70 @@ mod tests { use db::open_test_db; use gpui; + #[gpui::test] + async fn test_breakpoints() { + env_logger::try_init().ok(); + + let db = WorkspaceDb(open_test_db("test_breakpoints").await); + let id = db.next_id().await.unwrap(); + + let path = Path::new("/tmp/test.rs"); + + let breakpoint = Breakpoint { + position: 123, + kind: BreakpointKind::Standard, + }; + + let log_breakpoint = Breakpoint { + position: 456, + kind: BreakpointKind::Log("Test log message".into()), + }; + + let workspace = SerializedWorkspace { + id, + location: SerializedWorkspaceLocation::from_local_paths(["/tmp"]), + center_group: Default::default(), + window_bounds: Default::default(), + display: Default::default(), + docks: Default::default(), + centered_layout: false, + breakpoints: { + let mut map = collections::BTreeMap::default(); + map.insert( + Arc::from(path), + vec![ + SerializedBreakpoint { + position: breakpoint.position, + path: Arc::from(path), + kind: breakpoint.kind.clone(), + }, + SerializedBreakpoint { + position: log_breakpoint.position, + path: Arc::from(path), + kind: log_breakpoint.kind.clone(), + }, + ], + ); + map + }, + session_id: None, + window_id: None, + }; + + db.save_workspace(workspace.clone()).await; + + let loaded = db.workspace_for_roots(&["/tmp"]).unwrap(); + let loaded_breakpoints = loaded.breakpoints.get(&Arc::from(path)).unwrap(); + + assert_eq!(loaded_breakpoints.len(), 2); + assert_eq!(loaded_breakpoints[0].position, breakpoint.position); + assert_eq!(loaded_breakpoints[0].kind, breakpoint.kind); + assert_eq!(loaded_breakpoints[1].position, log_breakpoint.position); + assert_eq!(loaded_breakpoints[1].kind, log_breakpoint.kind); + assert_eq!(loaded_breakpoints[0].path, Arc::from(path)); + assert_eq!(loaded_breakpoints[1].path, Arc::from(path)); + } + #[gpui::test] async fn test_next_id_stability() { env_logger::try_init().ok(); @@ -1243,6 +1535,7 @@ mod tests { display: Default::default(), docks: Default::default(), centered_layout: false, + breakpoints: Default::default(), session_id: None, window_id: None, }; @@ -1255,6 +1548,7 @@ mod tests { display: Default::default(), docks: Default::default(), centered_layout: false, + breakpoints: Default::default(), session_id: None, window_id: None, }; @@ -1359,6 +1653,7 @@ mod tests { ), center_group, window_bounds: Default::default(), + breakpoints: Default::default(), display: Default::default(), docks: Default::default(), centered_layout: false, @@ -1393,6 +1688,7 @@ mod tests { ), center_group: Default::default(), window_bounds: Default::default(), + breakpoints: Default::default(), display: Default::default(), docks: Default::default(), centered_layout: false, @@ -1408,6 +1704,7 @@ mod tests { display: Default::default(), docks: Default::default(), centered_layout: false, + breakpoints: Default::default(), session_id: None, window_id: Some(2), }; @@ -1447,6 +1744,7 @@ mod tests { ), center_group: Default::default(), window_bounds: Default::default(), + breakpoints: Default::default(), display: Default::default(), docks: Default::default(), centered_layout: false, @@ -1486,6 +1784,7 @@ mod tests { display: Default::default(), docks: Default::default(), centered_layout: false, + breakpoints: Default::default(), session_id: Some("session-id-1".to_owned()), window_id: Some(10), }; @@ -1498,6 +1797,7 @@ mod tests { display: Default::default(), docks: Default::default(), centered_layout: false, + breakpoints: Default::default(), session_id: Some("session-id-1".to_owned()), window_id: Some(20), }; @@ -1510,6 +1810,7 @@ mod tests { display: Default::default(), docks: Default::default(), centered_layout: false, + breakpoints: Default::default(), session_id: Some("session-id-2".to_owned()), window_id: Some(30), }; @@ -1522,6 +1823,7 @@ mod tests { display: Default::default(), docks: Default::default(), centered_layout: false, + breakpoints: Default::default(), session_id: None, window_id: None, }; @@ -1539,6 +1841,7 @@ mod tests { display: Default::default(), docks: Default::default(), centered_layout: false, + breakpoints: Default::default(), session_id: Some("session-id-2".to_owned()), window_id: Some(50), }; @@ -1551,6 +1854,7 @@ mod tests { ), center_group: Default::default(), window_bounds: Default::default(), + breakpoints: Default::default(), display: Default::default(), docks: Default::default(), centered_layout: false, @@ -1608,6 +1912,7 @@ mod tests { window_bounds: Default::default(), display: Default::default(), docks: Default::default(), + breakpoints: Default::default(), centered_layout: false, session_id: None, window_id: None, @@ -1655,6 +1960,7 @@ mod tests { docks: Default::default(), centered_layout: false, session_id: Some("one-session".to_owned()), + breakpoints: Default::default(), window_id: Some(window_id), }) .collect::>(); @@ -1746,6 +2052,7 @@ mod tests { docks: Default::default(), centered_layout: false, session_id: Some("one-session".to_owned()), + breakpoints: Default::default(), window_id: Some(window_id), }) .collect::>(); diff --git a/crates/workspace/src/persistence/model.rs b/crates/workspace/src/persistence/model.rs index f4c20544db..0d8046ff20 100644 --- a/crates/workspace/src/persistence/model.rs +++ b/crates/workspace/src/persistence/model.rs @@ -10,10 +10,11 @@ use db::sqlez::{ }; use gpui::{AsyncWindowContext, Entity, WeakEntity}; use itertools::Itertools as _; -use project::Project; +use project::{debugger::breakpoint_store::SerializedBreakpoint, Project}; use remote::ssh_session::SshProjectId; use serde::{Deserialize, Serialize}; use std::{ + collections::BTreeMap, path::{Path, PathBuf}, sync::Arc, }; @@ -263,6 +264,7 @@ pub(crate) struct SerializedWorkspace { pub(crate) display: Option, pub(crate) docks: DockStructure, pub(crate) session_id: Option, + pub(crate) breakpoints: BTreeMap, Vec>, pub(crate) window_id: Option, } diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 2c2fbe6ece..c020bede08 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -127,6 +127,23 @@ static ZED_WINDOW_POSITION: LazyLock>> = LazyLock::new(|| { actions!(assistant, [ShowConfiguration]); +actions!( + debugger, + [ + Start, + Continue, + Disconnect, + Pause, + Restart, + StepInto, + StepOver, + StepOut, + StepBack, + Stop, + ToggleIgnoreBreakpoints + ] +); + actions!( workspace, [ @@ -155,6 +172,7 @@ actions!( ReloadActiveItem, SaveAs, SaveWithoutFormat, + ShutdownDebugAdapters, ToggleBottomDock, ToggleCenteredLayout, ToggleLeftDock, @@ -1211,6 +1229,7 @@ impl Workspace { // Get project paths for all of the abs_paths let mut project_paths: Vec<(PathBuf, Option)> = Vec::with_capacity(paths_to_open.len()); + for path in paths_to_open.into_iter() { if let Some((_, project_entry)) = cx .update(|cx| { @@ -3409,9 +3428,10 @@ impl Workspace { serialize_workspace = false; } pane::Event::RemoveItem { .. } => {} - pane::Event::RemovedItem { item_id } => { + pane::Event::RemovedItem { item } => { cx.emit(Event::ActiveItemChanged); - if let hash_map::Entry::Occupied(entry) = self.panes_by_item.entry(*item_id) { + self.update_window_edited(window, cx); + if let hash_map::Entry::Occupied(entry) = self.panes_by_item.entry(item.item_id()) { if entry.get().entity_id() == pane.entity_id() { entry.remove(); } @@ -4644,6 +4664,10 @@ impl Workspace { }; if let Some(location) = location { + let breakpoints = self.project.update(cx, |project, cx| { + project.breakpoint_store().read(cx).all_breakpoints(cx) + }); + let center_group = build_serialized_pane_group(&self.center.root, window, cx); let docks = build_serialized_docks(self, window, cx); let window_bounds = Some(SerializedWindowBounds(window.window_bounds())); @@ -4656,6 +4680,7 @@ impl Workspace { docks, centered_layout: self.centered_layout, session_id: self.session_id.clone(), + breakpoints, window_id: Some(window.window_handle().window_id().as_u64()), }; return window.spawn(cx, |_| persistence::DB.save_workspace(serialized_workspace)); @@ -4796,6 +4821,17 @@ impl Workspace { cx.notify(); })?; + let _ = project + .update(&mut cx, |project, cx| { + project + .breakpoint_store() + .update(cx, |breakpoint_store, cx| { + breakpoint_store + .with_serialized_breakpoints(serialized_workspace.breakpoints, cx) + }) + })? + .await; + // Clean up all the items that have _not_ been loaded. Our ItemIds aren't stable. That means // after loading the items, we might have different items and in order to avoid // the database filling up, we delete items that haven't been loaded now. diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index c5e53b817b..a7d9e2021a 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -41,6 +41,8 @@ command_palette.workspace = true command_palette_hooks.workspace = true component_preview.workspace = true copilot.workspace = true +debugger_ui.workspace = true +debugger_tools.workspace = true db.workspace = true diagnostics.workspace = true editor.workspace = true diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index fae38a14ac..bb399250c5 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -381,6 +381,8 @@ fn main() { zed::init(cx); project::Project::init(&client, cx); + debugger_ui::init(cx); + debugger_tools::init(cx); client::init(&client, cx); let telemetry = client.telemetry(); telemetry.start( diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index e2238399f5..9c84272d0e 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -18,9 +18,10 @@ use breadcrumbs::Breadcrumbs; use client::{zed_urls, ZED_URL_SCHEME}; use collections::VecDeque; use command_palette_hooks::CommandPaletteFilter; +use debugger_ui::debugger_panel::DebugPanel; use editor::ProposedChangesEditorToolbar; use editor::{scroll::Autoscroll, Editor, MultiBuffer}; -use feature_flags::FeatureFlagAppExt; +use feature_flags::{Debugger, FeatureFlagAppExt, FeatureFlagViewExt}; use futures::{channel::mpsc, select_biased, StreamExt}; use git_ui::git_panel::GitPanel; use git_ui::project_diff::ProjectDiffToolbar; @@ -35,7 +36,10 @@ use migrate::{MigrationBanner, MigrationEvent, MigrationNotification, MigrationT use migrator::{migrate_keymap, migrate_settings}; pub use open_listener::*; use outline_panel::OutlinePanel; -use paths::{local_settings_file_relative_path, local_tasks_file_relative_path}; +use paths::{ + local_debug_file_relative_path, local_settings_file_relative_path, + local_tasks_file_relative_path, +}; use project::{DirectoryLister, ProjectItem}; use project_panel::ProjectPanel; use prompt_store::PromptBuilder; @@ -45,9 +49,9 @@ use release_channel::{AppCommitSha, ReleaseChannel}; use rope::Rope; use search::project_search::ProjectSearchBar; use settings::{ - initial_project_settings_content, initial_tasks_content, update_settings_file, - InvalidSettingsError, KeymapFile, KeymapFileLoadResult, Settings, SettingsStore, - DEFAULT_KEYMAP_PATH, VIM_KEYMAP_PATH, + initial_debug_tasks_content, initial_project_settings_content, initial_tasks_content, + update_settings_file, InvalidSettingsError, KeymapFile, KeymapFileLoadResult, Settings, + SettingsStore, DEFAULT_KEYMAP_PATH, VIM_KEYMAP_PATH, }; use std::any::TypeId; use std::path::PathBuf; @@ -83,7 +87,9 @@ actions!( OpenDefaultSettings, OpenProjectSettings, OpenProjectTasks, + OpenProjectDebugTasks, OpenTasks, + OpenDebugTasks, ResetDatabase, ShowAll, ToggleFullScreen, @@ -429,6 +435,17 @@ fn initialize_panels( workspace.add_panel(channels_panel, window, cx); workspace.add_panel(chat_panel, window, cx); workspace.add_panel(notification_panel, window, cx); + cx.when_flag_enabled::(window, |_, window, cx| { + cx.spawn_in(window, |workspace, mut cx| async move { + let debug_panel = DebugPanel::load(workspace.clone(), cx.clone()).await?; + workspace.update_in(&mut cx, |workspace, window, cx| { + workspace.add_panel(debug_panel, window, cx); + })?; + Result::<_, anyhow::Error>::Ok(()) + }) + .detach() + }); + let entity = cx.entity(); let project = workspace.project().clone(); let app_state = workspace.app_state().clone(); @@ -703,10 +720,7 @@ fn register_actions( }, ) .register_action( - move |_: &mut Workspace, - _: &zed_actions::OpenKeymap, - window: &mut Window, - cx: &mut Context| { + move |_: &mut Workspace, _: &zed_actions::OpenKeymap, window, cx| { open_settings_file( paths::keymap_file(), || settings::initial_keymap_content().as_ref().into(), @@ -715,47 +729,40 @@ fn register_actions( ); }, ) + .register_action(move |_: &mut Workspace, _: &OpenSettings, window, cx| { + open_settings_file( + paths::settings_file(), + || settings::initial_user_settings_content().as_ref().into(), + window, + cx, + ); + }) .register_action( - move |_: &mut Workspace, - _: &OpenSettings, - window: &mut Window, - cx: &mut Context| { - open_settings_file( - paths::settings_file(), - || settings::initial_user_settings_content().as_ref().into(), - window, - cx, - ); - }, - ) - .register_action( - |_: &mut Workspace, - _: &OpenAccountSettings, - _: &mut Window, - cx: &mut Context| { + |_: &mut Workspace, _: &OpenAccountSettings, _: &mut Window, cx| { cx.open_url(&zed_urls::account_url(cx)); }, ) - .register_action( - move |_: &mut Workspace, - _: &OpenTasks, - window: &mut Window, - cx: &mut Context| { - open_settings_file( - paths::tasks_file(), - || settings::initial_tasks_content().as_ref().into(), - window, - cx, - ); - }, - ) + .register_action(move |_: &mut Workspace, _: &OpenTasks, window, cx| { + open_settings_file( + paths::tasks_file(), + || settings::initial_tasks_content().as_ref().into(), + window, + cx, + ); + }) + .register_action(move |_: &mut Workspace, _: &OpenDebugTasks, window, cx| { + open_settings_file( + paths::debug_tasks_file(), + || settings::initial_debug_tasks_content().as_ref().into(), + window, + cx, + ); + }) .register_action(open_project_settings_file) .register_action(open_project_tasks_file) + .register_action(open_project_debug_tasks_file) .register_action( - move |workspace: &mut Workspace, - _: &zed_actions::OpenDefaultKeymap, - window: &mut Window, - cx: &mut Context| { + move |workspace, _: &zed_actions::OpenDefaultKeymap, window, cx| { open_bundled_file( workspace, settings::default_keymap(), @@ -766,21 +773,16 @@ fn register_actions( ); }, ) - .register_action( - move |workspace: &mut Workspace, - _: &OpenDefaultSettings, - window: &mut Window, - cx: &mut Context| { - open_bundled_file( - workspace, - settings::default_settings(), - "Default Settings", - "JSON", - window, - cx, - ); - }, - ) + .register_action(move |workspace, _: &OpenDefaultSettings, window, cx| { + open_bundled_file( + workspace, + settings::default_settings(), + "Default Settings", + "JSON", + window, + cx, + ); + }) .register_action( |workspace: &mut Workspace, _: &project_panel::ToggleFocus, @@ -928,6 +930,8 @@ fn initialize_pane( toolbar.add_item(project_search_bar, window, cx); let lsp_log_item = cx.new(|_| language_tools::LspLogToolbarItemView::new()); toolbar.add_item(lsp_log_item, window, cx); + let dap_log_item = cx.new(|_| debugger_tools::DapLogToolbarItemView::new()); + toolbar.add_item(dap_log_item, window, cx); let syntax_tree_item = cx.new(|_| language_tools::SyntaxTreeToolbarItemView::new()); toolbar.add_item(syntax_tree_item, window, cx); let migration_banner = cx.new(|cx| MigrationBanner::new(workspace, cx)); @@ -1519,6 +1523,21 @@ fn open_project_tasks_file( ) } +fn open_project_debug_tasks_file( + workspace: &mut Workspace, + _: &OpenProjectDebugTasks, + window: &mut Window, + cx: &mut Context, +) { + open_local_file( + workspace, + local_debug_file_relative_path(), + initial_debug_tasks_content(), + window, + cx, + ) +} + fn open_local_file( workspace: &mut Workspace, settings_relative_path: &'static Path, @@ -4277,6 +4296,11 @@ mod tests { repl::init(app_state.fs.clone(), cx); repl::notebook::init(cx); tasks_ui::init(cx); + project::debugger::breakpoint_store::BreakpointStore::init( + &app_state.client.clone().into(), + ); + project::debugger::dap_store::DapStore::init(&app_state.client.clone().into()); + debugger_ui::init(cx); initialize_workspace(app_state.clone(), prompt_builder, cx); search::init(cx); app_state diff --git a/crates/zeta/src/rate_completion_modal.rs b/crates/zeta/src/rate_completion_modal.rs index 9e4ad6c79b..2d94481379 100644 --- a/crates/zeta/src/rate_completion_modal.rs +++ b/crates/zeta/src/rate_completion_modal.rs @@ -281,6 +281,7 @@ impl RateCompletionModal { editor.set_show_git_diff_gutter(false, cx); editor.set_show_code_actions(false, cx); editor.set_show_runnables(false, cx); + editor.set_show_breakpoints(false, cx); editor.set_show_wrap_guides(false, cx); editor.set_show_indent_guides(false, cx); editor.set_show_edit_predictions(Some(false), window, cx); diff --git a/docs/src/languages/markdown.md b/docs/src/languages/markdown.md index be8b38a79e..ef6c4d38f6 100644 --- a/docs/src/languages/markdown.md +++ b/docs/src/languages/markdown.md @@ -23,7 +23,7 @@ def fib(n): ### Format -Zed supports using Prettier to automatically re-format Markdown documents. You can trigger this manually via the {#action editor::Format} action or via the {#kb editor::Format} keyboard shortcut. Alternately, you can automattically format by enabling [`format_on_save`](./configuring-zed.md#format-on-save) in your settings.json: +Zed supports using Prettier to automatically re-format Markdown documents. You can trigger this manually via the {#action editor::Format} action or via the {#kb editor::Format} keyboard shortcut. Alternately, you can automatically format by enabling [`format_on_save`](./configuring-zed.md#format-on-save) in your settings.json: ```json "languages": { diff --git a/docs/src/model-improvement.md b/docs/src/model-improvement.md index 44610b668a..faaabe3ee1 100644 --- a/docs/src/model-improvement.md +++ b/docs/src/model-improvement.md @@ -4,7 +4,7 @@ When using the Zed Assistant, Zed does not persistently store user content or use user content for training of its models. -When using upstream services through Zed AI, we require similar assurances from our service providers. For example, usage of Anthropic Claude 3.5 via Zed AI in the Asssistant is governed by the [Anthropic Commercial Terms](https://www.anthropic.com/legal/commercial-terms) which includes the following: +When using upstream services through Zed AI, we require similar assurances from our service providers. For example, usage of Anthropic Claude 3.5 via Zed AI in the Assistant is governed by the [Anthropic Commercial Terms](https://www.anthropic.com/legal/commercial-terms) which includes the following: > "Anthropic may not train models on Customer Content from paid Services."