Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions assets/settings/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//
Expand Down
2 changes: 2 additions & 0 deletions crates/editor/src/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
95 changes: 95 additions & 0 deletions crates/editor/src/editor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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<Self>,
) -> Task<Result<Navigated>> {
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(&regex_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,
Expand Down
11 changes: 11 additions & 0 deletions crates/editor/src/editor_settings.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use core::num;

use collections::HashMap;
use gpui::App;
use language::CursorShape;
use project::project_settings::DiagnosticSeverity;
Expand Down Expand Up @@ -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<HideMouseMode>,
pub snippet_sort_order: SnippetSortOrder,
pub diagnostics_max_severity: Option<DiagnosticSeverity>,
Expand All @@ -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<String, String>,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Toolbar {
pub breadcrumbs: bool,
Expand Down Expand Up @@ -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),
Expand Down
205 changes: 203 additions & 2 deletions crates/editor/src/editor_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -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::<Editor>()
.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::<Editor>(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::<Editor>()
.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::<Editor>(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::<Editor>()
.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::<Editor>(cx)
.unwrap();
let path = editor.read(cx).project_path(cx).unwrap().path;
assert_eq!(path.as_ref(), "tests/lib_test.rs");
});
}
3 changes: 3 additions & 0 deletions crates/editor/src/element.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading