diff --git a/assets/settings/default.json b/assets/settings/default.json index f1b8d9e76bc600..34c6237aec4f6d 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -1601,6 +1601,16 @@ "Markdown": [".rules", ".cursorrules", ".windsurfrules", ".clinerules"], "Shell Script": [".env.*"] }, + // Patterns for navigating between source and test files. + // Use `{}` as a placeholder for the captured part of the path. + // Define both directions to enable navigation from source to test and back. + "go_to_test": { + "patterns": { + // Elixir + "lib/{}.ex": "test/{}_test.exs", + "test/{}_test.exs": "lib/{}.ex" + } + }, // Settings for which version of Node.js and NPM to use when installing // language servers and Copilot. // diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index e823b06910fba6..99e85084c1511a 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -526,6 +526,8 @@ actions!( GoToImplementationSplit, /// Goes to the next change in the file. GoToNextChange, + /// Goes to the corresponding test file for the current file. + GoToTest, /// Goes to the parent module of the current file. GoToParentModule, /// Goes to the previous change in the file. diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 8cb3d1abf7d026..c485b66ec1ac9d 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -160,6 +160,7 @@ use project::{ project_settings::{DiagnosticSeverity, GoToDiagnosticSeverityFilter, ProjectSettings}, }; use rand::seq::SliceRandom; +use regex::Regex; use rpc::{ErrorCode, ErrorExt, proto::PeerId}; use scroll::{Autoscroll, OngoingScroll, ScrollAnchor, ScrollManager}; use selections_collection::{MutableSelectionsCollection, SelectionsCollection}; @@ -16634,6 +16635,100 @@ impl Editor { self.go_to_definition_of_kind(GotoDefinitionKind::Implementation, true, window, cx) } + pub fn go_to_test( + &mut self, + _: &GoToTest, + window: &mut Window, + cx: &mut Context, + ) -> Task> { + let Some(file) = self.target_file(cx) else { + return Task::ready(Ok(Navigated::No)); + }; + let Some(buffer) = self.buffer.read(cx).as_singleton() else { + return Task::ready(Ok(Navigated::No)); + }; + let Some(project) = self.project.clone() else { + return Task::ready(Ok(Navigated::No)); + }; + + let target_path = file.path().as_unix_str().to_string(); + let workspace = self.workspace(); + let worktree_root = project + .read(cx) + .worktree_for_id(file.worktree_id(cx), cx) + .and_then(|worktree| worktree.read(cx).root_dir()); + let patterns = EditorSettings::get_global(cx).go_to_test.patterns.clone(); + + cx.spawn_in(window, async move |_editor, cx| { + let Some(workspace) = workspace else { + return Ok(Navigated::No); + }; + + let test_path = patterns.iter().find_map(|(source_pattern, test_pattern)| { + let regex_pattern = regex::escape(source_pattern).replace(r"\{\}", "(.+)") + "$"; + let regex = Regex::new(®ex_pattern).ok()?; + let captures = regex.captures(&target_path)?; + Some(test_pattern.replace("{}", &captures[1])) + }); + + let Some(test_path) = test_path else { + return Ok(Navigated::No); + }; + + let resolved = project + .update(cx, |project, cx| { + project.resolve_path_in_buffer(&test_path, &buffer, cx) + })? + .await; + + if let Some(resolved) = resolved { + workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_resolved_path(resolved, window, cx) + })? + .await?; + } else { + let Some(root) = worktree_root else { + return Ok(Navigated::No); + }; + let abs_path = root.join(&test_path); + + let answer = workspace.update_in(cx, |_workspace, window, cx| { + window.prompt( + gpui::PromptLevel::Info, + &format!( + "Test file does not exist:\n{}\n\nWould you like to create it?", + test_path + ), + None, + &["Create", "Cancel"], + cx, + ) + })?; + + if answer.await? != 0 { + return Ok(Navigated::No); + } + + let fs = + workspace.read_with(cx, |workspace, _| workspace.app_state().fs.clone())?; + + if let Some(parent) = abs_path.parent() { + fs.create_dir(parent).await?; + } + fs.create_file(&abs_path, Default::default()).await?; + + workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_abs_path(abs_path, Default::default(), window, cx) + })? + .await?; + } + + Ok(Navigated::Yes) + }) + } + pub fn go_to_type_definition( &mut self, _: &GoToTypeDefinition, diff --git a/crates/editor/src/editor_settings.rs b/crates/editor/src/editor_settings.rs index e1984311d4eb0b..bd7c5ec191ddf9 100644 --- a/crates/editor/src/editor_settings.rs +++ b/crates/editor/src/editor_settings.rs @@ -1,5 +1,6 @@ use core::num; +use collections::HashMap; use gpui::App; use language::CursorShape; use project::project_settings::DiagnosticSeverity; @@ -49,6 +50,7 @@ pub struct EditorSettings { pub show_signature_help_after_edits: bool, pub go_to_definition_fallback: GoToDefinitionFallback, pub jupyter: Jupyter, + pub go_to_test: GoToTest, pub hide_mouse: Option, pub snippet_sort_order: SnippetSortOrder, pub diagnostics_max_severity: Option, @@ -71,6 +73,12 @@ pub struct StickyScroll { pub enabled: bool, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GoToTest { + /// Patterns for navigating between source and test files. + pub patterns: HashMap, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct Toolbar { pub breadcrumbs: bool, @@ -274,6 +282,9 @@ impl Settings for EditorSettings { jupyter: Jupyter { enabled: editor.jupyter.unwrap().enabled.unwrap(), }, + go_to_test: GoToTest { + patterns: editor.go_to_test.unwrap().patterns.unwrap(), + }, hide_mouse: editor.hide_mouse, snippet_sort_order: editor.snippet_sort_order.unwrap(), diagnostics_max_severity: editor.diagnostics_max_severity.map(Into::into), diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 20ad9ca076ed4e..742b196a3ccbe7 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -45,8 +45,9 @@ use project::{ }; use serde_json::{self, json}; use settings::{ - AllLanguageSettingsContent, EditorSettingsContent, IndentGuideBackgroundColoring, - IndentGuideColoring, ProjectSettingsContent, SearchSettingsContent, + AllLanguageSettingsContent, EditorSettingsContent, GoToTestContent, + IndentGuideBackgroundColoring, IndentGuideColoring, ProjectSettingsContent, + SearchSettingsContent, }; use std::{cell::RefCell, future::Future, rc::Rc, sync::atomic::AtomicBool, time::Instant}; use std::{ @@ -28072,3 +28073,203 @@ async fn test_multibuffer_selections_with_folding(cx: &mut TestAppContext) { 3 "}); } + +#[gpui::test] +async fn test_go_to_test(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + update_test_editor_settings(cx, |settings| { + settings.go_to_test = Some(GoToTestContent { + patterns: Some( + [("src/{}.rs".to_string(), "tests/{}_test.rs".to_string())] + .into_iter() + .collect(), + ), + }); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/a"), + json!({ + "src": { + "lib.rs": "", + }, + "tests": { + "lib_test.rs": "", + } + }), + ) + .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 = project.update(cx, |project, cx| project.worktrees(cx).next().unwrap()); + let worktree_id = worktree.update(cx, |worktree, _| worktree.id()); + + let editor = workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_path( + (worktree_id, rel_path("src/lib.rs")), + None, + true, + window, + cx, + ) + }) + .unwrap() + .await + .downcast::() + .unwrap(); + + let navigated = editor + .update_in(cx, |editor, window, cx| { + editor.go_to_test(&GoToTest, window, cx) + }) + .await + .unwrap(); + + assert_eq!(navigated, Navigated::Yes); + + // Verify we opened the test file + workspace.update(cx, |workspace, cx| { + let editor = workspace + .active_item(cx) + .unwrap() + .act_as::(cx) + .unwrap(); + let path = editor.read(cx).project_path(cx).unwrap().path; + assert_eq!(path.as_ref(), "tests/lib_test.rs"); + }); +} + +#[gpui::test] +async fn test_go_to_test_no_match(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + update_test_editor_settings(cx, |settings| { + settings.go_to_test = Some(GoToTestContent { + patterns: Some( + [("src/{}.rs".to_string(), "tests/{}_test.rs".to_string())] + .into_iter() + .collect(), + ), + }); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/a"), + json!({ + "other.rs": "", + }), + ) + .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 = project.update(cx, |project, cx| project.worktrees(cx).next().unwrap()); + let worktree_id = worktree.update(cx, |worktree, _| worktree.id()); + + let editor = workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_path((worktree_id, rel_path("other.rs")), None, true, window, cx) + }) + .unwrap() + .await + .downcast::() + .unwrap(); + + let navigated = editor + .update_in(cx, |editor, window, cx| { + editor.go_to_test(&GoToTest, window, cx) + }) + .await + .unwrap(); + + assert_eq!(navigated, Navigated::No); + + // Verify we are still in the original file + workspace.update(cx, |workspace, cx| { + let editor = workspace + .active_item(cx) + .unwrap() + .act_as::(cx) + .unwrap(); + let path = editor.read(cx).project_path(cx).unwrap().path; + assert_eq!(path.as_ref(), "other.rs"); + }); +} +#[gpui::test] +async fn test_go_to_test_create_new(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + update_test_editor_settings(cx, |settings| { + settings.go_to_test = Some(GoToTestContent { + patterns: Some( + [("src/{}.rs".to_string(), "tests/{}_test.rs".to_string())] + .into_iter() + .collect(), + ), + }); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/a"), + json!({ + "src": { + "lib.rs": "", + }, + }), + ) + .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 = project.update(cx, |project, cx| project.worktrees(cx).next().unwrap()); + let worktree_id = worktree.update(cx, |worktree, _| worktree.id()); + + let editor = workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_path( + (worktree_id, rel_path("src/lib.rs")), + None, + true, + window, + cx, + ) + }) + .unwrap() + .await + .downcast::() + .unwrap(); + + let navigate_task = editor.update_in(cx, |editor, window, cx| { + editor.go_to_test(&GoToTest, window, cx) + }); + + cx.executor().run_until_parked(); + cx.simulate_prompt_answer("Create"); + + let navigated = navigate_task.await.unwrap(); + + assert_eq!(navigated, Navigated::Yes); + + // Verify we created and opened the new test file + workspace.update(cx, |workspace, cx| { + let editor = workspace + .active_item(cx) + .unwrap() + .act_as::(cx) + .unwrap(); + let path = editor.read(cx).project_path(cx).unwrap().path; + assert_eq!(path.as_ref(), "tests/lib_test.rs"); + }); +} diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 785fd9de00888a..906c8e3ce359ae 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -420,6 +420,9 @@ impl EditorElement { .go_to_implementation_split(action, window, cx) .detach_and_log_err(cx); }); + register_action(editor, window, |editor, action, window, cx| { + editor.go_to_test(action, window, cx).detach_and_log_err(cx); + }); register_action(editor, window, |editor, action, window, cx| { editor .go_to_type_definition(action, window, cx) diff --git a/crates/settings/src/settings_content/editor.rs b/crates/settings/src/settings_content/editor.rs index 4ef5f3e427b8ca..dc151711b8eb23 100644 --- a/crates/settings/src/settings_content/editor.rs +++ b/crates/settings/src/settings_content/editor.rs @@ -203,6 +203,9 @@ pub struct EditorSettingsContent { /// /// Default: [`DocumentColorsRenderMode::Inlay`] pub lsp_document_colors: Option, + + /// Go to test related settings + pub go_to_test: Option, /// When to show the scrollbar in the completion menu. /// This setting can take four values: /// @@ -801,6 +804,18 @@ pub struct DragAndDropSelectionContent { pub delay: Option, } +/// Patterns for navigating between source and test files. +#[skip_serializing_none] +#[derive(Clone, Default, Debug, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq, Eq)] +pub struct GoToTestContent { + /// Patterns for navigating between source and test files. + /// Use `{}` as a placeholder for the captured part of the path. + /// Define both directions to enable navigation from source to test and back. + /// + /// Default: `{"lib/{}.ex": "test/{}_test.exs", "test/{}_test.exs": "lib/{}.ex"}` + pub patterns: Option>, +} + /// When to show the minimap in the editor. /// /// Default: never diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs index 0de37b5daecadb..034bed9b27c284 100644 --- a/crates/settings/src/vscode_import.rs +++ b/crates/settings/src/vscode_import.rs @@ -264,6 +264,7 @@ impl VsCodeSettings { hover_popover_enabled: self.read_bool("editor.hover.enabled"), inline_code_actions: None, jupyter: None, + go_to_test: None, lsp_document_colors: None, lsp_highlight_debounce: None, middle_click_paste: None,