feat: initial working version of projections

dino created

Change summary

crates/vim/src/projections.rs | 195 ++++++++++++++++++++++++++++++------
1 file changed, 161 insertions(+), 34 deletions(-)

Detailed changes

crates/vim/src/projections.rs 🔗

@@ -14,17 +14,24 @@
 // - [ ] Implement `:a` to open alternate file
 // - [ ] Implement `:as` to open alternate file in split
 // - [ ] Implement `:av` to open alternate file in vertical split
-// - [ ] Implement actually updating the state from the `projections.json` file
+// - [X] Implement actually updating the state from the `projections.json` file
 // - [ ] Make this work with excerpts in multibuffers
 
 use crate::Vim;
+use anyhow::Result;
 use editor::Editor;
 use gpui::Context;
 use gpui::Window;
 use gpui::actions;
+use project::Fs;
 use project::ProjectItem;
 use project::ProjectPath;
 use regex::Regex;
+use serde::Deserialize;
+use settings::parse_json_with_comments;
+use std::collections::HashMap;
+use std::path::Path;
+use std::sync::Arc;
 use util::rel_path::RelPath;
 
 #[derive(Debug)]
@@ -33,6 +40,13 @@ struct Projection {
     target: String,
 }
 
+#[derive(Deserialize, Debug)]
+struct ProjectionValue {
+    alternate: String,
+}
+
+type ProjectionsConfig = HashMap<String, ProjectionValue>;
+
 impl Projection {
     fn new(source: &str, target: &str) -> Self {
         // Replace the `*` character in the source string, if such a character
@@ -50,8 +64,6 @@ impl Projection {
     }
 
     /// Determines whether the provided path matches this projection's source.
-    /// TODO!: We'll likely want to update this to use `ProjectPath` instead of
-    /// `&str`.
     fn matches(&self, path: &str) -> bool {
         self.source.is_match(path)
     }
@@ -82,6 +94,15 @@ pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
     Vim::action(editor, cx, Vim::open_projection);
 }
 
+async fn load_projections(root_path: &Path, fs: Arc<dyn Fs>) -> Result<ProjectionsConfig> {
+    let projections_path = root_path.join(".zed").join("projections.json");
+
+    let content = fs.load(&projections_path).await?;
+    let config = parse_json_with_comments::<ProjectionsConfig>(&content)?;
+
+    Ok(config)
+}
+
 impl Vim {
     pub fn open_projection(
         &mut self,
@@ -89,44 +110,106 @@ impl Vim {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        // Implementation for opening a projection
-        dbg!("[vim] attempting to open projection...");
         self.update_editor(cx, |_vim, editor, cx| {
-            let project_path = editor
+            let current_file_path = editor
                 .buffer()
                 .read(cx)
                 .as_singleton()
                 .and_then(|buffer| buffer.read(cx).project_path(cx));
 
-            // User is editing an empty buffer, can't even find a projection.
-            if project_path.is_none() {
+            // User is editing an empty buffer, can't find a projection.
+            let Some(current_file_path) = current_file_path else {
                 return;
-            }
-
-            if let Some(project_path) = project_path
-                && let Some(workspace) = editor.workspace()
-            {
-                dbg!(&project_path);
-                if project_path.path.as_unix_str()
-                    == "lib/phx_new_web/controllers/page_controller.ex"
-                {
-                    dbg!("[vim] opening projection...");
-                    workspace
-                        .update(cx, |workspace, cx| {
-                            let worktree_id = project_path.worktree_id;
-                            let mut project_path = ProjectPath::root_path(worktree_id);
-                            project_path.path = RelPath::unix(
-                                "test/phx_new_web/controllers/page_controller_test.exs",
-                            )
-                            .unwrap()
-                            .into_arc();
-                            dbg!(&project_path);
-
-                            workspace.open_path(project_path, None, true, window, cx)
-                        })
-                        .detach();
-                }
-            }
+            };
+
+            let Some(workspace) = editor.workspace() else {
+                return;
+            };
+
+            let Some(project) = editor.project() else {
+                return;
+            };
+
+            // Extract data we need before going async
+            let worktree_id = current_file_path.worktree_id;
+            let current_path = current_file_path.path.clone();
+            let fs = project.read(cx).fs().clone();
+
+            // Get the worktree to extract its root path
+            let worktree = project.read(cx).worktree_for_id(worktree_id, cx);
+
+            let Some(worktree) = worktree else {
+                return;
+            };
+
+            let root_path = worktree.read(cx).abs_path();
+
+            workspace.update(cx, |_workspace, cx| {
+                cx.spawn_in(window, async move |workspace, cx| {
+                    // Load the projections configuration
+                    let config = match load_projections(&root_path, fs).await {
+                        Ok(config) => {
+                            log::info!("Loaded projections config: {:?}", config);
+                            config
+                        }
+                        Err(err) => {
+                            log::warn!("Failed to load projections: {:?}", err);
+                            return;
+                        }
+                    };
+
+                    // Convert config to Projection instances and find a match
+                    let current_path_str = current_path.as_unix_str();
+                    log::info!("Looking for projection for path: {}", current_path_str);
+                    let mut alternate_path: Option<String> = None;
+
+                    for (source_pattern, projection_value) in config.iter() {
+                        log::debug!(
+                            "Trying pattern '{}' -> '{}'",
+                            source_pattern,
+                            projection_value.alternate
+                        );
+                        let projection =
+                            Projection::new(source_pattern, &projection_value.alternate);
+
+                        if projection.matches(current_path_str) {
+                            let alt = projection.alternate(current_path_str);
+                            log::info!("Found match! Alternate path: {}", alt);
+                            alternate_path = Some(alt);
+                            break;
+                        }
+                    }
+
+                    // If we found an alternate, open it
+                    if let Some(alternate_path) = alternate_path {
+                        let alternate_rel_path = match RelPath::unix(&alternate_path) {
+                            Ok(path) => path,
+                            Err(_) => return,
+                        };
+
+                        let alternate_project_path = ProjectPath {
+                            worktree_id,
+                            path: alternate_rel_path.into_arc(),
+                        };
+
+                        let result = workspace.update_in(cx, |workspace, window, cx| {
+                            workspace.open_path(alternate_project_path, None, true, window, cx)
+                        });
+
+                        match result {
+                            Ok(task) => {
+                                task.detach();
+                            }
+                            Err(err) => {
+                                log::error!("Failed to open alternate file: {:?}", err);
+                            }
+                        }
+                    } else {
+                        log::info!("No alternate projection found for: {}", current_path_str);
+                    }
+                })
+                .detach();
+            });
         });
     }
 }
@@ -134,7 +217,13 @@ impl Vim {
 #[cfg(test)]
 mod tests {
     use super::Projection;
+    use super::load_projections;
     use gpui::TestAppContext;
+    use project::FakeFs;
+    use project::Project;
+    use serde_json::json;
+    use settings::SettingsStore;
+    use util::path;
 
     #[gpui::test]
     async fn test_matches(_cx: &mut TestAppContext) {
@@ -158,4 +247,42 @@ mod tests {
         let path = "lib/app/module.ex";
         assert_eq!(projection.alternate(path), "test/app/module_test.exs");
     }
+
+    #[gpui::test]
+    async fn test_load_projections(cx: &mut TestAppContext) {
+        cx.update(|cx| {
+            let settings_store = SettingsStore::test(cx);
+            cx.set_global(settings_store);
+        });
+
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(
+            path!("/dir"),
+            json!({
+                ".zed": {
+                    "projections.json": r#"{
+                        "src/main/java/*.java": {"alternate": "src/test/java/{}.java"},
+                        "src/test/java/*.java": {"alternate": "src/main/java/{}.java"}
+                    }"#
+                }
+            }),
+        )
+        .await;
+
+        let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
+        let worktree = project.read_with(cx, |project, _cx| project.worktrees(_cx).next().unwrap());
+
+        let root_path = worktree.read_with(cx, |wt, _| wt.abs_path());
+        let config = load_projections(&root_path, fs).await.unwrap();
+
+        assert_eq!(config.len(), 2);
+        assert_eq!(
+            config.get("src/main/java/*.java").unwrap().alternate,
+            "src/test/java/{}.java"
+        );
+        assert_eq!(
+            config.get("src/test/java/*.java").unwrap().alternate,
+            "src/main/java/{}.java"
+        );
+    }
 }