Merge pull request #948 from zed-industries/project-browser-refinements

Antonio Scandurra created

Add commands for manipulating files in the project panel

Change summary

Cargo.lock                                      |    7 
assets/keymaps/default.json                     |    4 
assets/themes/cave-dark.json                    |   60 
assets/themes/cave-light.json                   |   60 
assets/themes/dark.json                         |   60 
assets/themes/light.json                        |   60 
assets/themes/solarized-dark.json               |   60 
assets/themes/solarized-light.json              |   60 
assets/themes/sulphurpool-dark.json             |   60 
assets/themes/sulphurpool-light.json            |   60 
crates/auto_update/src/auto_update.rs           |    2 
crates/chat_panel/src/chat_panel.rs             |    2 
crates/collab/src/rpc.rs                        |  245 ++++
crates/collab/src/rpc/store.rs                  |    3 
crates/contacts_panel/src/contacts_panel.rs     |    2 
crates/diagnostics/src/items.rs                 |    4 
crates/editor/src/editor.rs                     |   26 
crates/editor/src/element.rs                    |    8 
crates/file_finder/src/file_finder.rs           |    2 
crates/gpui/src/elements/mouse_event_handler.rs |   12 
crates/gpui/src/executor.rs                     |   16 
crates/gpui/src/platform/event.rs               |    3 
crates/gpui/src/platform/mac/event.rs           |    1 
crates/gpui/src/views/select.rs                 |    4 
crates/project/Cargo.toml                       |    2 
crates/project/src/fs.rs                        |   33 
crates/project/src/project.rs                   |  640 +++++-----
crates/project/src/worktree.rs                  |  573 +++++++--
crates/project_panel/Cargo.toml                 |    4 
crates/project_panel/src/project_panel.rs       | 1043 +++++++++++++-----
crates/project_symbols/src/project_symbols.rs   |   17 
crates/rpc/proto/zed.proto                      |  135 +
crates/rpc/src/proto.rs                         |   17 
crates/rpc/src/rpc.rs                           |    2 
crates/search/src/buffer_search.rs              |    4 
crates/search/src/project_search.rs             |   15 
crates/sum_tree/src/sum_tree.rs                 |   36 
crates/theme/src/theme.rs                       |    9 
crates/vim/src/vim_test_context.rs              |    2 
crates/workspace/src/lsp_status.rs              |    2 
crates/workspace/src/pane.rs                    |   49 
crates/workspace/src/sidebar.rs                 |    2 
crates/workspace/src/workspace.rs               |   22 
crates/zed/src/zed.rs                           |   21 
styles/src/styleTree/projectPanel.ts            |   49 
45 files changed, 2,289 insertions(+), 1,209 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -63,9 +63,9 @@ dependencies = [
 
 [[package]]
 name = "anyhow"
-version = "1.0.42"
+version = "1.0.57"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "595d3cfa7a60d4555cb5067b99f07142a08ea778de5cf993f7b75c7d8fabc486"
+checksum = "08f9b8508dccb7687a1d6c4ce66b2b0ecef467c94667de27d8d7fe1f8d2a9cdc"
 
 [[package]]
 name = "arrayref"
@@ -3329,7 +3329,10 @@ dependencies = [
 name = "project_panel"
 version = "0.1.0"
 dependencies = [
+ "editor",
+ "futures",
  "gpui",
+ "postage",
  "project",
  "serde_json",
  "settings",

assets/keymaps/default.json 🔗

@@ -331,7 +331,9 @@
         "context": "ProjectPanel",
         "bindings": {
             "left": "project_panel::CollapseSelectedEntry",
-            "right": "project_panel::ExpandSelectedEntry"
+            "right": "project_panel::ExpandSelectedEntry",
+            "f2": "project_panel::Rename",
+            "backspace": "project_panel::Delete"
         }
     }
 ]

assets/themes/cave-dark.json 🔗

@@ -937,6 +937,7 @@
       "top": 6,
       "bottom": 6
     },
+    "indent_width": 20,
     "entry": {
       "height": 24,
       "icon_color": "#8b8792",
@@ -946,41 +947,42 @@
         "family": "Zed Mono",
         "color": "#8b8792",
         "size": 14
+      },
+      "hover": {
+        "background": "#5852603d",
+        "text": {
+          "family": "Zed Mono",
+          "color": "#e2dfe7",
+          "size": 14
+        }
+      },
+      "active": {
+        "background": "#5852605c",
+        "text": {
+          "family": "Zed Mono",
+          "color": "#e2dfe7",
+          "size": 14
+        }
+      },
+      "active_hover": {
+        "background": "#5852603d",
+        "text": {
+          "family": "Zed Mono",
+          "color": "#efecf4",
+          "size": 14
+        }
       }
     },
-    "hovered_entry": {
-      "height": 24,
-      "background": "#5852603d",
-      "icon_color": "#8b8792",
-      "icon_size": 8,
-      "icon_spacing": 8,
-      "text": {
-        "family": "Zed Mono",
-        "color": "#e2dfe7",
-        "size": 14
-      }
-    },
-    "selected_entry": {
-      "height": 24,
-      "icon_color": "#8b8792",
-      "icon_size": 8,
-      "icon_spacing": 8,
+    "filename_editor": {
+      "background": "#26232a5c",
       "text": {
         "family": "Zed Mono",
         "color": "#e2dfe7",
         "size": 14
-      }
-    },
-    "hovered_selected_entry": {
-      "height": 24,
-      "background": "#5852603d",
-      "icon_color": "#8b8792",
-      "icon_size": 8,
-      "icon_spacing": 8,
-      "text": {
-        "family": "Zed Mono",
-        "color": "#efecf4",
-        "size": 14
+      },
+      "selection": {
+        "cursor": "#576ddb",
+        "selection": "#576ddb3d"
       }
     }
   },

assets/themes/cave-light.json 🔗

@@ -937,6 +937,7 @@
       "top": 6,
       "bottom": 6
     },
+    "indent_width": 20,
     "entry": {
       "height": 24,
       "icon_color": "#585260",
@@ -946,41 +947,42 @@
         "family": "Zed Mono",
         "color": "#585260",
         "size": 14
+      },
+      "hover": {
+        "background": "#8b87921f",
+        "text": {
+          "family": "Zed Mono",
+          "color": "#26232a",
+          "size": 14
+        }
+      },
+      "active": {
+        "background": "#8b87922e",
+        "text": {
+          "family": "Zed Mono",
+          "color": "#26232a",
+          "size": 14
+        }
+      },
+      "active_hover": {
+        "background": "#8b87921f",
+        "text": {
+          "family": "Zed Mono",
+          "color": "#19171c",
+          "size": 14
+        }
       }
     },
-    "hovered_entry": {
-      "height": 24,
-      "background": "#8b87921f",
-      "icon_color": "#585260",
-      "icon_size": 8,
-      "icon_spacing": 8,
-      "text": {
-        "family": "Zed Mono",
-        "color": "#26232a",
-        "size": 14
-      }
-    },
-    "selected_entry": {
-      "height": 24,
-      "icon_color": "#585260",
-      "icon_size": 8,
-      "icon_spacing": 8,
+    "filename_editor": {
+      "background": "#e2dfe72e",
       "text": {
         "family": "Zed Mono",
         "color": "#26232a",
         "size": 14
-      }
-    },
-    "hovered_selected_entry": {
-      "height": 24,
-      "background": "#8b87921f",
-      "icon_color": "#585260",
-      "icon_size": 8,
-      "icon_spacing": 8,
-      "text": {
-        "family": "Zed Mono",
-        "color": "#19171c",
-        "size": 14
+      },
+      "selection": {
+        "cursor": "#576ddb",
+        "selection": "#576ddb3d"
       }
     }
   },

assets/themes/dark.json 🔗

@@ -937,6 +937,7 @@
       "top": 6,
       "bottom": 6
     },
+    "indent_width": 20,
     "entry": {
       "height": 24,
       "icon_color": "#555555",
@@ -946,41 +947,42 @@
         "family": "Zed Mono",
         "color": "#808080",
         "size": 14
+      },
+      "hover": {
+        "background": "#232323",
+        "text": {
+          "family": "Zed Mono",
+          "color": "#f1f1f1",
+          "size": 14
+        }
+      },
+      "active": {
+        "background": "#2b2b2b",
+        "text": {
+          "family": "Zed Mono",
+          "color": "#f1f1f1",
+          "size": 14
+        }
+      },
+      "active_hover": {
+        "background": "#232323",
+        "text": {
+          "family": "Zed Mono",
+          "color": "#ffffff",
+          "size": 14
+        }
       }
     },
-    "hovered_entry": {
-      "height": 24,
-      "background": "#232323",
-      "icon_color": "#555555",
-      "icon_size": 8,
-      "icon_spacing": 8,
-      "text": {
-        "family": "Zed Mono",
-        "color": "#f1f1f1",
-        "size": 14
-      }
-    },
-    "selected_entry": {
-      "height": 24,
-      "icon_color": "#555555",
-      "icon_size": 8,
-      "icon_spacing": 8,
+    "filename_editor": {
+      "background": "#ffffff1f",
       "text": {
         "family": "Zed Mono",
         "color": "#f1f1f1",
         "size": 14
-      }
-    },
-    "hovered_selected_entry": {
-      "height": 24,
-      "background": "#232323",
-      "icon_color": "#555555",
-      "icon_size": 8,
-      "icon_spacing": 8,
-      "text": {
-        "family": "Zed Mono",
-        "color": "#ffffff",
-        "size": 14
+      },
+      "selection": {
+        "cursor": "#2472f2",
+        "selection": "#2472f23d"
       }
     }
   },

assets/themes/light.json 🔗

@@ -937,6 +937,7 @@
       "top": 6,
       "bottom": 6
     },
+    "indent_width": 20,
     "entry": {
       "height": 24,
       "icon_color": "#9c9c9c",
@@ -946,41 +947,42 @@
         "family": "Zed Mono",
         "color": "#636363",
         "size": 14
+      },
+      "hover": {
+        "background": "#eaeaea",
+        "text": {
+          "family": "Zed Mono",
+          "color": "#2b2b2b",
+          "size": 14
+        }
+      },
+      "active": {
+        "background": "#e3e3e3",
+        "text": {
+          "family": "Zed Mono",
+          "color": "#2b2b2b",
+          "size": 14
+        }
+      },
+      "active_hover": {
+        "background": "#eaeaea",
+        "text": {
+          "family": "Zed Mono",
+          "color": "#000000",
+          "size": 14
+        }
       }
     },
-    "hovered_entry": {
-      "height": 24,
-      "background": "#eaeaea",
-      "icon_color": "#9c9c9c",
-      "icon_size": 8,
-      "icon_spacing": 8,
-      "text": {
-        "family": "Zed Mono",
-        "color": "#2b2b2b",
-        "size": 14
-      }
-    },
-    "selected_entry": {
-      "height": 24,
-      "icon_color": "#9c9c9c",
-      "icon_size": 8,
-      "icon_spacing": 8,
+    "filename_editor": {
+      "background": "#0000000f",
       "text": {
         "family": "Zed Mono",
         "color": "#2b2b2b",
         "size": 14
-      }
-    },
-    "hovered_selected_entry": {
-      "height": 24,
-      "background": "#eaeaea",
-      "icon_color": "#9c9c9c",
-      "icon_size": 8,
-      "icon_spacing": 8,
-      "text": {
-        "family": "Zed Mono",
-        "color": "#000000",
-        "size": 14
+      },
+      "selection": {
+        "cursor": "#2472f2",
+        "selection": "#2472f23d"
       }
     }
   },

assets/themes/solarized-dark.json 🔗

@@ -937,6 +937,7 @@
       "top": 6,
       "bottom": 6
     },
+    "indent_width": 20,
     "entry": {
       "height": 24,
       "icon_color": "#93a1a1",
@@ -946,41 +947,42 @@
         "family": "Zed Mono",
         "color": "#93a1a1",
         "size": 14
+      },
+      "hover": {
+        "background": "#586e753d",
+        "text": {
+          "family": "Zed Mono",
+          "color": "#eee8d5",
+          "size": 14
+        }
+      },
+      "active": {
+        "background": "#586e755c",
+        "text": {
+          "family": "Zed Mono",
+          "color": "#eee8d5",
+          "size": 14
+        }
+      },
+      "active_hover": {
+        "background": "#586e753d",
+        "text": {
+          "family": "Zed Mono",
+          "color": "#fdf6e3",
+          "size": 14
+        }
       }
     },
-    "hovered_entry": {
-      "height": 24,
-      "background": "#586e753d",
-      "icon_color": "#93a1a1",
-      "icon_size": 8,
-      "icon_spacing": 8,
-      "text": {
-        "family": "Zed Mono",
-        "color": "#eee8d5",
-        "size": 14
-      }
-    },
-    "selected_entry": {
-      "height": 24,
-      "icon_color": "#93a1a1",
-      "icon_size": 8,
-      "icon_spacing": 8,
+    "filename_editor": {
+      "background": "#0736425c",
       "text": {
         "family": "Zed Mono",
         "color": "#eee8d5",
         "size": 14
-      }
-    },
-    "hovered_selected_entry": {
-      "height": 24,
-      "background": "#586e753d",
-      "icon_color": "#93a1a1",
-      "icon_size": 8,
-      "icon_spacing": 8,
-      "text": {
-        "family": "Zed Mono",
-        "color": "#fdf6e3",
-        "size": 14
+      },
+      "selection": {
+        "cursor": "#268bd2",
+        "selection": "#268bd23d"
       }
     }
   },

assets/themes/solarized-light.json 🔗

@@ -937,6 +937,7 @@
       "top": 6,
       "bottom": 6
     },
+    "indent_width": 20,
     "entry": {
       "height": 24,
       "icon_color": "#586e75",
@@ -946,41 +947,42 @@
         "family": "Zed Mono",
         "color": "#586e75",
         "size": 14
+      },
+      "hover": {
+        "background": "#93a1a11f",
+        "text": {
+          "family": "Zed Mono",
+          "color": "#073642",
+          "size": 14
+        }
+      },
+      "active": {
+        "background": "#93a1a12e",
+        "text": {
+          "family": "Zed Mono",
+          "color": "#073642",
+          "size": 14
+        }
+      },
+      "active_hover": {
+        "background": "#93a1a11f",
+        "text": {
+          "family": "Zed Mono",
+          "color": "#002b36",
+          "size": 14
+        }
       }
     },
-    "hovered_entry": {
-      "height": 24,
-      "background": "#93a1a11f",
-      "icon_color": "#586e75",
-      "icon_size": 8,
-      "icon_spacing": 8,
-      "text": {
-        "family": "Zed Mono",
-        "color": "#073642",
-        "size": 14
-      }
-    },
-    "selected_entry": {
-      "height": 24,
-      "icon_color": "#586e75",
-      "icon_size": 8,
-      "icon_spacing": 8,
+    "filename_editor": {
+      "background": "#eee8d52e",
       "text": {
         "family": "Zed Mono",
         "color": "#073642",
         "size": 14
-      }
-    },
-    "hovered_selected_entry": {
-      "height": 24,
-      "background": "#93a1a11f",
-      "icon_color": "#586e75",
-      "icon_size": 8,
-      "icon_spacing": 8,
-      "text": {
-        "family": "Zed Mono",
-        "color": "#002b36",
-        "size": 14
+      },
+      "selection": {
+        "cursor": "#268bd2",
+        "selection": "#268bd23d"
       }
     }
   },

assets/themes/sulphurpool-dark.json 🔗

@@ -937,6 +937,7 @@
       "top": 6,
       "bottom": 6
     },
+    "indent_width": 20,
     "entry": {
       "height": 24,
       "icon_color": "#979db4",
@@ -946,41 +947,42 @@
         "family": "Zed Mono",
         "color": "#979db4",
         "size": 14
+      },
+      "hover": {
+        "background": "#5e66873d",
+        "text": {
+          "family": "Zed Mono",
+          "color": "#dfe2f1",
+          "size": 14
+        }
+      },
+      "active": {
+        "background": "#5e66875c",
+        "text": {
+          "family": "Zed Mono",
+          "color": "#dfe2f1",
+          "size": 14
+        }
+      },
+      "active_hover": {
+        "background": "#5e66873d",
+        "text": {
+          "family": "Zed Mono",
+          "color": "#f5f7ff",
+          "size": 14
+        }
       }
     },
-    "hovered_entry": {
-      "height": 24,
-      "background": "#5e66873d",
-      "icon_color": "#979db4",
-      "icon_size": 8,
-      "icon_spacing": 8,
-      "text": {
-        "family": "Zed Mono",
-        "color": "#dfe2f1",
-        "size": 14
-      }
-    },
-    "selected_entry": {
-      "height": 24,
-      "icon_color": "#979db4",
-      "icon_size": 8,
-      "icon_spacing": 8,
+    "filename_editor": {
+      "background": "#2932565c",
       "text": {
         "family": "Zed Mono",
         "color": "#dfe2f1",
         "size": 14
-      }
-    },
-    "hovered_selected_entry": {
-      "height": 24,
-      "background": "#5e66873d",
-      "icon_color": "#979db4",
-      "icon_size": 8,
-      "icon_spacing": 8,
-      "text": {
-        "family": "Zed Mono",
-        "color": "#f5f7ff",
-        "size": 14
+      },
+      "selection": {
+        "cursor": "#3d8fd1",
+        "selection": "#3d8fd13d"
       }
     }
   },

assets/themes/sulphurpool-light.json 🔗

@@ -937,6 +937,7 @@
       "top": 6,
       "bottom": 6
     },
+    "indent_width": 20,
     "entry": {
       "height": 24,
       "icon_color": "#5e6687",
@@ -946,41 +947,42 @@
         "family": "Zed Mono",
         "color": "#5e6687",
         "size": 14
+      },
+      "hover": {
+        "background": "#979db41f",
+        "text": {
+          "family": "Zed Mono",
+          "color": "#293256",
+          "size": 14
+        }
+      },
+      "active": {
+        "background": "#979db42e",
+        "text": {
+          "family": "Zed Mono",
+          "color": "#293256",
+          "size": 14
+        }
+      },
+      "active_hover": {
+        "background": "#979db41f",
+        "text": {
+          "family": "Zed Mono",
+          "color": "#202746",
+          "size": 14
+        }
       }
     },
-    "hovered_entry": {
-      "height": 24,
-      "background": "#979db41f",
-      "icon_color": "#5e6687",
-      "icon_size": 8,
-      "icon_spacing": 8,
-      "text": {
-        "family": "Zed Mono",
-        "color": "#293256",
-        "size": 14
-      }
-    },
-    "selected_entry": {
-      "height": 24,
-      "icon_color": "#5e6687",
-      "icon_size": 8,
-      "icon_spacing": 8,
+    "filename_editor": {
+      "background": "#dfe2f12e",
       "text": {
         "family": "Zed Mono",
         "color": "#293256",
         "size": 14
-      }
-    },
-    "hovered_selected_entry": {
-      "height": 24,
-      "background": "#979db41f",
-      "icon_color": "#5e6687",
-      "icon_size": 8,
-      "icon_spacing": 8,
-      "text": {
-        "family": "Zed Mono",
-        "color": "#202746",
-        "size": 14
+      },
+      "selection": {
+        "cursor": "#3d8fd1",
+        "selection": "#3d8fd13d"
       }
     }
   },

crates/auto_update/src/auto_update.rs 🔗

@@ -270,7 +270,7 @@ impl View for AutoUpdateIndicator {
                         )
                         .boxed()
                     })
-                    .on_click(|cx| cx.dispatch_action(DismissErrorMessage))
+                    .on_click(|_, cx| cx.dispatch_action(DismissErrorMessage))
                     .boxed()
                 }
                 AutoUpdateStatus::Idle => Empty::new().boxed(),

crates/chat_panel/src/chat_panel.rs 🔗

@@ -320,7 +320,7 @@ impl ChatPanel {
                 .boxed()
             })
             .with_cursor_style(CursorStyle::PointingHand)
-            .on_click(move |cx| {
+            .on_click(move |_, cx| {
                 let rpc = rpc.clone();
                 let this = this.clone();
                 cx.spawn(|mut cx| async move {

crates/collab/src/rpc.rs 🔗

@@ -126,6 +126,9 @@ impl Server {
             .add_request_handler(Server::forward_project_request::<proto::PerformRename>)
             .add_request_handler(Server::forward_project_request::<proto::ReloadBuffers>)
             .add_request_handler(Server::forward_project_request::<proto::FormatBuffers>)
+            .add_request_handler(Server::forward_project_request::<proto::CreateProjectEntry>)
+            .add_request_handler(Server::forward_project_request::<proto::RenameProjectEntry>)
+            .add_request_handler(Server::forward_project_request::<proto::DeleteProjectEntry>)
             .add_request_handler(Server::update_buffer)
             .add_message_handler(Server::update_buffer_file)
             .add_message_handler(Server::buffer_reloaded)
@@ -157,9 +160,7 @@ impl Server {
                 let span = info_span!(
                     "handle message",
                     payload_type = envelope.payload_type_name(),
-                    payload = serde_json::to_string_pretty(&envelope.payload)
-                        .unwrap()
-                        .as_str(),
+                    payload = format!("{:?}", envelope.payload).as_str(),
                 );
                 let future = (handler)(server, *envelope);
                 async move {
@@ -447,6 +448,7 @@ impl Server {
                                 .cloned()
                                 .collect(),
                             visible: worktree.visible,
+                            scan_id: shared_worktree.scan_id,
                         })
                     })
                     .collect();
@@ -577,6 +579,7 @@ impl Server {
             request.payload.worktree_id,
             &request.payload.removed_entries,
             &request.payload.updated_entries,
+            request.payload.scan_id,
         )?;
 
         broadcast(request.sender_id, connection_ids, |connection_id| {
@@ -1808,6 +1811,176 @@ mod tests {
             .await;
     }
 
+    #[gpui::test(iterations = 10)]
+    async fn test_fs_operations(
+        executor: Arc<Deterministic>,
+        cx_a: &mut TestAppContext,
+        cx_b: &mut TestAppContext,
+    ) {
+        executor.forbid_parking();
+        let fs = FakeFs::new(cx_a.background());
+
+        // Connect to a server as 2 clients.
+        let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await;
+        let mut client_a = server.create_client(cx_a, "user_a").await;
+        let mut client_b = server.create_client(cx_b, "user_b").await;
+
+        // Share a project as client A
+        fs.insert_tree(
+            "/dir",
+            json!({
+                ".zed.toml": r#"collaborators = ["user_b"]"#,
+                "a.txt": "a-contents",
+                "b.txt": "b-contents",
+            }),
+        )
+        .await;
+
+        let (project_a, worktree_id) = client_a.build_local_project(fs, "/dir", cx_a).await;
+        let project_id = project_a.read_with(cx_a, |project, _| project.remote_id().unwrap());
+        project_a
+            .update(cx_a, |project, cx| project.share(cx))
+            .await
+            .unwrap();
+
+        let project_b = client_b.build_remote_project(project_id, cx_b).await;
+
+        let worktree_a =
+            project_a.read_with(cx_a, |project, cx| project.worktrees(cx).next().unwrap());
+        let worktree_b =
+            project_b.read_with(cx_b, |project, cx| project.worktrees(cx).next().unwrap());
+
+        let entry = project_b
+            .update(cx_b, |project, cx| {
+                project
+                    .create_entry((worktree_id, "c.txt"), false, cx)
+                    .unwrap()
+            })
+            .await
+            .unwrap();
+        worktree_a.read_with(cx_a, |worktree, _| {
+            assert_eq!(
+                worktree
+                    .paths()
+                    .map(|p| p.to_string_lossy())
+                    .collect::<Vec<_>>(),
+                [".zed.toml", "a.txt", "b.txt", "c.txt"]
+            );
+        });
+        worktree_b.read_with(cx_b, |worktree, _| {
+            assert_eq!(
+                worktree
+                    .paths()
+                    .map(|p| p.to_string_lossy())
+                    .collect::<Vec<_>>(),
+                [".zed.toml", "a.txt", "b.txt", "c.txt"]
+            );
+        });
+
+        project_b
+            .update(cx_b, |project, cx| {
+                project.rename_entry(entry.id, Path::new("d.txt"), cx)
+            })
+            .unwrap()
+            .await
+            .unwrap();
+        worktree_a.read_with(cx_a, |worktree, _| {
+            assert_eq!(
+                worktree
+                    .paths()
+                    .map(|p| p.to_string_lossy())
+                    .collect::<Vec<_>>(),
+                [".zed.toml", "a.txt", "b.txt", "d.txt"]
+            );
+        });
+        worktree_b.read_with(cx_b, |worktree, _| {
+            assert_eq!(
+                worktree
+                    .paths()
+                    .map(|p| p.to_string_lossy())
+                    .collect::<Vec<_>>(),
+                [".zed.toml", "a.txt", "b.txt", "d.txt"]
+            );
+        });
+
+        let dir_entry = project_b
+            .update(cx_b, |project, cx| {
+                project
+                    .create_entry((worktree_id, "DIR"), true, cx)
+                    .unwrap()
+            })
+            .await
+            .unwrap();
+        worktree_a.read_with(cx_a, |worktree, _| {
+            assert_eq!(
+                worktree
+                    .paths()
+                    .map(|p| p.to_string_lossy())
+                    .collect::<Vec<_>>(),
+                [".zed.toml", "DIR", "a.txt", "b.txt", "d.txt"]
+            );
+        });
+        worktree_b.read_with(cx_b, |worktree, _| {
+            assert_eq!(
+                worktree
+                    .paths()
+                    .map(|p| p.to_string_lossy())
+                    .collect::<Vec<_>>(),
+                [".zed.toml", "DIR", "a.txt", "b.txt", "d.txt"]
+            );
+        });
+
+        project_b
+            .update(cx_b, |project, cx| {
+                project.delete_entry(dir_entry.id, cx).unwrap()
+            })
+            .await
+            .unwrap();
+        worktree_a.read_with(cx_a, |worktree, _| {
+            assert_eq!(
+                worktree
+                    .paths()
+                    .map(|p| p.to_string_lossy())
+                    .collect::<Vec<_>>(),
+                [".zed.toml", "a.txt", "b.txt", "d.txt"]
+            );
+        });
+        worktree_b.read_with(cx_b, |worktree, _| {
+            assert_eq!(
+                worktree
+                    .paths()
+                    .map(|p| p.to_string_lossy())
+                    .collect::<Vec<_>>(),
+                [".zed.toml", "a.txt", "b.txt", "d.txt"]
+            );
+        });
+
+        project_b
+            .update(cx_b, |project, cx| {
+                project.delete_entry(entry.id, cx).unwrap()
+            })
+            .await
+            .unwrap();
+        worktree_a.read_with(cx_a, |worktree, _| {
+            assert_eq!(
+                worktree
+                    .paths()
+                    .map(|p| p.to_string_lossy())
+                    .collect::<Vec<_>>(),
+                [".zed.toml", "a.txt", "b.txt"]
+            );
+        });
+        worktree_b.read_with(cx_b, |worktree, _| {
+            assert_eq!(
+                worktree
+                    .paths()
+                    .map(|p| p.to_string_lossy())
+                    .collect::<Vec<_>>(),
+                [".zed.toml", "a.txt", "b.txt"]
+            );
+        });
+    }
+
     #[gpui::test(iterations = 10)]
     async fn test_buffer_conflict_after_save(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) {
         cx_a.foreground().forbid_parking();
@@ -3725,7 +3898,7 @@ mod tests {
         let (_window_b, workspace_b) = cx_b.add_window(|cx| Workspace::new(&params, cx));
         let editor_b = workspace_b
             .update(cx_b, |workspace, cx| {
-                workspace.open_path((worktree_id, "main.rs"), cx)
+                workspace.open_path((worktree_id, "main.rs"), true, cx)
             })
             .await
             .unwrap()
@@ -3973,7 +4146,7 @@ mod tests {
         let (_window_b, workspace_b) = cx_b.add_window(|cx| Workspace::new(&params, cx));
         let editor_b = workspace_b
             .update(cx_b, |workspace, cx| {
-                workspace.open_path((worktree_id, "one.rs"), cx)
+                workspace.open_path((worktree_id, "one.rs"), true, cx)
             })
             .await
             .unwrap()
@@ -4725,7 +4898,7 @@ mod tests {
         let pane_a = workspace_a.read_with(cx_a, |workspace, _| workspace.active_pane().clone());
         let editor_a1 = workspace_a
             .update(cx_a, |workspace, cx| {
-                workspace.open_path((worktree_id, "1.txt"), cx)
+                workspace.open_path((worktree_id, "1.txt"), true, cx)
             })
             .await
             .unwrap()
@@ -4733,7 +4906,7 @@ mod tests {
             .unwrap();
         let editor_a2 = workspace_a
             .update(cx_a, |workspace, cx| {
-                workspace.open_path((worktree_id, "2.txt"), cx)
+                workspace.open_path((worktree_id, "2.txt"), true, cx)
             })
             .await
             .unwrap()
@@ -4744,7 +4917,7 @@ mod tests {
         let workspace_b = client_b.build_workspace(&project_b, cx_b);
         let editor_b1 = workspace_b
             .update(cx_b, |workspace, cx| {
-                workspace.open_path((worktree_id, "1.txt"), cx)
+                workspace.open_path((worktree_id, "1.txt"), true, cx)
             })
             .await
             .unwrap()
@@ -4937,7 +5110,7 @@ mod tests {
         let pane_a1 = workspace_a.read_with(cx_a, |workspace, _| workspace.active_pane().clone());
         let _editor_a1 = workspace_a
             .update(cx_a, |workspace, cx| {
-                workspace.open_path((worktree_id, "1.txt"), cx)
+                workspace.open_path((worktree_id, "1.txt"), true, cx)
             })
             .await
             .unwrap()
@@ -4949,7 +5122,7 @@ mod tests {
         let pane_b1 = workspace_b.read_with(cx_b, |workspace, _| workspace.active_pane().clone());
         let _editor_b1 = workspace_b
             .update(cx_b, |workspace, cx| {
-                workspace.open_path((worktree_id, "2.txt"), cx)
+                workspace.open_path((worktree_id, "2.txt"), true, cx)
             })
             .await
             .unwrap()
@@ -4984,7 +5157,7 @@ mod tests {
             .update(cx_a, |workspace, cx| {
                 workspace.activate_next_pane(cx);
                 assert_eq!(*workspace.active_pane(), pane_a1);
-                workspace.open_path((worktree_id, "3.txt"), cx)
+                workspace.open_path((worktree_id, "3.txt"), true, cx)
             })
             .await
             .unwrap();
@@ -4992,7 +5165,7 @@ mod tests {
             .update(cx_b, |workspace, cx| {
                 workspace.activate_next_pane(cx);
                 assert_eq!(*workspace.active_pane(), pane_b1);
-                workspace.open_path((worktree_id, "4.txt"), cx)
+                workspace.open_path((worktree_id, "4.txt"), true, cx)
             })
             .await
             .unwrap();
@@ -5081,7 +5254,7 @@ mod tests {
         let workspace_a = client_a.build_workspace(&project_a, cx_a);
         let _editor_a1 = workspace_a
             .update(cx_a, |workspace, cx| {
-                workspace.open_path((worktree_id, "1.txt"), cx)
+                workspace.open_path((worktree_id, "1.txt"), true, cx)
             })
             .await
             .unwrap()
@@ -5194,7 +5367,7 @@ mod tests {
         // When client B activates a different item in the original pane, it automatically stops following client A.
         workspace_b
             .update(cx_b, |workspace, cx| {
-                workspace.open_path((worktree_id, "2.txt"), cx)
+                workspace.open_path((worktree_id, "2.txt"), true, cx)
             })
             .await
             .unwrap();
@@ -5633,6 +5806,7 @@ mod tests {
                     guest_client.username,
                     id
                 );
+                assert_eq!(guest_snapshot.scan_id(), host_snapshot.scan_id());
             }
 
             guest_client
@@ -6354,6 +6528,49 @@ mod tests {
                                 client.buffers.extend(search.await?.into_keys());
                             }
                         }
+                        60..=69 => {
+                            let worktree = project
+                                .read_with(cx, |project, cx| {
+                                    project
+                                        .worktrees(&cx)
+                                        .filter(|worktree| {
+                                            let worktree = worktree.read(cx);
+                                            worktree.is_visible()
+                                                && worktree.entries(false).any(|e| e.is_file())
+                                                && worktree
+                                                    .root_entry()
+                                                    .map_or(false, |e| e.is_dir())
+                                        })
+                                        .choose(&mut *rng.lock())
+                                })
+                                .unwrap();
+                            let (worktree_id, worktree_root_name) = worktree
+                                .read_with(cx, |worktree, _| {
+                                    (worktree.id(), worktree.root_name().to_string())
+                                });
+
+                            let mut new_name = String::new();
+                            for _ in 0..10 {
+                                let letter = rng.lock().gen_range('a'..='z');
+                                new_name.push(letter);
+                            }
+                            let mut new_path = PathBuf::new();
+                            new_path.push(new_name);
+                            new_path.set_extension("rs");
+                            log::info!(
+                                "{}: creating {:?} in worktree {} ({})",
+                                guest_username,
+                                new_path,
+                                worktree_id,
+                                worktree_root_name,
+                            );
+                            project
+                                .update(cx, |project, cx| {
+                                    project.create_entry((worktree_id, new_path), false, cx)
+                                })
+                                .unwrap()
+                                .await?;
+                        }
                         _ => {
                             buffer.update(cx, |buffer, cx| {
                                 log::info!(

crates/collab/src/rpc/store.rs 🔗

@@ -46,6 +46,7 @@ pub struct ProjectShare {
 pub struct WorktreeShare {
     pub entries: HashMap<u64, proto::Entry>,
     pub diagnostic_summaries: BTreeMap<PathBuf, proto::DiagnosticSummary>,
+    pub scan_id: u64,
 }
 
 #[derive(Default)]
@@ -561,6 +562,7 @@ impl Store {
         worktree_id: u64,
         removed_entries: &[u64],
         updated_entries: &[proto::Entry],
+        scan_id: u64,
     ) -> Result<Vec<ConnectionId>> {
         let project = self.write_project(project_id, connection_id)?;
         let worktree = project
@@ -574,6 +576,7 @@ impl Store {
         for entry in updated_entries {
             worktree.entries.insert(entry.id, entry.clone());
         }
+        worktree.scan_id = scan_id;
         let connection_ids = project.connection_ids();
         Ok(connection_ids)
     }

crates/contacts_panel/src/contacts_panel.rs 🔗

@@ -204,7 +204,7 @@ impl ContactsPanel {
                                 } else {
                                     CursorStyle::Arrow
                                 })
-                                .on_click(move |cx| {
+                                .on_click(move |_, cx| {
                                     if !is_host && !is_guest {
                                         cx.dispatch_global_action(JoinProject {
                                             project_id,

crates/diagnostics/src/items.rs 🔗

@@ -161,7 +161,7 @@ impl View for DiagnosticIndicator {
                     .boxed()
             })
             .with_cursor_style(CursorStyle::PointingHand)
-            .on_click(|cx| cx.dispatch_action(crate::Deploy))
+            .on_click(|_, cx| cx.dispatch_action(crate::Deploy))
             .aligned()
             .boxed(),
         );
@@ -194,7 +194,7 @@ impl View for DiagnosticIndicator {
                     .boxed()
                 })
                 .with_cursor_style(CursorStyle::PointingHand)
-                .on_click(|cx| cx.dispatch_action(GoToNextDiagnostic))
+                .on_click(|_, cx| cx.dispatch_action(GoToNextDiagnostic))
                 .boxed(),
             );
         }

crates/editor/src/editor.rs 🔗

@@ -9354,19 +9354,10 @@ mod tests {
         let fs = FakeFs::new(cx.background().clone());
         fs.insert_file("/file.rs", Default::default()).await;
 
-        let project = Project::test(fs, cx);
+        let project = Project::test(fs, ["/file.rs"], cx).await;
         project.update(cx, |project, _| project.languages().add(Arc::new(language)));
-
-        let worktree_id = project
-            .update(cx, |project, cx| {
-                project.find_or_create_local_worktree("/file.rs", true, cx)
-            })
-            .await
-            .unwrap()
-            .0
-            .read_with(cx, |tree, _| tree.id());
         let buffer = project
-            .update(cx, |project, cx| project.open_buffer((worktree_id, ""), cx))
+            .update(cx, |project, cx| project.open_local_buffer("/file.rs", cx))
             .await
             .unwrap();
 
@@ -9485,19 +9476,10 @@ mod tests {
         let fs = FakeFs::new(cx.background().clone());
         fs.insert_file("/file.rs", text).await;
 
-        let project = Project::test(fs, cx);
+        let project = Project::test(fs, ["/file.rs"], cx).await;
         project.update(cx, |project, _| project.languages().add(Arc::new(language)));
-
-        let worktree_id = project
-            .update(cx, |project, cx| {
-                project.find_or_create_local_worktree("/file.rs", true, cx)
-            })
-            .await
-            .unwrap()
-            .0
-            .read_with(cx, |tree, _| tree.id());
         let buffer = project
-            .update(cx, |project, cx| project.open_buffer((worktree_id, ""), cx))
+            .update(cx, |project, cx| project.open_local_buffer("/file.rs", cx))
             .await
             .unwrap();
         let mut fake_server = fake_servers.next().await.unwrap();

crates/editor/src/element.rs 🔗

@@ -875,6 +875,12 @@ impl Element for EditorElement {
                     .max(constraint.min_along(Axis::Vertical))
                     .min(line_height * max_lines as f32),
             )
+        } else if let EditorMode::SingleLine = snapshot.mode {
+            size.set_y(
+                line_height
+                    .min(constraint.max_along(Axis::Vertical))
+                    .max(constraint.min_along(Axis::Vertical)),
+            )
         } else if size.y().is_infinite() {
             size.set_y(scroll_height);
         }
@@ -1183,7 +1189,7 @@ impl Element for EditorElement {
                 click_count,
                 ..
             } => self.mouse_down(*position, *alt, *shift, *click_count, layout, paint, cx),
-            Event::LeftMouseUp { position } => self.mouse_up(*position, cx),
+            Event::LeftMouseUp { position, .. } => self.mouse_up(*position, cx),
             Event::LeftMouseDragged { position } => {
                 self.mouse_dragged(*position, layout, paint, cx)
             }

crates/file_finder/src/file_finder.rs 🔗

@@ -102,7 +102,7 @@ impl FileFinder {
         match event {
             Event::Selected(project_path) => {
                 workspace
-                    .open_path(project_path.clone(), cx)
+                    .open_path(project_path.clone(), true, cx)
                     .detach_and_log_err(cx);
                 workspace.dismiss_modal(cx);
             }

crates/gpui/src/elements/mouse_event_handler.rs 🔗

@@ -15,7 +15,7 @@ pub struct MouseEventHandler {
     child: ElementBox,
     cursor_style: Option<CursorStyle>,
     mouse_down_handler: Option<Box<dyn FnMut(&mut EventContext)>>,
-    click_handler: Option<Box<dyn FnMut(&mut EventContext)>>,
+    click_handler: Option<Box<dyn FnMut(usize, &mut EventContext)>>,
     drag_handler: Option<Box<dyn FnMut(Vector2F, &mut EventContext)>>,
     padding: Padding,
 }
@@ -57,7 +57,7 @@ impl MouseEventHandler {
         self
     }
 
-    pub fn on_click(mut self, handler: impl FnMut(&mut EventContext) + 'static) -> Self {
+    pub fn on_click(mut self, handler: impl FnMut(usize, &mut EventContext) + 'static) -> Self {
         self.click_handler = Some(Box::new(handler));
         self
     }
@@ -151,14 +151,18 @@ impl Element for MouseEventHandler {
                     handled_in_child
                 }
             }
-            Event::LeftMouseUp { position, .. } => {
+            Event::LeftMouseUp {
+                position,
+                click_count,
+                ..
+            } => {
                 state.prev_drag_position = None;
                 if !handled_in_child && state.clicked {
                     state.clicked = false;
                     cx.notify();
                     if let Some(handler) = click_handler {
                         if hit_bounds.contains_point(*position) {
-                            handler(cx);
+                            handler(*click_count, cx);
                         }
                     }
                     true

crates/gpui/src/executor.rs 🔗

@@ -360,6 +360,14 @@ impl Deterministic {
 
         self.state.lock().now = new_now;
     }
+
+    pub fn forbid_parking(&self) {
+        use rand::prelude::*;
+
+        let mut state = self.state.lock();
+        state.forbid_parking = true;
+        state.rng = StdRng::seed_from_u64(state.seed);
+    }
 }
 
 impl Drop for Timer {
@@ -507,14 +515,8 @@ impl Foreground {
 
     #[cfg(any(test, feature = "test-support"))]
     pub fn forbid_parking(&self) {
-        use rand::prelude::*;
-
         match self {
-            Self::Deterministic { executor, .. } => {
-                let mut state = executor.state.lock();
-                state.forbid_parking = true;
-                state.rng = StdRng::seed_from_u64(state.seed);
-            }
+            Self::Deterministic { executor, .. } => executor.forbid_parking(),
             _ => panic!("this method can only be called on a deterministic executor"),
         }
     }

crates/gpui/src/platform/event.rs 🔗

@@ -28,6 +28,7 @@ pub enum Event {
     },
     LeftMouseUp {
         position: Vector2F,
+        click_count: usize,
     },
     LeftMouseDragged {
         position: Vector2F,
@@ -68,7 +69,7 @@ impl Event {
             Event::KeyDown { .. } => None,
             Event::ScrollWheel { position, .. }
             | Event::LeftMouseDown { position, .. }
-            | Event::LeftMouseUp { position }
+            | Event::LeftMouseUp { position, .. }
             | Event::LeftMouseDragged { position }
             | Event::RightMouseDown { position, .. }
             | Event::RightMouseUp { position }

crates/gpui/src/platform/mac/event.rs 🔗

@@ -129,6 +129,7 @@ impl Event {
                     native_event.locationInWindow().x as f32,
                     window_height - native_event.locationInWindow().y as f32,
                 ),
+                click_count: native_event.clickCount() as usize,
             }),
             NSEventType::NSRightMouseDown => {
                 let modifiers = native_event.modifierFlags();

crates/gpui/src/views/select.rs 🔗

@@ -119,7 +119,7 @@ impl View for Select {
                 .with_style(style.header)
                 .boxed()
             })
-            .on_click(move |cx| cx.dispatch_action(ToggleSelect))
+            .on_click(move |_, cx| cx.dispatch_action(ToggleSelect))
             .boxed(),
         );
         if self.is_open {
@@ -153,7 +153,7 @@ impl View for Select {
                                                 )
                                             },
                                         )
-                                        .on_click(move |cx| cx.dispatch_action(SelectItem(ix)))
+                                        .on_click(move |_, cx| cx.dispatch_action(SelectItem(ix)))
                                         .boxed()
                                     }))
                                 },

crates/project/Cargo.toml 🔗

@@ -29,7 +29,7 @@ settings = { path = "../settings" }
 sum_tree = { path = "../sum_tree" }
 util = { path = "../util" }
 aho-corasick = "0.7"
-anyhow = "1.0.38"
+anyhow = "1.0.57"
 async-trait = "0.1"
 futures = "0.3"
 ignore = "0.4"

crates/project/src/fs.rs 🔗

@@ -379,7 +379,7 @@ impl FakeFs {
     async fn simulate_random_delay(&self) {
         self.executor
             .upgrade()
-            .expect("excecutor has been dropped")
+            .expect("executor has been dropped")
             .simulate_random_delay()
             .await;
     }
@@ -493,7 +493,7 @@ impl Fs for FakeFs {
         });
 
         for (relative_path, entry) in removed {
-            let new_path = target.join(relative_path);
+            let new_path = normalize_path(&target.join(relative_path));
             state.entries.insert(new_path, entry);
         }
 
@@ -501,13 +501,15 @@ impl Fs for FakeFs {
         Ok(())
     }
 
-    async fn remove_dir(&self, path: &Path, options: RemoveOptions) -> Result<()> {
-        let path = normalize_path(path);
+    async fn remove_dir(&self, dir_path: &Path, options: RemoveOptions) -> Result<()> {
+        let dir_path = normalize_path(dir_path);
         let mut state = self.state.lock().await;
-        state.validate_path(&path)?;
-        if let Some(entry) = state.entries.get(&path) {
+        state.validate_path(&dir_path)?;
+        if let Some(entry) = state.entries.get(&dir_path) {
             if !entry.metadata.is_dir {
-                return Err(anyhow!("cannot remove {path:?} because it is not a dir"));
+                return Err(anyhow!(
+                    "cannot remove {dir_path:?} because it is not a dir"
+                ));
             }
 
             if !options.recursive {
@@ -517,14 +519,14 @@ impl Fs for FakeFs {
                     .filter(|path| path.starts_with(path))
                     .count();
                 if descendants > 1 {
-                    return Err(anyhow!("{path:?} is not empty"));
+                    return Err(anyhow!("{dir_path:?} is not empty"));
                 }
             }
 
-            state.entries.retain(|path, _| !path.starts_with(path));
-            state.emit_event(&[path]).await;
+            state.entries.retain(|path, _| !path.starts_with(&dir_path));
+            state.emit_event(&[dir_path]).await;
         } else if !options.ignore_if_not_exists {
-            return Err(anyhow!("{path:?} does not exist"));
+            return Err(anyhow!("{dir_path:?} does not exist"));
         }
 
         Ok(())
@@ -647,9 +649,16 @@ impl Fs for FakeFs {
         let (tx, rx) = smol::channel::unbounded();
         state.event_txs.push(tx);
         let path = path.to_path_buf();
+        let executor = self.executor.clone();
         Box::pin(futures::StreamExt::filter(rx, move |events| {
             let result = events.iter().any(|event| event.path.starts_with(&path));
-            async move { result }
+            let executor = executor.clone();
+            async move {
+                if let Some(executor) = executor.clone().upgrade() {
+                    executor.simulate_random_delay().await;
+                }
+                result
+            }
         }))
     }
 

crates/project/src/project.rs 🔗

@@ -36,9 +36,11 @@ use std::{
     cell::RefCell,
     cmp::{self, Ordering},
     convert::TryInto,
+    ffi::OsString,
     hash::Hash,
     mem,
     ops::Range,
+    os::unix::{ffi::OsStrExt, prelude::OsStringExt},
     path::{Component, Path, PathBuf},
     rc::Rc,
     sync::{
@@ -225,6 +227,8 @@ impl DiagnosticSummary {
 pub struct ProjectEntryId(usize);
 
 impl ProjectEntryId {
+    pub const MAX: Self = Self(usize::MAX);
+
     pub fn new(counter: &AtomicUsize) -> Self {
         Self(counter.fetch_add(1, SeqCst))
     }
@@ -257,6 +261,9 @@ impl Project {
         client.add_model_message_handler(Self::handle_update_buffer);
         client.add_model_message_handler(Self::handle_update_diagnostic_summary);
         client.add_model_message_handler(Self::handle_update_worktree);
+        client.add_model_request_handler(Self::handle_create_project_entry);
+        client.add_model_request_handler(Self::handle_rename_project_entry);
+        client.add_model_request_handler(Self::handle_delete_project_entry);
         client.add_model_request_handler(Self::handle_apply_additional_edits_for_completion);
         client.add_model_request_handler(Self::handle_apply_code_action);
         client.add_model_request_handler(Self::handle_reload_buffers);
@@ -452,12 +459,27 @@ impl Project {
     }
 
     #[cfg(any(test, feature = "test-support"))]
-    pub fn test(fs: Arc<dyn Fs>, cx: &mut gpui::TestAppContext) -> ModelHandle<Project> {
+    pub async fn test(
+        fs: Arc<dyn Fs>,
+        root_paths: impl IntoIterator<Item = impl AsRef<Path>>,
+        cx: &mut gpui::TestAppContext,
+    ) -> ModelHandle<Project> {
         let languages = Arc::new(LanguageRegistry::test());
         let http_client = client::test::FakeHttpClient::with_404_response();
         let client = client::Client::new(http_client.clone());
         let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http_client, cx));
-        cx.update(|cx| Project::local(client, user_store, languages, fs, cx))
+        let project = cx.update(|cx| Project::local(client, user_store, languages, fs, cx));
+        for path in root_paths {
+            let (tree, _) = project
+                .update(cx, |project, cx| {
+                    project.find_or_create_local_worktree(path, true, cx)
+                })
+                .await
+                .unwrap();
+            tree.read_with(cx, |tree, _| tree.as_local().unwrap().scan_complete())
+                .await;
+        }
+        project
     }
 
     pub fn buffer_for_id(&self, remote_id: u64, cx: &AppContext) -> Option<ModelHandle<Buffer>> {
@@ -669,6 +691,125 @@ impl Project {
             .map(|worktree| worktree.read(cx).id())
     }
 
+    pub fn create_entry(
+        &mut self,
+        project_path: impl Into<ProjectPath>,
+        is_directory: bool,
+        cx: &mut ModelContext<Self>,
+    ) -> Option<Task<Result<Entry>>> {
+        let project_path = project_path.into();
+        let worktree = self.worktree_for_id(project_path.worktree_id, cx)?;
+        if self.is_local() {
+            Some(worktree.update(cx, |worktree, cx| {
+                worktree
+                    .as_local_mut()
+                    .unwrap()
+                    .create_entry(project_path.path, is_directory, cx)
+            }))
+        } else {
+            let client = self.client.clone();
+            let project_id = self.remote_id().unwrap();
+            Some(cx.spawn_weak(|_, mut cx| async move {
+                let response = client
+                    .request(proto::CreateProjectEntry {
+                        worktree_id: project_path.worktree_id.to_proto(),
+                        project_id,
+                        path: project_path.path.as_os_str().as_bytes().to_vec(),
+                        is_directory,
+                    })
+                    .await?;
+                let entry = response
+                    .entry
+                    .ok_or_else(|| anyhow!("missing entry in response"))?;
+                worktree
+                    .update(&mut cx, |worktree, cx| {
+                        worktree.as_remote().unwrap().insert_entry(
+                            entry,
+                            response.worktree_scan_id as usize,
+                            cx,
+                        )
+                    })
+                    .await
+            }))
+        }
+    }
+
+    pub fn rename_entry(
+        &mut self,
+        entry_id: ProjectEntryId,
+        new_path: impl Into<Arc<Path>>,
+        cx: &mut ModelContext<Self>,
+    ) -> Option<Task<Result<Entry>>> {
+        let worktree = self.worktree_for_entry(entry_id, cx)?;
+        let new_path = new_path.into();
+        if self.is_local() {
+            worktree.update(cx, |worktree, cx| {
+                worktree
+                    .as_local_mut()
+                    .unwrap()
+                    .rename_entry(entry_id, new_path, cx)
+            })
+        } else {
+            let client = self.client.clone();
+            let project_id = self.remote_id().unwrap();
+
+            Some(cx.spawn_weak(|_, mut cx| async move {
+                let response = client
+                    .request(proto::RenameProjectEntry {
+                        project_id,
+                        entry_id: entry_id.to_proto(),
+                        new_path: new_path.as_os_str().as_bytes().to_vec(),
+                    })
+                    .await?;
+                let entry = response
+                    .entry
+                    .ok_or_else(|| anyhow!("missing entry in response"))?;
+                worktree
+                    .update(&mut cx, |worktree, cx| {
+                        worktree.as_remote().unwrap().insert_entry(
+                            entry,
+                            response.worktree_scan_id as usize,
+                            cx,
+                        )
+                    })
+                    .await
+            }))
+        }
+    }
+
+    pub fn delete_entry(
+        &mut self,
+        entry_id: ProjectEntryId,
+        cx: &mut ModelContext<Self>,
+    ) -> Option<Task<Result<()>>> {
+        let worktree = self.worktree_for_entry(entry_id, cx)?;
+        if self.is_local() {
+            worktree.update(cx, |worktree, cx| {
+                worktree.as_local_mut().unwrap().delete_entry(entry_id, cx)
+            })
+        } else {
+            let client = self.client.clone();
+            let project_id = self.remote_id().unwrap();
+            Some(cx.spawn_weak(|_, mut cx| async move {
+                let response = client
+                    .request(proto::DeleteProjectEntry {
+                        project_id,
+                        entry_id: entry_id.to_proto(),
+                    })
+                    .await?;
+                worktree
+                    .update(&mut cx, move |worktree, cx| {
+                        worktree.as_remote().unwrap().delete_entry(
+                            entry_id,
+                            response.worktree_scan_id as usize,
+                            cx,
+                        )
+                    })
+                    .await
+            }))
+        }
+    }
+
     pub fn can_share(&self, cx: &AppContext) -> bool {
         self.is_local() && self.visible_worktrees(cx).next().is_some()
     }
@@ -850,6 +991,18 @@ impl Project {
         })
     }
 
+    pub fn open_local_buffer(
+        &mut self,
+        abs_path: impl AsRef<Path>,
+        cx: &mut ModelContext<Self>,
+    ) -> Task<Result<ModelHandle<Buffer>>> {
+        if let Some((worktree, relative_path)) = self.find_local_worktree(abs_path.as_ref(), cx) {
+            self.open_buffer((worktree.read(cx).id(), relative_path), cx)
+        } else {
+            Task::ready(Err(anyhow!("no such path")))
+        }
+    }
+
     pub fn open_buffer(
         &mut self,
         path: impl Into<ProjectPath>,
@@ -879,9 +1032,9 @@ impl Project {
                 entry.insert(rx.clone());
 
                 let load_buffer = if worktree.read(cx).is_local() {
-                    self.open_local_buffer(&project_path.path, &worktree, cx)
+                    self.open_local_buffer_internal(&project_path.path, &worktree, cx)
                 } else {
-                    self.open_remote_buffer(&project_path.path, &worktree, cx)
+                    self.open_remote_buffer_internal(&project_path.path, &worktree, cx)
                 };
 
                 cx.spawn(move |this, mut cx| async move {
@@ -911,7 +1064,7 @@ impl Project {
         })
     }
 
-    fn open_local_buffer(
+    fn open_local_buffer_internal(
         &mut self,
         path: &Arc<Path>,
         worktree: &ModelHandle<Worktree>,
@@ -928,7 +1081,7 @@ impl Project {
         })
     }
 
-    fn open_remote_buffer(
+    fn open_remote_buffer_internal(
         &mut self,
         path: &Arc<Path>,
         worktree: &ModelHandle<Worktree>,
@@ -3664,6 +3817,7 @@ impl Project {
                 entries: Default::default(),
                 diagnostic_summaries: Default::default(),
                 visible: envelope.payload.visible,
+                scan_id: 0,
             };
             let (worktree, load_task) =
                 Worktree::remote(remote_id, replica_id, worktree, client, cx);
@@ -3704,6 +3858,86 @@ impl Project {
         })
     }
 
+    async fn handle_create_project_entry(
+        this: ModelHandle<Self>,
+        envelope: TypedEnvelope<proto::CreateProjectEntry>,
+        _: Arc<Client>,
+        mut cx: AsyncAppContext,
+    ) -> Result<proto::ProjectEntryResponse> {
+        let worktree = this.update(&mut cx, |this, cx| {
+            let worktree_id = WorktreeId::from_proto(envelope.payload.worktree_id);
+            this.worktree_for_id(worktree_id, cx)
+                .ok_or_else(|| anyhow!("worktree not found"))
+        })?;
+        let worktree_scan_id = worktree.read_with(&cx, |worktree, _| worktree.scan_id());
+        let entry = worktree
+            .update(&mut cx, |worktree, cx| {
+                let worktree = worktree.as_local_mut().unwrap();
+                let path = PathBuf::from(OsString::from_vec(envelope.payload.path));
+                worktree.create_entry(path, envelope.payload.is_directory, cx)
+            })
+            .await?;
+        Ok(proto::ProjectEntryResponse {
+            entry: Some((&entry).into()),
+            worktree_scan_id: worktree_scan_id as u64,
+        })
+    }
+
+    async fn handle_rename_project_entry(
+        this: ModelHandle<Self>,
+        envelope: TypedEnvelope<proto::RenameProjectEntry>,
+        _: Arc<Client>,
+        mut cx: AsyncAppContext,
+    ) -> Result<proto::ProjectEntryResponse> {
+        let entry_id = ProjectEntryId::from_proto(envelope.payload.entry_id);
+        let worktree = this.read_with(&cx, |this, cx| {
+            this.worktree_for_entry(entry_id, cx)
+                .ok_or_else(|| anyhow!("worktree not found"))
+        })?;
+        let worktree_scan_id = worktree.read_with(&cx, |worktree, _| worktree.scan_id());
+        let entry = worktree
+            .update(&mut cx, |worktree, cx| {
+                let new_path = PathBuf::from(OsString::from_vec(envelope.payload.new_path));
+                worktree
+                    .as_local_mut()
+                    .unwrap()
+                    .rename_entry(entry_id, new_path, cx)
+                    .ok_or_else(|| anyhow!("invalid entry"))
+            })?
+            .await?;
+        Ok(proto::ProjectEntryResponse {
+            entry: Some((&entry).into()),
+            worktree_scan_id: worktree_scan_id as u64,
+        })
+    }
+
+    async fn handle_delete_project_entry(
+        this: ModelHandle<Self>,
+        envelope: TypedEnvelope<proto::DeleteProjectEntry>,
+        _: Arc<Client>,
+        mut cx: AsyncAppContext,
+    ) -> Result<proto::ProjectEntryResponse> {
+        let entry_id = ProjectEntryId::from_proto(envelope.payload.entry_id);
+        let worktree = this.read_with(&cx, |this, cx| {
+            this.worktree_for_entry(entry_id, cx)
+                .ok_or_else(|| anyhow!("worktree not found"))
+        })?;
+        let worktree_scan_id = worktree.read_with(&cx, |worktree, _| worktree.scan_id());
+        worktree
+            .update(&mut cx, |worktree, cx| {
+                worktree
+                    .as_local_mut()
+                    .unwrap()
+                    .delete_entry(entry_id, cx)
+                    .ok_or_else(|| anyhow!("invalid entry"))
+            })?
+            .await?;
+        Ok(proto::ProjectEntryResponse {
+            entry: None,
+            worktree_scan_id: worktree_scan_id as u64,
+        })
+    }
+
     async fn handle_update_diagnostic_summary(
         this: ModelHandle<Self>,
         envelope: TypedEnvelope<proto::UpdateDiagnosticSummary>,
@@ -4905,6 +5139,8 @@ impl Item for Buffer {
 
 #[cfg(test)]
 mod tests {
+    use crate::worktree::WorktreeHandle;
+
     use super::{Event, *};
     use fs::RealFs;
     use futures::{future, StreamExt};
@@ -4918,7 +5154,6 @@ mod tests {
     use std::{cell::RefCell, os::unix, path::PathBuf, rc::Rc, task::Poll};
     use unindent::Unindent as _;
     use util::{assert_set_eq, test::temp_tree};
-    use worktree::WorktreeHandle as _;
 
     #[gpui::test]
     async fn test_populate_and_search(cx: &mut gpui::TestAppContext) {
@@ -4945,19 +5180,10 @@ mod tests {
         )
         .unwrap();
 
-        let project = Project::test(Arc::new(RealFs), cx);
+        let project = Project::test(Arc::new(RealFs), [root_link_path], cx).await;
 
-        let (tree, _) = project
-            .update(cx, |project, cx| {
-                project.find_or_create_local_worktree(&root_link_path, true, cx)
-            })
-            .await
-            .unwrap();
-
-        cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
-            .await;
-        cx.read(|cx| {
-            let tree = tree.read(cx);
+        project.read_with(cx, |project, cx| {
+            let tree = project.worktrees(cx).next().unwrap().read(cx);
             assert_eq!(tree.file_count(), 5);
             assert_eq!(
                 tree.inode_for_path("fennel/grape"),
@@ -5038,25 +5264,16 @@ mod tests {
         )
         .await;
 
-        let project = Project::test(fs.clone(), cx);
+        let project = Project::test(fs.clone(), ["/the-root"], cx).await;
         project.update(cx, |project, _| {
             project.languages.add(Arc::new(rust_language));
             project.languages.add(Arc::new(json_language));
         });
 
-        let worktree_id = project
-            .update(cx, |project, cx| {
-                project.find_or_create_local_worktree("/the-root", true, cx)
-            })
-            .await
-            .unwrap()
-            .0
-            .read_with(cx, |tree, _| tree.id());
-
         // Open a buffer without an associated language server.
         let toml_buffer = project
             .update(cx, |project, cx| {
-                project.open_buffer((worktree_id, "Cargo.toml"), cx)
+                project.open_local_buffer("/the-root/Cargo.toml", cx)
             })
             .await
             .unwrap();
@@ -5064,7 +5281,7 @@ mod tests {
         // Open a buffer with an associated language server.
         let rust_buffer = project
             .update(cx, |project, cx| {
-                project.open_buffer((worktree_id, "test.rs"), cx)
+                project.open_local_buffer("/the-root/test.rs", cx)
             })
             .await
             .unwrap();
@@ -5111,7 +5328,7 @@ mod tests {
         // Open a third buffer with a different associated language server.
         let json_buffer = project
             .update(cx, |project, cx| {
-                project.open_buffer((worktree_id, "package.json"), cx)
+                project.open_local_buffer("/the-root/package.json", cx)
             })
             .await
             .unwrap();
@@ -5141,7 +5358,7 @@ mod tests {
         // it is also configured based on the existing language server's capabilities.
         let rust_buffer2 = project
             .update(cx, |project, cx| {
-                project.open_buffer((worktree_id, "test2.rs"), cx)
+                project.open_local_buffer("/the-root/test2.rs", cx)
             })
             .await
             .unwrap();
@@ -5270,6 +5487,7 @@ mod tests {
                 language_id: Default::default()
             },
         );
+
         // We clear the diagnostics, since the language has changed.
         rust_buffer2.read_with(cx, |buffer, _| {
             assert_eq!(
@@ -5382,34 +5600,14 @@ mod tests {
         )
         .await;
 
-        let project = Project::test(fs, cx);
-        let worktree_a_id = project
-            .update(cx, |project, cx| {
-                project.find_or_create_local_worktree("/dir/a.rs", true, cx)
-            })
-            .await
-            .unwrap()
-            .0
-            .read_with(cx, |tree, _| tree.id());
-        let worktree_b_id = project
-            .update(cx, |project, cx| {
-                project.find_or_create_local_worktree("/dir/b.rs", true, cx)
-            })
-            .await
-            .unwrap()
-            .0
-            .read_with(cx, |tree, _| tree.id());
+        let project = Project::test(fs, ["/dir/a.rs", "/dir/b.rs"], cx).await;
 
         let buffer_a = project
-            .update(cx, |project, cx| {
-                project.open_buffer((worktree_a_id, ""), cx)
-            })
+            .update(cx, |project, cx| project.open_local_buffer("/dir/a.rs", cx))
             .await
             .unwrap();
         let buffer_b = project
-            .update(cx, |project, cx| {
-                project.open_buffer((worktree_b_id, ""), cx)
-            })
+            .update(cx, |project, cx| project.open_local_buffer("/dir/b.rs", cx))
             .await
             .unwrap();
 
@@ -5513,25 +5711,14 @@ mod tests {
         )
         .await;
 
-        let project = Project::test(fs, cx);
+        let project = Project::test(fs, ["/dir"], cx).await;
         project.update(cx, |project, _| project.languages.add(Arc::new(language)));
-
-        let (tree, _) = project
-            .update(cx, |project, cx| {
-                project.find_or_create_local_worktree("/dir", true, cx)
-            })
-            .await
-            .unwrap();
-        let worktree_id = tree.read_with(cx, |tree, _| tree.id());
-
-        cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
-            .await;
+        let worktree_id =
+            project.read_with(cx, |p, cx| p.worktrees(cx).next().unwrap().read(cx).id());
 
         // Cause worktree to start the fake language server
         let _buffer = project
-            .update(cx, |project, cx| {
-                project.open_buffer((worktree_id, Path::new("b.rs")), cx)
-            })
+            .update(cx, |project, cx| project.open_local_buffer("/dir/b.rs", cx))
             .await
             .unwrap();
 
@@ -5577,7 +5764,7 @@ mod tests {
         );
 
         let buffer = project
-            .update(cx, |p, cx| p.open_buffer((worktree_id, "a.rs"), cx))
+            .update(cx, |p, cx| p.open_local_buffer("/dir/a.rs", cx))
             .await
             .unwrap();
 
@@ -5646,22 +5833,11 @@ mod tests {
         let fs = FakeFs::new(cx.background());
         fs.insert_tree("/dir", json!({ "a.rs": "" })).await;
 
-        let project = Project::test(fs, cx);
+        let project = Project::test(fs, ["/dir"], cx).await;
         project.update(cx, |project, _| project.languages.add(Arc::new(language)));
 
-        let worktree_id = project
-            .update(cx, |project, cx| {
-                project.find_or_create_local_worktree("/dir", true, cx)
-            })
-            .await
-            .unwrap()
-            .0
-            .read_with(cx, |tree, _| tree.id());
-
         let buffer = project
-            .update(cx, |project, cx| {
-                project.open_buffer((worktree_id, "a.rs"), cx)
-            })
+            .update(cx, |project, cx| project.open_local_buffer("/dir/a.rs", cx))
             .await
             .unwrap();
 
@@ -5726,22 +5902,11 @@ mod tests {
         let fs = FakeFs::new(cx.background());
         fs.insert_tree("/dir", json!({ "a.rs": text })).await;
 
-        let project = Project::test(fs, cx);
+        let project = Project::test(fs, ["/dir"], cx).await;
         project.update(cx, |project, _| project.languages.add(Arc::new(language)));
 
-        let worktree_id = project
-            .update(cx, |project, cx| {
-                project.find_or_create_local_worktree("/dir", true, cx)
-            })
-            .await
-            .unwrap()
-            .0
-            .read_with(cx, |tree, _| tree.id());
-
         let buffer = project
-            .update(cx, |project, cx| {
-                project.open_buffer((worktree_id, "a.rs"), cx)
-            })
+            .update(cx, |project, cx| project.open_local_buffer("/dir/a.rs", cx))
             .await
             .unwrap();
 
@@ -6006,20 +6171,9 @@ mod tests {
         let fs = FakeFs::new(cx.background());
         fs.insert_tree("/dir", json!({ "a.rs": text })).await;
 
-        let project = Project::test(fs, cx);
-        let worktree_id = project
-            .update(cx, |project, cx| {
-                project.find_or_create_local_worktree("/dir", true, cx)
-            })
-            .await
-            .unwrap()
-            .0
-            .read_with(cx, |tree, _| tree.id());
-
+        let project = Project::test(fs, ["/dir"], cx).await;
         let buffer = project
-            .update(cx, |project, cx| {
-                project.open_buffer((worktree_id, "a.rs"), cx)
-            })
+            .update(cx, |project, cx| project.open_local_buffer("/dir/a.rs", cx))
             .await
             .unwrap();
 
@@ -6108,22 +6262,10 @@ mod tests {
         )
         .await;
 
-        let project = Project::test(fs, cx);
+        let project = Project::test(fs, ["/dir"], cx).await;
         project.update(cx, |project, _| project.languages.add(Arc::new(language)));
-
-        let worktree_id = project
-            .update(cx, |project, cx| {
-                project.find_or_create_local_worktree("/dir", true, cx)
-            })
-            .await
-            .unwrap()
-            .0
-            .read_with(cx, |tree, _| tree.id());
-
         let buffer = project
-            .update(cx, |project, cx| {
-                project.open_buffer((worktree_id, "a.rs"), cx)
-            })
+            .update(cx, |project, cx| project.open_local_buffer("/dir/a.rs", cx))
             .await
             .unwrap();
 
@@ -6274,20 +6416,9 @@ mod tests {
         )
         .await;
 
-        let project = Project::test(fs, cx);
-        let worktree_id = project
-            .update(cx, |project, cx| {
-                project.find_or_create_local_worktree("/dir", true, cx)
-            })
-            .await
-            .unwrap()
-            .0
-            .read_with(cx, |tree, _| tree.id());
-
+        let project = Project::test(fs, ["/dir"], cx).await;
         let buffer = project
-            .update(cx, |project, cx| {
-                project.open_buffer((worktree_id, "a.rs"), cx)
-            })
+            .update(cx, |project, cx| project.open_local_buffer("/dir/a.rs", cx))
             .await
             .unwrap();
 
@@ -6408,17 +6539,7 @@ mod tests {
             }
         }));
 
-        let project = Project::test(Arc::new(RealFs), cx);
-        let (tree, _) = project
-            .update(cx, |project, cx| {
-                project.find_or_create_local_worktree(&dir.path(), true, cx)
-            })
-            .await
-            .unwrap();
-
-        cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
-            .await;
-
+        let project = Project::test(Arc::new(RealFs), [dir.path()], cx).await;
         let cancel_flag = Default::default();
         let results = project
             .read_with(cx, |project, cx| {
@@ -6451,21 +6572,11 @@ mod tests {
         )
         .await;
 
-        let project = Project::test(fs, cx);
+        let project = Project::test(fs, ["/dir/b.rs"], cx).await;
         project.update(cx, |project, _| project.languages.add(Arc::new(language)));
 
-        let (tree, _) = project
-            .update(cx, |project, cx| {
-                project.find_or_create_local_worktree("/dir/b.rs", true, cx)
-            })
-            .await
-            .unwrap();
-        let worktree_id = tree.read_with(cx, |tree, _| tree.id());
-        cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
-            .await;
-
         let buffer = project
-            .update(cx, |project, cx| project.open_buffer((worktree_id, ""), cx))
+            .update(cx, |project, cx| project.open_local_buffer("/dir/b.rs", cx))
             .await
             .unwrap();
 
@@ -6555,21 +6666,10 @@ mod tests {
         )
         .await;
 
-        let project = Project::test(fs, cx);
+        let project = Project::test(fs, ["/dir"], cx).await;
         project.update(cx, |project, _| project.languages.add(Arc::new(language)));
-
-        let (tree, _) = project
-            .update(cx, |project, cx| {
-                project.find_or_create_local_worktree("/dir", true, cx)
-            })
-            .await
-            .unwrap();
-        let worktree_id = tree.read_with(cx, |tree, _| tree.id());
-        cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
-            .await;
-
         let buffer = project
-            .update(cx, |p, cx| p.open_buffer((worktree_id, "a.ts"), cx))
+            .update(cx, |p, cx| p.open_local_buffer("/dir/a.ts", cx))
             .await
             .unwrap();
 
@@ -6624,21 +6724,10 @@ mod tests {
         )
         .await;
 
-        let project = Project::test(fs, cx);
+        let project = Project::test(fs, ["/dir"], cx).await;
         project.update(cx, |project, _| project.languages.add(Arc::new(language)));
-
-        let (tree, _) = project
-            .update(cx, |project, cx| {
-                project.find_or_create_local_worktree("/dir", true, cx)
-            })
-            .await
-            .unwrap();
-        let worktree_id = tree.read_with(cx, |tree, _| tree.id());
-        cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
-            .await;
-
         let buffer = project
-            .update(cx, |p, cx| p.open_buffer((worktree_id, "a.ts"), cx))
+            .update(cx, |p, cx| p.open_local_buffer("/dir/a.ts", cx))
             .await
             .unwrap();
 
@@ -6741,18 +6830,9 @@ mod tests {
         )
         .await;
 
-        let project = Project::test(fs.clone(), cx);
-        let worktree_id = project
-            .update(cx, |p, cx| {
-                p.find_or_create_local_worktree("/dir", true, cx)
-            })
-            .await
-            .unwrap()
-            .0
-            .read_with(cx, |tree, _| tree.id());
-
+        let project = Project::test(fs.clone(), ["/dir"], cx).await;
         let buffer = project
-            .update(cx, |p, cx| p.open_buffer((worktree_id, "file1"), cx))
+            .update(cx, |p, cx| p.open_local_buffer("/dir/file1", cx))
             .await
             .unwrap();
         buffer
@@ -6779,18 +6859,9 @@ mod tests {
         )
         .await;
 
-        let project = Project::test(fs.clone(), cx);
-        let worktree_id = project
-            .update(cx, |p, cx| {
-                p.find_or_create_local_worktree("/dir/file1", true, cx)
-            })
-            .await
-            .unwrap()
-            .0
-            .read_with(cx, |tree, _| tree.id());
-
+        let project = Project::test(fs.clone(), ["/dir/file1"], cx).await;
         let buffer = project
-            .update(cx, |p, cx| p.open_buffer((worktree_id, ""), cx))
+            .update(cx, |p, cx| p.open_local_buffer("/dir/file1", cx))
             .await
             .unwrap();
         buffer
@@ -6810,15 +6881,7 @@ mod tests {
         let fs = FakeFs::new(cx.background());
         fs.insert_tree("/dir", json!({})).await;
 
-        let project = Project::test(fs.clone(), cx);
-        let (worktree, _) = project
-            .update(cx, |project, cx| {
-                project.find_or_create_local_worktree("/dir", true, cx)
-            })
-            .await
-            .unwrap();
-        let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id());
-
+        let project = Project::test(fs.clone(), ["/dir"], cx).await;
         let buffer = project.update(cx, |project, cx| {
             project.create_buffer("", None, cx).unwrap()
         });
@@ -6842,7 +6905,7 @@ mod tests {
 
         let opened_buffer = project
             .update(cx, |project, cx| {
-                project.open_buffer((worktree_id, "file1"), cx)
+                project.open_local_buffer("/dir/file1", cx)
             })
             .await
             .unwrap();
@@ -6865,24 +6928,18 @@ mod tests {
             }
         }));
 
-        let project = Project::test(Arc::new(RealFs), cx);
+        let project = Project::test(Arc::new(RealFs), [dir.path()], cx).await;
         let rpc = project.read_with(cx, |p, _| p.client.clone());
 
-        let (tree, _) = project
-            .update(cx, |p, cx| {
-                p.find_or_create_local_worktree(dir.path(), true, cx)
-            })
-            .await
-            .unwrap();
-        let worktree_id = tree.read_with(cx, |tree, _| tree.id());
-
         let buffer_for_path = |path: &'static str, cx: &mut gpui::TestAppContext| {
-            let buffer = project.update(cx, |p, cx| p.open_buffer((worktree_id, path), cx));
+            let buffer = project.update(cx, |p, cx| p.open_local_buffer(dir.path().join(path), cx));
             async move { buffer.await.unwrap() }
         };
         let id_for_path = |path: &'static str, cx: &gpui::TestAppContext| {
-            tree.read_with(cx, |tree, _| {
-                tree.entry_for_path(path)
+            project.read_with(cx, |project, cx| {
+                let tree = project.worktrees(cx).next().unwrap();
+                tree.read(cx)
+                    .entry_for_path(path)
                     .expect(&format!("no entry for path {}", path))
                     .id
             })
@@ -6897,11 +6954,8 @@ mod tests {
         let file3_id = id_for_path("a/file3", &cx);
         let file4_id = id_for_path("b/c/file4", &cx);
 
-        // Wait for the initial scan.
-        cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
-            .await;
-
         // Create a remote copy of this worktree.
+        let tree = project.read_with(cx, |project, cx| project.worktrees(cx).next().unwrap());
         let initial_snapshot = tree.read_with(cx, |tree, _| tree.as_local().unwrap().snapshot());
         let (remote, load_task) = cx.update(|cx| {
             Worktree::remote(
@@ -6912,6 +6966,7 @@ mod tests {
                 cx,
             )
         });
+        // tree
         load_task.await;
 
         cx.read(|cx| {
@@ -7005,7 +7060,7 @@ mod tests {
     async fn test_buffer_deduping(cx: &mut gpui::TestAppContext) {
         let fs = FakeFs::new(cx.background());
         fs.insert_tree(
-            "/the-dir",
+            "/dir",
             json!({
                 "a.txt": "a-contents",
                 "b.txt": "b-contents",
@@ -7013,22 +7068,14 @@ mod tests {
         )
         .await;
 
-        let project = Project::test(fs.clone(), cx);
-        let worktree_id = project
-            .update(cx, |p, cx| {
-                p.find_or_create_local_worktree("/the-dir", true, cx)
-            })
-            .await
-            .unwrap()
-            .0
-            .read_with(cx, |tree, _| tree.id());
+        let project = Project::test(fs.clone(), ["/dir"], cx).await;
 
         // Spawn multiple tasks to open paths, repeating some paths.
         let (buffer_a_1, buffer_b, buffer_a_2) = project.update(cx, |p, cx| {
             (
-                p.open_buffer((worktree_id, "a.txt"), cx),
-                p.open_buffer((worktree_id, "b.txt"), cx),
-                p.open_buffer((worktree_id, "a.txt"), cx),
+                p.open_local_buffer("/dir/a.txt", cx),
+                p.open_local_buffer("/dir/b.txt", cx),
+                p.open_local_buffer("/dir/a.txt", cx),
             )
         });
 
@@ -7045,7 +7092,7 @@ mod tests {
         // Open the same path again while it is still open.
         drop(buffer_a_1);
         let buffer_a_3 = project
-            .update(cx, |p, cx| p.open_buffer((worktree_id, "a.txt"), cx))
+            .update(cx, |p, cx| p.open_local_buffer("/dir/a.txt", cx))
             .await
             .unwrap();
 
@@ -7055,30 +7102,21 @@ mod tests {
 
     #[gpui::test]
     async fn test_buffer_is_dirty(cx: &mut gpui::TestAppContext) {
-        use std::fs;
-
-        let dir = temp_tree(json!({
-            "file1": "abc",
-            "file2": "def",
-            "file3": "ghi",
-        }));
-
-        let project = Project::test(Arc::new(RealFs), cx);
-        let (worktree, _) = project
-            .update(cx, |p, cx| {
-                p.find_or_create_local_worktree(dir.path(), true, cx)
-            })
-            .await
-            .unwrap();
-        let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id());
+        let fs = FakeFs::new(cx.background());
+        fs.insert_tree(
+            "/dir",
+            json!({
+                "file1": "abc",
+                "file2": "def",
+                "file3": "ghi",
+            }),
+        )
+        .await;
 
-        worktree.flush_fs_events(&cx).await;
-        worktree
-            .read_with(cx, |t, _| t.as_local().unwrap().scan_complete())
-            .await;
+        let project = Project::test(fs.clone(), ["/dir"], cx).await;
 
         let buffer1 = project
-            .update(cx, |p, cx| p.open_buffer((worktree_id, "file1"), cx))
+            .update(cx, |p, cx| p.open_local_buffer("/dir/file1", cx))
             .await
             .unwrap();
         let events = Rc::new(RefCell::new(Vec::new()));
@@ -7148,7 +7186,7 @@ mod tests {
         // When a file is deleted, the buffer is considered dirty.
         let events = Rc::new(RefCell::new(Vec::new()));
         let buffer2 = project
-            .update(cx, |p, cx| p.open_buffer((worktree_id, "file2"), cx))
+            .update(cx, |p, cx| p.open_local_buffer("/dir/file2", cx))
             .await
             .unwrap();
         buffer2.update(cx, |_, cx| {
@@ -7159,7 +7197,9 @@ mod tests {
             .detach();
         });
 
-        fs::remove_file(dir.path().join("file2")).unwrap();
+        fs.remove_file("/dir/file2".as_ref(), Default::default())
+            .await
+            .unwrap();
         buffer2.condition(&cx, |b, _| b.is_dirty()).await;
         assert_eq!(
             *events.borrow(),
@@ -7169,7 +7209,7 @@ mod tests {
         // When a file is already dirty when deleted, we don't emit a Dirtied event.
         let events = Rc::new(RefCell::new(Vec::new()));
         let buffer3 = project
-            .update(cx, |p, cx| p.open_buffer((worktree_id, "file3"), cx))
+            .update(cx, |p, cx| p.open_local_buffer("/dir/file3", cx))
             .await
             .unwrap();
         buffer3.update(cx, |_, cx| {
@@ -7180,12 +7220,13 @@ mod tests {
             .detach();
         });
 
-        worktree.flush_fs_events(&cx).await;
         buffer3.update(cx, |buffer, cx| {
             buffer.edit([(0..0, "x")], cx);
         });
         events.borrow_mut().clear();
-        fs::remove_file(dir.path().join("file3")).unwrap();
+        fs.remove_file("/dir/file3".as_ref(), Default::default())
+            .await
+            .unwrap();
         buffer3
             .condition(&cx, |_, _| !events.borrow().is_empty())
             .await;
@@ -7195,47 +7236,24 @@ mod tests {
 
     #[gpui::test]
     async fn test_buffer_file_changes_on_disk(cx: &mut gpui::TestAppContext) {
-        use std::fs;
-
         let initial_contents = "aaa\nbbbbb\nc\n";
-        let dir = temp_tree(json!({ "the-file": initial_contents }));
-
-        let project = Project::test(Arc::new(RealFs), cx);
-        let (worktree, _) = project
-            .update(cx, |p, cx| {
-                p.find_or_create_local_worktree(dir.path(), true, cx)
-            })
-            .await
-            .unwrap();
-        let worktree_id = worktree.read_with(cx, |tree, _| tree.id());
-
-        worktree
-            .read_with(cx, |t, _| t.as_local().unwrap().scan_complete())
-            .await;
-
-        let abs_path = dir.path().join("the-file");
+        let fs = FakeFs::new(cx.background());
+        fs.insert_tree(
+            "/dir",
+            json!({
+                "the-file": initial_contents,
+            }),
+        )
+        .await;
+        let project = Project::test(fs.clone(), ["/dir"], cx).await;
         let buffer = project
-            .update(cx, |p, cx| p.open_buffer((worktree_id, "the-file"), cx))
+            .update(cx, |p, cx| p.open_local_buffer("/dir/the-file", cx))
             .await
             .unwrap();
 
-        // TODO
-        // Add a cursor on each row.
-        // let selection_set_id = buffer.update(&mut cx, |buffer, cx| {
-        //     assert!(!buffer.is_dirty());
-        //     buffer.add_selection_set(
-        //         &(0..3)
-        //             .map(|row| Selection {
-        //                 id: row as usize,
-        //                 start: Point::new(row, 1),
-        //                 end: Point::new(row, 1),
-        //                 reversed: false,
-        //                 goal: SelectionGoal::None,
-        //             })
-        //             .collect::<Vec<_>>(),
-        //         cx,
-        //     )
-        // });
+        let anchors = (0..3)
+            .map(|row| buffer.read_with(cx, |b, _| b.anchor_before(Point::new(row, 1))))
+            .collect::<Vec<_>>();
 
         // Change the file on disk, adding two new lines of text, and removing
         // one line.

crates/project/src/worktree.rs 🔗

@@ -1,4 +1,4 @@
-use crate::ProjectEntryId;
+use crate::{ProjectEntryId, RemoveOptions};
 
 use super::{
     fs::{self, Fs},
@@ -42,6 +42,7 @@ use std::{
     fmt,
     future::Future,
     ops::{Deref, DerefMut},
+    os::unix::prelude::{OsStrExt, OsStringExt},
     path::{Path, PathBuf},
     sync::{atomic::AtomicUsize, Arc},
     time::{Duration, SystemTime},
@@ -78,11 +79,12 @@ pub struct LocalWorktree {
 }
 
 pub struct RemoteWorktree {
-    pub(crate) snapshot: Snapshot,
+    pub snapshot: Snapshot,
+    pub(crate) background_snapshot: Arc<Mutex<Snapshot>>,
     project_id: u64,
-    snapshot_rx: watch::Receiver<Snapshot>,
     client: Arc<Client>,
     updates_tx: UnboundedSender<proto::UpdateWorktree>,
+    last_scan_id_rx: watch::Receiver<usize>,
     replica_id: ReplicaId,
     diagnostic_summaries: TreeMap<PathKey, DiagnosticSummary>,
     visible: bool,
@@ -95,12 +97,12 @@ pub struct Snapshot {
     root_char_bag: CharBag,
     entries_by_path: SumTree<Entry>,
     entries_by_id: SumTree<PathEntry>,
+    scan_id: usize,
 }
 
 #[derive(Clone)]
 pub struct LocalSnapshot {
     abs_path: Arc<Path>,
-    scan_id: usize,
     ignores: HashMap<Arc<Path>, (Arc<Gitignore>, usize)>,
     removed_entry_ids: HashMap<u64, ProjectEntryId>,
     next_entry_id: Arc<AtomicUsize>,
@@ -214,17 +216,21 @@ impl Worktree {
             root_char_bag,
             entries_by_path: Default::default(),
             entries_by_id: Default::default(),
+            scan_id: worktree.scan_id as usize,
         };
 
         let (updates_tx, mut updates_rx) = mpsc::unbounded();
-        let (mut snapshot_tx, snapshot_rx) = watch::channel_with(snapshot.clone());
+        let background_snapshot = Arc::new(Mutex::new(snapshot.clone()));
+        let (mut snapshot_updated_tx, mut snapshot_updated_rx) = watch::channel();
+        let (mut last_scan_id_tx, last_scan_id_rx) = watch::channel_with(worktree.scan_id as usize);
         let worktree_handle = cx.add_model(|_: &mut ModelContext<Worktree>| {
             Worktree::Remote(RemoteWorktree {
                 project_id: project_remote_id,
                 replica_id,
                 snapshot: snapshot.clone(),
-                snapshot_rx: snapshot_rx.clone(),
+                background_snapshot: background_snapshot.clone(),
                 updates_tx,
+                last_scan_id_rx,
                 client: client.clone(),
                 diagnostic_summaries: TreeMap::from_ordered_entries(
                     worktree.diagnostic_summaries.into_iter().map(|summary| {
@@ -274,37 +280,42 @@ impl Worktree {
                     .await;
 
                 {
-                    let mut snapshot = snapshot_tx.borrow_mut();
+                    let mut snapshot = background_snapshot.lock();
                     snapshot.entries_by_path = entries_by_path;
                     snapshot.entries_by_id = entries_by_id;
+                    snapshot_updated_tx.send(()).await.ok();
                 }
 
                 cx.background()
                     .spawn(async move {
                         while let Some(update) = updates_rx.next().await {
-                            let mut snapshot = snapshot_tx.borrow().clone();
-                            if let Err(error) = snapshot.apply_remote_update(update) {
+                            if let Err(error) =
+                                background_snapshot.lock().apply_remote_update(update)
+                            {
                                 log::error!("error applying worktree update: {}", error);
                             }
-                            *snapshot_tx.borrow_mut() = snapshot;
+                            snapshot_updated_tx.send(()).await.ok();
                         }
                     })
                     .detach();
 
-                {
-                    let mut snapshot_rx = snapshot_rx.clone();
+                cx.spawn(|mut cx| {
                     let this = worktree_handle.downgrade();
-                    cx.spawn(|mut cx| async move {
-                        while let Some(_) = snapshot_rx.recv().await {
+                    async move {
+                        while let Some(_) = snapshot_updated_rx.recv().await {
                             if let Some(this) = this.upgrade(&cx) {
-                                this.update(&mut cx, |this, cx| this.poll_snapshot(cx));
+                                this.update(&mut cx, |this, cx| {
+                                    this.poll_snapshot(cx);
+                                    let this = this.as_remote_mut().unwrap();
+                                    *last_scan_id_tx.borrow_mut() = this.snapshot.scan_id;
+                                });
                             } else {
                                 break;
                             }
                         }
-                    })
-                    .detach();
-                }
+                    }
+                })
+                .detach();
             }
         });
         (worktree_handle, deserialize_task)
@@ -357,6 +368,13 @@ impl Worktree {
         }
     }
 
+    pub fn scan_id(&self) -> usize {
+        match self {
+            Worktree::Local(worktree) => worktree.snapshot.scan_id,
+            Worktree::Remote(worktree) => worktree.snapshot.scan_id,
+        }
+    }
+
     pub fn is_visible(&self) -> bool {
         match self {
             Worktree::Local(worktree) => worktree.visible,
@@ -410,7 +428,7 @@ impl Worktree {
                 }
             }
             Self::Remote(worktree) => {
-                worktree.snapshot = worktree.snapshot_rx.borrow().clone();
+                worktree.snapshot = worktree.background_snapshot.lock().clone();
                 cx.emit(Event::UpdatedEntries);
             }
         };
@@ -454,7 +472,6 @@ impl LocalWorktree {
         let tree = cx.add_model(move |cx: &mut ModelContext<Worktree>| {
             let mut snapshot = LocalSnapshot {
                 abs_path,
-                scan_id: 0,
                 ignores: Default::default(),
                 removed_entry_ids: Default::default(),
                 next_entry_id,
@@ -464,6 +481,7 @@ impl LocalWorktree {
                     root_char_bag,
                     entries_by_path: Default::default(),
                     entries_by_id: Default::default(),
+                    scan_id: 0,
                 },
             };
             if let Some(metadata) = metadata {
@@ -494,24 +512,13 @@ impl LocalWorktree {
 
             cx.spawn_weak(|this, mut cx| async move {
                 while let Some(scan_state) = scan_states_rx.next().await {
-                    if let Some(handle) = this.upgrade(&cx) {
-                        let to_send = handle.update(&mut cx, |this, cx| {
-                            last_scan_state_tx.blocking_send(scan_state).ok();
+                    if let Some(this) = this.upgrade(&cx) {
+                        last_scan_state_tx.blocking_send(scan_state).ok();
+                        this.update(&mut cx, |this, cx| {
                             this.poll_snapshot(cx);
-                            let tree = this.as_local_mut().unwrap();
-                            if !tree.is_scanning() {
-                                if let Some(share) = tree.share.as_ref() {
-                                    return Some((tree.snapshot(), share.snapshots_tx.clone()));
-                                }
-                            }
-                            None
-                        });
-
-                        if let Some((snapshot, snapshots_to_send_tx)) = to_send {
-                            if let Err(err) = snapshots_to_send_tx.send(snapshot).await {
-                                log::error!("error submitting snapshot to send {}", err);
-                            }
-                        }
+                            this.as_local().unwrap().broadcast_snapshot()
+                        })
+                        .await;
                     } else {
                         break;
                     }
@@ -623,12 +630,15 @@ impl LocalWorktree {
         let handle = cx.handle();
         let path = Arc::from(path);
         let abs_path = self.absolutize(&path);
-        let background_snapshot = self.background_snapshot.clone();
         let fs = self.fs.clone();
         cx.spawn(|this, mut cx| async move {
             let text = fs.load(&abs_path).await?;
             // Eagerly populate the snapshot with an updated entry for the loaded file
-            let entry = refresh_entry(fs.as_ref(), &background_snapshot, path, &abs_path).await?;
+            let entry = this
+                .update(&mut cx, |this, _| {
+                    this.as_local().unwrap().refresh_entry(path, abs_path, None)
+                })
+                .await?;
             this.update(&mut cx, |this, cx| this.poll_snapshot(cx));
             Ok((
                 File {
@@ -652,7 +662,7 @@ impl LocalWorktree {
         let buffer = buffer_handle.read(cx);
         let text = buffer.as_rope().clone();
         let version = buffer.version();
-        let save = self.save(path, text, cx);
+        let save = self.write_file(path, text, cx);
         let handle = cx.handle();
         cx.as_mut().spawn(|mut cx| async move {
             let entry = save.await?;
@@ -672,28 +682,188 @@ impl LocalWorktree {
         })
     }
 
-    fn save(
+    pub fn create_entry(
+        &self,
+        path: impl Into<Arc<Path>>,
+        is_dir: bool,
+        cx: &mut ModelContext<Worktree>,
+    ) -> Task<Result<Entry>> {
+        self.write_entry_internal(
+            path,
+            if is_dir {
+                None
+            } else {
+                Some(Default::default())
+            },
+            cx,
+        )
+    }
+
+    pub fn write_file(
         &self,
         path: impl Into<Arc<Path>>,
         text: Rope,
         cx: &mut ModelContext<Worktree>,
+    ) -> Task<Result<Entry>> {
+        self.write_entry_internal(path, Some(text), cx)
+    }
+
+    pub fn delete_entry(
+        &self,
+        entry_id: ProjectEntryId,
+        cx: &mut ModelContext<Worktree>,
+    ) -> Option<Task<Result<()>>> {
+        let entry = self.entry_for_id(entry_id)?.clone();
+        let abs_path = self.absolutize(&entry.path);
+        let delete = cx.background().spawn({
+            let fs = self.fs.clone();
+            let abs_path = abs_path.clone();
+            async move {
+                if entry.is_file() {
+                    fs.remove_file(&abs_path, Default::default()).await
+                } else {
+                    fs.remove_dir(
+                        &abs_path,
+                        RemoveOptions {
+                            recursive: true,
+                            ignore_if_not_exists: false,
+                        },
+                    )
+                    .await
+                }
+            }
+        });
+
+        Some(cx.spawn(|this, mut cx| async move {
+            delete.await?;
+            this.update(&mut cx, |this, _| {
+                let this = this.as_local_mut().unwrap();
+                let mut snapshot = this.background_snapshot.lock();
+                snapshot.delete_entry(entry_id);
+            });
+            this.update(&mut cx, |this, cx| {
+                this.poll_snapshot(cx);
+                this.as_local().unwrap().broadcast_snapshot()
+            })
+            .await;
+            Ok(())
+        }))
+    }
+
+    pub fn rename_entry(
+        &self,
+        entry_id: ProjectEntryId,
+        new_path: impl Into<Arc<Path>>,
+        cx: &mut ModelContext<Worktree>,
+    ) -> Option<Task<Result<Entry>>> {
+        let old_path = self.entry_for_id(entry_id)?.path.clone();
+        let new_path = new_path.into();
+        let abs_old_path = self.absolutize(&old_path);
+        let abs_new_path = self.absolutize(&new_path);
+        let rename = cx.background().spawn({
+            let fs = self.fs.clone();
+            let abs_new_path = abs_new_path.clone();
+            async move {
+                fs.rename(&abs_old_path, &abs_new_path, Default::default())
+                    .await
+            }
+        });
+
+        Some(cx.spawn(|this, mut cx| async move {
+            rename.await?;
+            let entry = this
+                .update(&mut cx, |this, _| {
+                    this.as_local_mut().unwrap().refresh_entry(
+                        new_path.clone(),
+                        abs_new_path,
+                        Some(old_path),
+                    )
+                })
+                .await?;
+            this.update(&mut cx, |this, cx| {
+                this.poll_snapshot(cx);
+                this.as_local().unwrap().broadcast_snapshot()
+            })
+            .await;
+            Ok(entry)
+        }))
+    }
+
+    fn write_entry_internal(
+        &self,
+        path: impl Into<Arc<Path>>,
+        text_if_file: Option<Rope>,
+        cx: &mut ModelContext<Worktree>,
     ) -> Task<Result<Entry>> {
         let path = path.into();
         let abs_path = self.absolutize(&path);
-        let background_snapshot = self.background_snapshot.clone();
-        let fs = self.fs.clone();
-        let save = cx.background().spawn(async move {
-            fs.save(&abs_path, &text).await?;
-            refresh_entry(fs.as_ref(), &background_snapshot, path.clone(), &abs_path).await
+        let write = cx.background().spawn({
+            let fs = self.fs.clone();
+            let abs_path = abs_path.clone();
+            async move {
+                if let Some(text) = text_if_file {
+                    fs.save(&abs_path, &text).await
+                } else {
+                    fs.create_dir(&abs_path).await
+                }
+            }
         });
 
         cx.spawn(|this, mut cx| async move {
-            let entry = save.await?;
-            this.update(&mut cx, |this, cx| this.poll_snapshot(cx));
+            write.await?;
+            let entry = this
+                .update(&mut cx, |this, _| {
+                    this.as_local_mut()
+                        .unwrap()
+                        .refresh_entry(path, abs_path, None)
+                })
+                .await?;
+            this.update(&mut cx, |this, cx| {
+                this.poll_snapshot(cx);
+                this.as_local().unwrap().broadcast_snapshot()
+            })
+            .await;
             Ok(entry)
         })
     }
 
+    fn refresh_entry(
+        &self,
+        path: Arc<Path>,
+        abs_path: PathBuf,
+        old_path: Option<Arc<Path>>,
+    ) -> impl Future<Output = Result<Entry>> {
+        let root_char_bag;
+        let next_entry_id;
+        let fs = self.fs.clone();
+        let shared_snapshots_tx = self.share.as_ref().map(|share| share.snapshots_tx.clone());
+        let snapshot = self.background_snapshot.clone();
+        {
+            let snapshot = snapshot.lock();
+            root_char_bag = snapshot.root_char_bag;
+            next_entry_id = snapshot.next_entry_id.clone();
+        }
+        async move {
+            let entry = Entry::new(
+                path,
+                &fs.metadata(&abs_path)
+                    .await?
+                    .ok_or_else(|| anyhow!("could not read saved file metadata"))?,
+                &next_entry_id,
+                root_char_bag,
+            );
+            let mut snapshot = snapshot.lock();
+            if let Some(old_path) = old_path {
+                snapshot.remove_path(&old_path);
+            }
+            let entry = snapshot.insert_entry(entry, fs.as_ref());
+            if let Some(tx) = shared_snapshots_tx {
+                tx.send(snapshot.clone()).await.ok();
+            }
+            Ok(entry)
+        }
+    }
+
     pub fn register(
         &mut self,
         project_id: u64,
@@ -761,6 +931,7 @@ impl LocalWorktree {
                                         .map(Into::into)
                                         .collect(),
                                     removed_entries: Default::default(),
+                                    scan_id: snapshot.scan_id as u64,
                                 })
                                 .await
                             {
@@ -829,6 +1000,23 @@ impl LocalWorktree {
     pub fn is_shared(&self) -> bool {
         self.share.is_some()
     }
+
+    fn broadcast_snapshot(&self) -> impl Future<Output = ()> {
+        let mut to_send = None;
+        if !self.is_scanning() {
+            if let Some(share) = self.share.as_ref() {
+                to_send = Some((self.snapshot(), share.snapshots_tx.clone()));
+            }
+        }
+
+        async move {
+            if let Some((snapshot, snapshots_to_send_tx)) = to_send {
+                if let Err(err) = snapshots_to_send_tx.send(snapshot).await {
+                    log::error!("error submitting snapshot to send {}", err);
+                }
+            }
+        }
+    }
 }
 
 impl RemoteWorktree {
@@ -843,10 +1031,20 @@ impl RemoteWorktree {
         self.updates_tx
             .unbounded_send(envelope.payload)
             .expect("consumer runs to completion");
-
         Ok(())
     }
 
+    fn wait_for_snapshot(&self, scan_id: usize) -> impl Future<Output = ()> {
+        let mut rx = self.last_scan_id_rx.clone();
+        async move {
+            while let Some(applied_scan_id) = rx.next().await {
+                if applied_scan_id >= scan_id {
+                    return;
+                }
+            }
+        }
+    }
+
     pub fn update_diagnostic_summary(
         &mut self,
         path: Arc<Path>,
@@ -863,6 +1061,44 @@ impl RemoteWorktree {
                 .insert(PathKey(path.clone()), summary);
         }
     }
+
+    pub fn insert_entry(
+        &self,
+        entry: proto::Entry,
+        scan_id: usize,
+        cx: &mut ModelContext<Worktree>,
+    ) -> Task<Result<Entry>> {
+        let wait_for_snapshot = self.wait_for_snapshot(scan_id);
+        cx.spawn(|this, mut cx| async move {
+            wait_for_snapshot.await;
+            this.update(&mut cx, |worktree, _| {
+                let worktree = worktree.as_remote_mut().unwrap();
+                let mut snapshot = worktree.background_snapshot.lock();
+                let entry = snapshot.insert_entry(entry);
+                worktree.snapshot = snapshot.clone();
+                entry
+            })
+        })
+    }
+
+    pub(crate) fn delete_entry(
+        &self,
+        id: ProjectEntryId,
+        scan_id: usize,
+        cx: &mut ModelContext<Worktree>,
+    ) -> Task<Result<()>> {
+        let wait_for_snapshot = self.wait_for_snapshot(scan_id);
+        cx.spawn(|this, mut cx| async move {
+            wait_for_snapshot.await;
+            this.update(&mut cx, |worktree, _| {
+                let worktree = worktree.as_remote_mut().unwrap();
+                let mut snapshot = worktree.background_snapshot.lock();
+                snapshot.delete_entry(id);
+                worktree.snapshot = snapshot.clone();
+            });
+            Ok(())
+        })
+    }
 }
 
 impl Snapshot {
@@ -874,6 +1110,33 @@ impl Snapshot {
         self.entries_by_id.get(&entry_id, &()).is_some()
     }
 
+    pub(crate) fn insert_entry(&mut self, entry: proto::Entry) -> Result<Entry> {
+        let entry = Entry::try_from((&self.root_char_bag, entry))?;
+        let old_entry = self.entries_by_id.insert_or_replace(
+            PathEntry {
+                id: entry.id,
+                path: entry.path.clone(),
+                is_ignored: entry.is_ignored,
+                scan_id: 0,
+            },
+            &(),
+        );
+        if let Some(old_entry) = old_entry {
+            self.entries_by_path.remove(&PathKey(old_entry.path), &());
+        }
+        self.entries_by_path.insert_or_replace(entry.clone(), &());
+        Ok(entry)
+    }
+
+    fn delete_entry(&mut self, entry_id: ProjectEntryId) -> bool {
+        if let Some(entry) = self.entries_by_id.remove(&entry_id, &()) {
+            self.entries_by_path.remove(&PathKey(entry.path), &());
+            true
+        } else {
+            false
+        }
+    }
+
     pub(crate) fn apply_remote_update(&mut self, update: proto::UpdateWorktree) -> Result<()> {
         let mut entries_by_path_edits = Vec::new();
         let mut entries_by_id_edits = Vec::new();
@@ -901,6 +1164,7 @@ impl Snapshot {
 
         self.entries_by_path.edit(entries_by_path_edits, &());
         self.entries_by_id.edit(entries_by_id_edits, &());
+        self.scan_id = update.scan_id as usize;
 
         Ok(())
     }
@@ -989,6 +1253,10 @@ impl Snapshot {
         &self.root_name
     }
 
+    pub fn scan_id(&self) -> usize {
+        self.scan_id
+    }
+
     pub fn entry_for_path(&self, path: impl AsRef<Path>) -> Option<&Entry> {
         let path = path.as_ref();
         self.traverse_from_path(true, true, path)
@@ -1038,6 +1306,7 @@ impl LocalSnapshot {
                 .map(|(path, summary)| summary.to_proto(&path.0))
                 .collect(),
             visible,
+            scan_id: self.scan_id as u64,
         }
     }
 
@@ -1103,6 +1372,7 @@ impl LocalSnapshot {
             root_name: self.root_name().to_string(),
             updated_entries,
             removed_entries,
+            scan_id: self.scan_id as u64,
         }
     }
 
@@ -1146,11 +1416,18 @@ impl LocalSnapshot {
         entries: impl IntoIterator<Item = Entry>,
         ignore: Option<Arc<Gitignore>>,
     ) {
-        let mut parent_entry = self
-            .entries_by_path
-            .get(&PathKey(parent_path.clone()), &())
-            .unwrap()
-            .clone();
+        let mut parent_entry = if let Some(parent_entry) =
+            self.entries_by_path.get(&PathKey(parent_path.clone()), &())
+        {
+            parent_entry.clone()
+        } else {
+            log::warn!(
+                "populating a directory {:?} that has been removed",
+                parent_path
+            );
+            return;
+        };
+
         if let Some(ignore) = ignore {
             self.ignores.insert(parent_path, (ignore, self.scan_id));
         }
@@ -1210,7 +1487,7 @@ impl LocalSnapshot {
 
         if path.file_name() == Some(&GITIGNORE) {
             if let Some((_, scan_id)) = self.ignores.get_mut(path.parent().unwrap()) {
-                *scan_id = self.scan_id;
+                *scan_id = self.snapshot.scan_id;
             }
         }
     }
@@ -1397,7 +1674,7 @@ impl language::File for File {
             Worktree::Local(worktree) => {
                 let rpc = worktree.client.clone();
                 let project_id = worktree.share.as_ref().map(|share| share.project_id);
-                let save = worktree.save(self.path.clone(), text, cx);
+                let save = worktree.write_file(self.path.clone(), text, cx);
                 cx.background().spawn(async move {
                     let entry = save.await?;
                     if let Some(project_id) = project_id {
@@ -1536,7 +1813,7 @@ pub struct Entry {
     pub is_ignored: bool,
 }
 
-#[derive(Clone, Debug, PartialEq, Eq)]
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
 pub enum EntryKind {
     PendingDir,
     Dir,
@@ -1777,14 +2054,14 @@ impl BackgroundScanner {
             let path: Arc<Path> = Arc::from(Path::new(""));
             let abs_path = self.abs_path();
             let (tx, rx) = channel::unbounded();
-            tx.send(ScanJob {
-                abs_path: abs_path.to_path_buf(),
-                path,
-                ignore_stack: IgnoreStack::none(),
-                scan_queue: tx.clone(),
-            })
-            .await
-            .unwrap();
+            self.executor
+                .block(tx.send(ScanJob {
+                    abs_path: abs_path.to_path_buf(),
+                    path,
+                    ignore_stack: IgnoreStack::none(),
+                    scan_queue: tx.clone(),
+                }))
+                .unwrap();
             drop(tx);
 
             self.executor
@@ -1907,83 +2184,91 @@ impl BackgroundScanner {
     }
 
     async fn process_events(&mut self, mut events: Vec<fsevent::Event>) -> bool {
-        let mut snapshot = self.snapshot();
-        snapshot.scan_id += 1;
+        events.sort_unstable_by(|a, b| a.path.cmp(&b.path));
+        events.dedup_by(|a, b| a.path.starts_with(&b.path));
 
-        let root_abs_path = if let Ok(abs_path) = self.fs.canonicalize(&snapshot.abs_path).await {
+        let root_char_bag;
+        let root_abs_path;
+        let next_entry_id;
+        {
+            let mut snapshot = self.snapshot.lock();
+            snapshot.scan_id += 1;
+            root_char_bag = snapshot.root_char_bag;
+            root_abs_path = snapshot.abs_path.clone();
+            next_entry_id = snapshot.next_entry_id.clone();
+        }
+
+        let root_abs_path = if let Ok(abs_path) = self.fs.canonicalize(&root_abs_path).await {
             abs_path
         } else {
             return false;
         };
-        let root_char_bag = snapshot.root_char_bag;
-        let next_entry_id = snapshot.next_entry_id.clone();
-
-        events.sort_unstable_by(|a, b| a.path.cmp(&b.path));
-        events.dedup_by(|a, b| a.path.starts_with(&b.path));
+        let metadata = futures::future::join_all(
+            events
+                .iter()
+                .map(|event| self.fs.metadata(&event.path))
+                .collect::<Vec<_>>(),
+        )
+        .await;
 
-        for event in &events {
-            match event.path.strip_prefix(&root_abs_path) {
-                Ok(path) => snapshot.remove_path(&path),
-                Err(_) => {
-                    log::error!(
-                        "unexpected event {:?} for root path {:?}",
-                        event.path,
-                        root_abs_path
-                    );
-                    continue;
+        // Hold the snapshot lock while clearing and re-inserting the root entries
+        // for each event. This way, the snapshot is not observable to the foreground
+        // thread while this operation is in-progress.
+        let (scan_queue_tx, scan_queue_rx) = channel::unbounded();
+        {
+            let mut snapshot = self.snapshot.lock();
+            for event in &events {
+                if let Ok(path) = event.path.strip_prefix(&root_abs_path) {
+                    snapshot.remove_path(&path);
                 }
             }
-        }
 
-        let (scan_queue_tx, scan_queue_rx) = channel::unbounded();
-        for event in events {
-            let path: Arc<Path> = match event.path.strip_prefix(&root_abs_path) {
-                Ok(path) => Arc::from(path.to_path_buf()),
-                Err(_) => {
-                    log::error!(
-                        "unexpected event {:?} for root path {:?}",
-                        event.path,
-                        root_abs_path
-                    );
-                    continue;
-                }
-            };
+            for (event, metadata) in events.into_iter().zip(metadata.into_iter()) {
+                let path: Arc<Path> = match event.path.strip_prefix(&root_abs_path) {
+                    Ok(path) => Arc::from(path.to_path_buf()),
+                    Err(_) => {
+                        log::error!(
+                            "unexpected event {:?} for root path {:?}",
+                            event.path,
+                            root_abs_path
+                        );
+                        continue;
+                    }
+                };
 
-            match self.fs.metadata(&event.path).await {
-                Ok(Some(metadata)) => {
-                    let ignore_stack = snapshot.ignore_stack_for_path(&path, metadata.is_dir);
-                    let mut fs_entry = Entry::new(
-                        path.clone(),
-                        &metadata,
-                        snapshot.next_entry_id.as_ref(),
-                        snapshot.root_char_bag,
-                    );
-                    fs_entry.is_ignored = ignore_stack.is_all();
-                    snapshot.insert_entry(fs_entry, self.fs.as_ref());
-                    if metadata.is_dir {
-                        scan_queue_tx
-                            .send(ScanJob {
-                                abs_path: event.path,
-                                path,
-                                ignore_stack,
-                                scan_queue: scan_queue_tx.clone(),
-                            })
-                            .await
-                            .unwrap();
+                match metadata {
+                    Ok(Some(metadata)) => {
+                        let ignore_stack = snapshot.ignore_stack_for_path(&path, metadata.is_dir);
+                        let mut fs_entry = Entry::new(
+                            path.clone(),
+                            &metadata,
+                            snapshot.next_entry_id.as_ref(),
+                            snapshot.root_char_bag,
+                        );
+                        fs_entry.is_ignored = ignore_stack.is_all();
+                        snapshot.insert_entry(fs_entry, self.fs.as_ref());
+                        if metadata.is_dir {
+                            self.executor
+                                .block(scan_queue_tx.send(ScanJob {
+                                    abs_path: event.path,
+                                    path,
+                                    ignore_stack,
+                                    scan_queue: scan_queue_tx.clone(),
+                                }))
+                                .unwrap();
+                        }
+                    }
+                    Ok(None) => {}
+                    Err(err) => {
+                        // TODO - create a special 'error' entry in the entries tree to mark this
+                        log::error!("error reading file on event {:?}", err);
                     }
-                }
-                Ok(None) => {}
-                Err(err) => {
-                    // TODO - create a special 'error' entry in the entries tree to mark this
-                    log::error!("error reading file on event {:?}", err);
                 }
             }
+            drop(scan_queue_tx);
         }
 
-        *self.snapshot.lock() = snapshot;
-
         // Scan any directories that were created as part of this event batch.
-        drop(scan_queue_tx);
         self.executor
             .scoped(|scope| {
                 for _ in 0..self.executor.num_cpus() {
@@ -2107,30 +2392,6 @@ impl BackgroundScanner {
     }
 }
 
-async fn refresh_entry(
-    fs: &dyn Fs,
-    snapshot: &Mutex<LocalSnapshot>,
-    path: Arc<Path>,
-    abs_path: &Path,
-) -> Result<Entry> {
-    let root_char_bag;
-    let next_entry_id;
-    {
-        let snapshot = snapshot.lock();
-        root_char_bag = snapshot.root_char_bag;
-        next_entry_id = snapshot.next_entry_id.clone();
-    }
-    let entry = Entry::new(
-        path,
-        &fs.metadata(abs_path)
-            .await?
-            .ok_or_else(|| anyhow!("could not read saved file metadata"))?,
-        &next_entry_id,
-        root_char_bag,
-    );
-    Ok(snapshot.lock().insert_entry(entry, fs))
-}
-
 fn char_bag_for_path(root_char_bag: CharBag, path: &Path) -> CharBag {
     let mut result = root_char_bag;
     result.extend(
@@ -2368,7 +2629,7 @@ impl<'a> From<&'a Entry> for proto::Entry {
         Self {
             id: entry.id.to_proto(),
             is_dir: entry.is_dir(),
-            path: entry.path.to_string_lossy().to_string(),
+            path: entry.path.as_os_str().as_bytes().to_vec(),
             inode: entry.inode,
             mtime: Some(entry.mtime.into()),
             is_symlink: entry.is_symlink,
@@ -2386,10 +2647,14 @@ impl<'a> TryFrom<(&'a CharBag, proto::Entry)> for Entry {
                 EntryKind::Dir
             } else {
                 let mut char_bag = root_char_bag.clone();
-                char_bag.extend(entry.path.chars().map(|c| c.to_ascii_lowercase()));
+                char_bag.extend(
+                    String::from_utf8_lossy(&entry.path)
+                        .chars()
+                        .map(|c| c.to_ascii_lowercase()),
+                );
                 EntryKind::File(char_bag)
             };
-            let path: Arc<Path> = Arc::from(Path::new(&entry.path));
+            let path: Arc<Path> = PathBuf::from(OsString::from_vec(entry.path)).into();
             Ok(Entry {
                 id: ProjectEntryId::from_proto(entry.id),
                 kind,
@@ -2541,7 +2806,6 @@ mod tests {
         let next_entry_id = Arc::new(AtomicUsize::new(0));
         let mut initial_snapshot = LocalSnapshot {
             abs_path: root_dir.path().into(),
-            scan_id: 0,
             removed_entry_ids: Default::default(),
             ignores: Default::default(),
             next_entry_id: next_entry_id.clone(),
@@ -2551,6 +2815,7 @@ mod tests {
                 entries_by_id: Default::default(),
                 root_name: Default::default(),
                 root_char_bag: Default::default(),
+                scan_id: 0,
             },
         };
         initial_snapshot.insert_entry(

crates/project_panel/Cargo.toml 🔗

@@ -8,15 +8,19 @@ path = "src/project_panel.rs"
 doctest = false
 
 [dependencies]
+editor = { path = "../editor" }
 gpui = { path = "../gpui" }
 project = { path = "../project" }
 settings = { path = "../settings" }
 theme = { path = "../theme" }
 util = { path = "../util" }
 workspace = { path = "../workspace" }
+postage = { version = "0.4.1", features = ["futures-traits"] }
+futures = "0.3"
 unicase = "2.6"
 
 [dev-dependencies]
+editor = { path = "../editor", features = ["test-support"] }
 gpui = { path = "../gpui", features = ["test-support"] }
 workspace = { path = "../workspace", features = ["test-support"] }
 serde_json = { version = "1.0.64", features = ["preserve_order"] }

crates/project_panel/src/project_panel.rs 🔗

@@ -1,15 +1,18 @@
+use editor::{Cancel, Editor};
+use futures::stream::StreamExt;
 use gpui::{
     actions,
+    anyhow::{anyhow, Result},
     elements::{
-        Align, ConstrainedBox, Empty, Flex, Label, MouseEventHandler, ParentElement, ScrollTarget,
-        Svg, UniformList, UniformListState,
+        ChildView, ConstrainedBox, Empty, Flex, Label, MouseEventHandler, ParentElement,
+        ScrollTarget, Svg, UniformList, UniformListState,
     },
     impl_internal_actions, keymap,
     platform::CursorStyle,
-    AppContext, Element, ElementBox, Entity, ModelHandle, MutableAppContext, View, ViewContext,
-    ViewHandle, WeakViewHandle,
+    AppContext, Element, ElementBox, Entity, ModelHandle, MutableAppContext, PromptLevel, Task,
+    View, ViewContext, ViewHandle, WeakViewHandle,
 };
-use project::{Entry, Project, ProjectEntryId, ProjectPath, Worktree, WorktreeId};
+use project::{Entry, EntryKind, Project, ProjectEntryId, ProjectPath, Worktree, WorktreeId};
 use settings::Settings;
 use std::{
     cmp::Ordering,
@@ -19,16 +22,20 @@ use std::{
 };
 use unicase::UniCase;
 use workspace::{
-    menu::{SelectNext, SelectPrev},
+    menu::{Confirm, SelectNext, SelectPrev},
     Workspace,
 };
 
+const NEW_ENTRY_ID: ProjectEntryId = ProjectEntryId::MAX;
+
 pub struct ProjectPanel {
     project: ModelHandle<Project>,
     list: UniformListState,
     visible_entries: Vec<(WorktreeId, Vec<Entry>)>,
     expanded_dir_ids: HashMap<WorktreeId, Vec<ProjectEntryId>>,
     selection: Option<Selection>,
+    edit_state: Option<EditState>,
+    filename_editor: ViewHandle<Editor>,
     handle: WeakViewHandle<Self>,
 }
 
@@ -38,22 +45,46 @@ struct Selection {
     entry_id: ProjectEntryId,
 }
 
+#[derive(Clone, Debug)]
+struct EditState {
+    worktree_id: WorktreeId,
+    entry_id: ProjectEntryId,
+    is_new_entry: bool,
+    is_dir: bool,
+    processing_filename: Option<String>,
+}
+
 #[derive(Debug, PartialEq, Eq)]
 struct EntryDetails {
     filename: String,
     depth: usize,
-    is_dir: bool,
+    kind: EntryKind,
     is_expanded: bool,
     is_selected: bool,
+    is_editing: bool,
+    is_processing: bool,
 }
 
 #[derive(Clone)]
 pub struct ToggleExpanded(pub ProjectEntryId);
 
 #[derive(Clone)]
-pub struct Open(pub ProjectEntryId);
+pub struct Open {
+    pub entry_id: ProjectEntryId,
+    pub change_focus: bool,
+}
 
-actions!(project_panel, [ExpandSelectedEntry, CollapseSelectedEntry]);
+actions!(
+    project_panel,
+    [
+        ExpandSelectedEntry,
+        CollapseSelectedEntry,
+        AddDirectory,
+        AddFile,
+        Delete,
+        Rename
+    ]
+);
 impl_internal_actions!(project_panel, [Open, ToggleExpanded]);
 
 pub fn init(cx: &mut MutableAppContext) {
@@ -63,10 +94,19 @@ pub fn init(cx: &mut MutableAppContext) {
     cx.add_action(ProjectPanel::select_prev);
     cx.add_action(ProjectPanel::select_next);
     cx.add_action(ProjectPanel::open_entry);
+    cx.add_action(ProjectPanel::add_file);
+    cx.add_action(ProjectPanel::add_directory);
+    cx.add_action(ProjectPanel::rename);
+    cx.add_async_action(ProjectPanel::delete);
+    cx.add_async_action(ProjectPanel::confirm);
+    cx.add_action(ProjectPanel::cancel);
 }
 
 pub enum Event {
-    OpenedEntry(ProjectEntryId),
+    OpenedEntry {
+        entry_id: ProjectEntryId,
+        focus_opened_item: bool,
+    },
 }
 
 impl ProjectPanel {
@@ -96,30 +136,55 @@ impl ProjectPanel {
             })
             .detach();
 
+            let filename_editor = cx.add_view(|cx| {
+                Editor::single_line(
+                    Some(|theme| {
+                        let mut style = theme.project_panel.filename_editor.clone();
+                        style.container.background_color.take();
+                        style
+                    }),
+                    cx,
+                )
+            });
+
             let mut this = Self {
                 project: project.clone(),
                 list: Default::default(),
                 visible_entries: Default::default(),
                 expanded_dir_ids: Default::default(),
                 selection: None,
+                edit_state: None,
+                filename_editor,
                 handle: cx.weak_handle(),
             };
             this.update_visible_entries(None, cx);
             this
         });
-        cx.subscribe(&project_panel, move |workspace, _, event, cx| match event {
-            &Event::OpenedEntry(entry_id) => {
-                if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
-                    if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
-                        workspace
-                            .open_path(
-                                ProjectPath {
-                                    worktree_id: worktree.read(cx).id(),
-                                    path: entry.path.clone(),
-                                },
-                                cx,
-                            )
-                            .detach_and_log_err(cx);
+        cx.subscribe(&project_panel, {
+            let project_panel = project_panel.downgrade();
+            move |workspace, _, event, cx| match event {
+                &Event::OpenedEntry {
+                    entry_id,
+                    focus_opened_item,
+                } => {
+                    if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
+                        if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
+                            workspace
+                                .open_path(
+                                    ProjectPath {
+                                        worktree_id: worktree.read(cx).id(),
+                                        path: entry.path.clone(),
+                                    },
+                                    focus_opened_item,
+                                    cx,
+                                )
+                                .detach_and_log_err(cx);
+                            if !focus_opened_item {
+                                if let Some(project_panel) = project_panel.upgrade(cx) {
+                                    cx.focus(&project_panel);
+                                }
+                            }
+                        }
                     }
                 }
             }
@@ -148,7 +213,10 @@ impl ProjectPanel {
                     }
                 }
             } else {
-                let event = Event::OpenedEntry(entry.id);
+                let event = Event::OpenedEntry {
+                    entry_id: entry.id,
+                    focus_opened_item: true,
+                };
                 cx.emit(event);
             }
         }
@@ -230,8 +298,193 @@ impl ProjectPanel {
         }
     }
 
+    fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
+        let edit_state = self.edit_state.as_mut()?;
+        cx.focus_self();
+
+        let worktree_id = edit_state.worktree_id;
+        let worktree = self.project.read(cx).worktree_for_id(worktree_id, cx)?;
+        let entry = worktree.read(cx).entry_for_id(edit_state.entry_id)?.clone();
+        let filename = self.filename_editor.read(cx).text(cx);
+
+        let edit_task;
+        let edited_entry_id;
+
+        if edit_state.is_new_entry {
+            self.selection = Some(Selection {
+                worktree_id,
+                entry_id: NEW_ENTRY_ID,
+            });
+            let new_path = entry.path.join(&filename);
+            edited_entry_id = NEW_ENTRY_ID;
+            edit_task = self.project.update(cx, |project, cx| {
+                project.create_entry((edit_state.worktree_id, new_path), edit_state.is_dir, cx)
+            })?;
+        } else {
+            let new_path = if let Some(parent) = entry.path.clone().parent() {
+                parent.join(&filename)
+            } else {
+                filename.clone().into()
+            };
+            edited_entry_id = entry.id;
+            edit_task = self.project.update(cx, |project, cx| {
+                project.rename_entry(entry.id, new_path, cx)
+            })?;
+        };
+
+        edit_state.processing_filename = Some(filename);
+        cx.notify();
+
+        Some(cx.spawn(|this, mut cx| async move {
+            let new_entry = edit_task.await;
+            this.update(&mut cx, |this, cx| {
+                this.edit_state.take();
+                cx.notify();
+            });
+
+            let new_entry = new_entry?;
+            this.update(&mut cx, |this, cx| {
+                if let Some(selection) = &mut this.selection {
+                    if selection.entry_id == edited_entry_id {
+                        selection.worktree_id = worktree_id;
+                        selection.entry_id = new_entry.id;
+                    }
+                }
+                this.update_visible_entries(None, cx);
+                cx.notify();
+            });
+            Ok(())
+        }))
+    }
+
+    fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
+        self.edit_state = None;
+        self.update_visible_entries(None, cx);
+        cx.focus_self();
+        cx.notify();
+    }
+
     fn open_entry(&mut self, action: &Open, cx: &mut ViewContext<Self>) {
-        cx.emit(Event::OpenedEntry(action.0));
+        cx.emit(Event::OpenedEntry {
+            entry_id: action.entry_id,
+            focus_opened_item: action.change_focus,
+        });
+    }
+
+    fn add_file(&mut self, _: &AddFile, cx: &mut ViewContext<Self>) {
+        self.add_entry(false, cx)
+    }
+
+    fn add_directory(&mut self, _: &AddDirectory, cx: &mut ViewContext<Self>) {
+        self.add_entry(true, cx)
+    }
+
+    fn add_entry(&mut self, is_dir: bool, cx: &mut ViewContext<Self>) {
+        if let Some(Selection {
+            worktree_id,
+            entry_id,
+        }) = self.selection
+        {
+            let directory_id;
+            if let Some((worktree, expanded_dir_ids)) = self
+                .project
+                .read(cx)
+                .worktree_for_id(worktree_id, cx)
+                .zip(self.expanded_dir_ids.get_mut(&worktree_id))
+            {
+                let worktree = worktree.read(cx);
+                if let Some(mut entry) = worktree.entry_for_id(entry_id) {
+                    loop {
+                        if entry.is_dir() {
+                            if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
+                                expanded_dir_ids.insert(ix, entry.id);
+                            }
+                            directory_id = entry.id;
+                            break;
+                        } else {
+                            if let Some(parent_path) = entry.path.parent() {
+                                if let Some(parent_entry) = worktree.entry_for_path(parent_path) {
+                                    entry = parent_entry;
+                                    continue;
+                                }
+                            }
+                            return;
+                        }
+                    }
+                } else {
+                    return;
+                };
+            } else {
+                return;
+            };
+
+            self.edit_state = Some(EditState {
+                worktree_id,
+                entry_id: directory_id,
+                is_new_entry: true,
+                is_dir,
+                processing_filename: None,
+            });
+            self.filename_editor
+                .update(cx, |editor, cx| editor.clear(cx));
+            cx.focus(&self.filename_editor);
+            self.update_visible_entries(Some((worktree_id, NEW_ENTRY_ID)), cx);
+            cx.notify();
+        }
+    }
+
+    fn rename(&mut self, _: &Rename, cx: &mut ViewContext<Self>) {
+        if let Some(Selection {
+            worktree_id,
+            entry_id,
+        }) = self.selection
+        {
+            if let Some(worktree) = self.project.read(cx).worktree_for_id(worktree_id, cx) {
+                if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
+                    self.edit_state = Some(EditState {
+                        worktree_id,
+                        entry_id,
+                        is_new_entry: false,
+                        is_dir: entry.is_dir(),
+                        processing_filename: None,
+                    });
+                    let filename = entry
+                        .path
+                        .file_name()
+                        .map_or(String::new(), |s| s.to_string_lossy().to_string());
+                    self.filename_editor.update(cx, |editor, cx| {
+                        editor.set_text(filename, cx);
+                        editor.select_all(&Default::default(), cx);
+                    });
+                    cx.focus(&self.filename_editor);
+                    self.update_visible_entries(None, cx);
+                    cx.notify();
+                }
+            }
+        }
+    }
+
+    fn delete(&mut self, _: &Delete, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
+        let Selection { entry_id, .. } = self.selection?;
+        let path = self.project.read(cx).path_for_entry(entry_id, cx)?.path;
+        let file_name = path.file_name()?;
+
+        let mut answer = cx.prompt(
+            PromptLevel::Info,
+            &format!("Delete {file_name:?}?"),
+            &["Delete", "Cancel"],
+        );
+        Some(cx.spawn(|this, mut cx| async move {
+            if answer.next().await != Some(0) {
+                return Ok(());
+            }
+            this.update(&mut cx, |this, cx| {
+                this.project
+                    .update(cx, |project, cx| project.delete_entry(entry_id, cx))
+                    .ok_or_else(|| anyhow!("no such entry"))
+            })?
+            .await
+        }))
     }
 
     fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
@@ -346,11 +599,35 @@ impl ProjectPanel {
                 }
             };
 
+            let mut new_entry_parent_id = None;
+            let mut new_entry_kind = EntryKind::Dir;
+            if let Some(edit_state) = &self.edit_state {
+                if edit_state.worktree_id == worktree_id && edit_state.is_new_entry {
+                    new_entry_parent_id = Some(edit_state.entry_id);
+                    new_entry_kind = if edit_state.is_dir {
+                        EntryKind::Dir
+                    } else {
+                        EntryKind::File(Default::default())
+                    };
+                }
+            }
+
             let mut visible_worktree_entries = Vec::new();
             let mut entry_iter = snapshot.entries(false);
-            while let Some(item) = entry_iter.entry() {
-                visible_worktree_entries.push(item.clone());
-                if expanded_dir_ids.binary_search(&item.id).is_err() {
+            while let Some(entry) = entry_iter.entry() {
+                visible_worktree_entries.push(entry.clone());
+                if Some(entry.id) == new_entry_parent_id {
+                    visible_worktree_entries.push(Entry {
+                        id: NEW_ENTRY_ID,
+                        kind: new_entry_kind,
+                        path: entry.path.join("\0").into(),
+                        inode: 0,
+                        mtime: entry.mtime,
+                        is_symlink: false,
+                        is_ignored: false,
+                    });
+                }
+                if expanded_dir_ids.binary_search(&entry.id).is_err() {
                     if entry_iter.advance_to_sibling() {
                         continue;
                     }
@@ -436,6 +713,7 @@ impl ProjectPanel {
             if ix >= range.end {
                 return;
             }
+
             if ix + visible_worktree_entries.len() <= range.start {
                 ix += visible_worktree_entries.len();
                 continue;
@@ -452,16 +730,42 @@ impl ProjectPanel {
                 let root_name = OsStr::new(snapshot.root_name());
                 for entry in &visible_worktree_entries[range.start.saturating_sub(ix)..end_ix - ix]
                 {
-                    let filename = entry.path.file_name().unwrap_or(root_name);
-                    let details = EntryDetails {
-                        filename: filename.to_string_lossy().to_string(),
+                    let mut details = EntryDetails {
+                        filename: entry
+                            .path
+                            .file_name()
+                            .unwrap_or(root_name)
+                            .to_string_lossy()
+                            .to_string(),
                         depth: entry.path.components().count(),
-                        is_dir: entry.is_dir(),
+                        kind: entry.kind,
                         is_expanded: expanded_entry_ids.binary_search(&entry.id).is_ok(),
                         is_selected: self.selection.map_or(false, |e| {
                             e.worktree_id == snapshot.id() && e.entry_id == entry.id
                         }),
+                        is_editing: false,
+                        is_processing: false,
                     };
+                    if let Some(edit_state) = &self.edit_state {
+                        let is_edited_entry = if edit_state.is_new_entry {
+                            entry.id == NEW_ENTRY_ID
+                        } else {
+                            entry.id == edit_state.entry_id
+                        };
+                        if is_edited_entry {
+                            if let Some(processing_filename) = &edit_state.processing_filename {
+                                details.is_processing = true;
+                                details.filename.clear();
+                                details.filename.push_str(&processing_filename);
+                            } else {
+                                if edit_state.is_new_entry {
+                                    details.filename.clear();
+                                }
+                                details.is_editing = true;
+                            }
+                        }
+                    }
+
                     callback(entry.id, details, cx);
                 }
             }
@@ -472,63 +776,73 @@ impl ProjectPanel {
     fn render_entry(
         entry_id: ProjectEntryId,
         details: EntryDetails,
+        editor: &ViewHandle<Editor>,
         theme: &theme::ProjectPanel,
         cx: &mut ViewContext<Self>,
     ) -> ElementBox {
-        let is_dir = details.is_dir;
+        let kind = details.kind;
+        let show_editor = details.is_editing && !details.is_processing;
         MouseEventHandler::new::<Self, _, _>(entry_id.to_usize(), cx, |state, _| {
-            let style = match (details.is_selected, state.hovered) {
-                (false, false) => &theme.entry,
-                (false, true) => &theme.hovered_entry,
-                (true, false) => &theme.selected_entry,
-                (true, true) => &theme.hovered_selected_entry,
+            let padding = theme.container.padding.left + details.depth as f32 * theme.indent_width;
+            let style = theme.entry.style_for(state, details.is_selected);
+            let row_container_style = if show_editor {
+                theme.filename_editor.container
+            } else {
+                style.container
             };
             Flex::row()
                 .with_child(
-                    ConstrainedBox::new(
-                        Align::new(
-                            ConstrainedBox::new(if is_dir {
-                                if details.is_expanded {
-                                    Svg::new("icons/disclosure-open.svg")
-                                        .with_color(style.icon_color)
-                                        .boxed()
-                                } else {
-                                    Svg::new("icons/disclosure-closed.svg")
-                                        .with_color(style.icon_color)
-                                        .boxed()
-                                }
-                            } else {
-                                Empty::new().boxed()
-                            })
-                            .with_max_width(style.icon_size)
-                            .with_max_height(style.icon_size)
-                            .boxed(),
-                        )
-                        .boxed(),
-                    )
+                    ConstrainedBox::new(if kind == EntryKind::Dir {
+                        if details.is_expanded {
+                            Svg::new("icons/disclosure-open.svg")
+                                .with_color(style.icon_color)
+                                .boxed()
+                        } else {
+                            Svg::new("icons/disclosure-closed.svg")
+                                .with_color(style.icon_color)
+                                .boxed()
+                        }
+                    } else {
+                        Empty::new().boxed()
+                    })
+                    .with_max_width(style.icon_size)
+                    .with_max_height(style.icon_size)
+                    .aligned()
+                    .constrained()
                     .with_width(style.icon_size)
                     .boxed(),
                 )
-                .with_child(
+                .with_child(if show_editor {
+                    ChildView::new(editor.clone())
+                        .contained()
+                        .with_margin_left(theme.entry.default.icon_spacing)
+                        .aligned()
+                        .left()
+                        .flex(1.0, true)
+                        .boxed()
+                } else {
                     Label::new(details.filename, style.text.clone())
                         .contained()
                         .with_margin_left(style.icon_spacing)
                         .aligned()
                         .left()
-                        .boxed(),
-                )
+                        .boxed()
+                })
                 .constrained()
-                .with_height(theme.entry.height)
+                .with_height(theme.entry.default.height)
                 .contained()
-                .with_style(style.container)
-                .with_padding_left(theme.container.padding.left + details.depth as f32 * 20.)
+                .with_style(row_container_style)
+                .with_padding_left(padding)
                 .boxed()
         })
-        .on_click(move |cx| {
-            if is_dir {
+        .on_click(move |click_count, cx| {
+            if kind == EntryKind::Dir {
                 cx.dispatch_action(ToggleExpanded(entry_id))
             } else {
-                cx.dispatch_action(Open(entry_id))
+                cx.dispatch_action(Open {
+                    entry_id,
+                    change_focus: click_count > 1,
+                })
             }
         })
         .with_cursor_style(CursorStyle::PointingHand)
@@ -556,8 +870,14 @@ impl View for ProjectPanel {
                 let theme = cx.global::<Settings>().theme.clone();
                 let this = handle.upgrade(cx).unwrap();
                 this.update(cx.app, |this, cx| {
-                    this.for_each_visible_entry(range.clone(), cx, |entry, details, cx| {
-                        items.push(Self::render_entry(entry, details, &theme.project_panel, cx));
+                    this.for_each_visible_entry(range.clone(), cx, |id, details, cx| {
+                        items.push(Self::render_entry(
+                            id,
+                            details,
+                            &this.filename_editor,
+                            &theme.project_panel,
+                            cx,
+                        ));
                     });
                 })
             },
@@ -584,6 +904,7 @@ impl Entity for ProjectPanel {
 mod tests {
     use super::*;
     use gpui::{TestAppContext, ViewHandle};
+    use project::FakeFs;
     use serde_json::json;
     use std::{collections::HashSet, path::Path};
     use workspace::WorkspaceParams;
@@ -592,8 +913,7 @@ mod tests {
     async fn test_visible_list(cx: &mut gpui::TestAppContext) {
         cx.foreground().forbid_parking();
 
-        let params = cx.update(WorkspaceParams::test);
-        let fs = params.fs.as_fake();
+        let fs = FakeFs::new(cx.background());
         fs.insert_tree(
             "/root1",
             json!({
@@ -630,241 +950,408 @@ mod tests {
         )
         .await;
 
-        let project = cx.update(|cx| {
-            Project::local(
-                params.client.clone(),
-                params.user_store.clone(),
-                params.languages.clone(),
-                params.fs.clone(),
-                cx,
-            )
-        });
-        let (root1, _) = project
-            .update(cx, |project, cx| {
-                project.find_or_create_local_worktree("/root1", true, cx)
-            })
-            .await
-            .unwrap();
-        root1
-            .read_with(cx, |t, _| t.as_local().unwrap().scan_complete())
-            .await;
-        let (root2, _) = project
-            .update(cx, |project, cx| {
-                project.find_or_create_local_worktree("/root2", true, cx)
-            })
-            .await
-            .unwrap();
-        root2
-            .read_with(cx, |t, _| t.as_local().unwrap().scan_complete())
-            .await;
-
+        let project = Project::test(fs.clone(), ["/root1", "/root2"], cx).await;
+        let params = cx.update(WorkspaceParams::test);
         let (_, workspace) = cx.add_window(|cx| Workspace::new(&params, cx));
         let panel = workspace.update(cx, |_, cx| ProjectPanel::new(project, cx));
         assert_eq!(
-            visible_entry_details(&panel, 0..50, cx),
+            visible_entries_as_strings(&panel, 0..50, cx),
             &[
-                EntryDetails {
-                    filename: "root1".to_string(),
-                    depth: 0,
-                    is_dir: true,
-                    is_expanded: true,
-                    is_selected: false,
-                },
-                EntryDetails {
-                    filename: "a".to_string(),
-                    depth: 1,
-                    is_dir: true,
-                    is_expanded: false,
-                    is_selected: false,
-                },
-                EntryDetails {
-                    filename: "b".to_string(),
-                    depth: 1,
-                    is_dir: true,
-                    is_expanded: false,
-                    is_selected: false,
-                },
-                EntryDetails {
-                    filename: "C".to_string(),
-                    depth: 1,
-                    is_dir: true,
-                    is_expanded: false,
-                    is_selected: false,
-                },
-                EntryDetails {
-                    filename: ".dockerignore".to_string(),
-                    depth: 1,
-                    is_dir: false,
-                    is_expanded: false,
-                    is_selected: false,
-                },
-                EntryDetails {
-                    filename: "root2".to_string(),
-                    depth: 0,
-                    is_dir: true,
-                    is_expanded: true,
-                    is_selected: false
-                },
-                EntryDetails {
-                    filename: "d".to_string(),
-                    depth: 1,
-                    is_dir: true,
-                    is_expanded: false,
-                    is_selected: false
-                },
-                EntryDetails {
-                    filename: "e".to_string(),
-                    depth: 1,
-                    is_dir: true,
-                    is_expanded: false,
-                    is_selected: false
-                }
-            ],
+                "v root1",
+                "    > a",
+                "    > b",
+                "    > C",
+                "      .dockerignore",
+                "v root2",
+                "    > d",
+                "    > e",
+            ]
         );
 
         toggle_expand_dir(&panel, "root1/b", cx);
         assert_eq!(
-            visible_entry_details(&panel, 0..50, cx),
+            visible_entries_as_strings(&panel, 0..50, cx),
             &[
-                EntryDetails {
-                    filename: "root1".to_string(),
-                    depth: 0,
-                    is_dir: true,
-                    is_expanded: true,
-                    is_selected: false,
-                },
-                EntryDetails {
-                    filename: "a".to_string(),
-                    depth: 1,
-                    is_dir: true,
-                    is_expanded: false,
-                    is_selected: false,
-                },
-                EntryDetails {
-                    filename: "b".to_string(),
-                    depth: 1,
-                    is_dir: true,
-                    is_expanded: true,
-                    is_selected: true,
-                },
-                EntryDetails {
-                    filename: "3".to_string(),
-                    depth: 2,
-                    is_dir: true,
-                    is_expanded: false,
-                    is_selected: false,
-                },
-                EntryDetails {
-                    filename: "4".to_string(),
-                    depth: 2,
-                    is_dir: true,
-                    is_expanded: false,
-                    is_selected: false,
-                },
-                EntryDetails {
-                    filename: "C".to_string(),
-                    depth: 1,
-                    is_dir: true,
-                    is_expanded: false,
-                    is_selected: false,
-                },
-                EntryDetails {
-                    filename: ".dockerignore".to_string(),
-                    depth: 1,
-                    is_dir: false,
-                    is_expanded: false,
-                    is_selected: false,
+                "v root1",
+                "    > a",
+                "    v b  <== selected",
+                "        > 3",
+                "        > 4",
+                "    > C",
+                "      .dockerignore",
+                "v root2",
+                "    > d",
+                "    > e",
+            ]
+        );
+
+        assert_eq!(
+            visible_entries_as_strings(&panel, 5..8, cx),
+            &[
+                //
+                "    > C",
+                "      .dockerignore",
+                "v root2",
+            ]
+        );
+    }
+
+    #[gpui::test(iterations = 30)]
+    async fn test_editing_files(cx: &mut gpui::TestAppContext) {
+        cx.foreground().forbid_parking();
+
+        let fs = FakeFs::new(cx.background());
+        fs.insert_tree(
+            "/root1",
+            json!({
+                ".dockerignore": "",
+                ".git": {
+                    "HEAD": "",
                 },
-                EntryDetails {
-                    filename: "root2".to_string(),
-                    depth: 0,
-                    is_dir: true,
-                    is_expanded: true,
-                    is_selected: false
+                "a": {
+                    "0": { "q": "", "r": "", "s": "" },
+                    "1": { "t": "", "u": "" },
+                    "2": { "v": "", "w": "", "x": "", "y": "" },
                 },
-                EntryDetails {
-                    filename: "d".to_string(),
-                    depth: 1,
-                    is_dir: true,
-                    is_expanded: false,
-                    is_selected: false
+                "b": {
+                    "3": { "Q": "" },
+                    "4": { "R": "", "S": "", "T": "", "U": "" },
                 },
-                EntryDetails {
-                    filename: "e".to_string(),
-                    depth: 1,
-                    is_dir: true,
-                    is_expanded: false,
-                    is_selected: false
+                "C": {
+                    "5": {},
+                    "6": { "V": "", "W": "" },
+                    "7": { "X": "" },
+                    "8": { "Y": {}, "Z": "" }
                 }
+            }),
+        )
+        .await;
+        fs.insert_tree(
+            "/root2",
+            json!({
+                "d": {
+                    "9": ""
+                },
+                "e": {}
+            }),
+        )
+        .await;
+
+        let project = Project::test(fs.clone(), ["/root1", "/root2"], cx).await;
+        let params = cx.update(WorkspaceParams::test);
+        let (_, workspace) = cx.add_window(|cx| Workspace::new(&params, cx));
+        let panel = workspace.update(cx, |_, cx| ProjectPanel::new(project, cx));
+
+        select_path(&panel, "root1", cx);
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..10, cx),
+            &[
+                "v root1  <== selected",
+                "    > a",
+                "    > b",
+                "    > C",
+                "      .dockerignore",
+                "v root2",
+                "    > d",
+                "    > e",
             ]
         );
 
+        // Add a file with the root folder selected. The filename editor is placed
+        // before the first file in the root folder.
+        panel.update(cx, |panel, cx| panel.add_file(&AddFile, cx));
+        assert!(panel.read_with(cx, |panel, cx| panel.filename_editor.is_focused(cx)));
         assert_eq!(
-            visible_entry_details(&panel, 5..8, cx),
-            [
-                EntryDetails {
-                    filename: "C".to_string(),
-                    depth: 1,
-                    is_dir: true,
-                    is_expanded: false,
-                    is_selected: false
-                },
-                EntryDetails {
-                    filename: ".dockerignore".to_string(),
-                    depth: 1,
-                    is_dir: false,
-                    is_expanded: false,
-                    is_selected: false
-                },
-                EntryDetails {
-                    filename: "root2".to_string(),
-                    depth: 0,
-                    is_dir: true,
-                    is_expanded: true,
-                    is_selected: false
-                }
+            visible_entries_as_strings(&panel, 0..10, cx),
+            &[
+                "v root1",
+                "    > a",
+                "    > b",
+                "    > C",
+                "      [EDITOR: '']  <== selected",
+                "      .dockerignore",
+                "v root2",
+                "    > d",
+                "    > e",
             ]
         );
 
-        fn toggle_expand_dir(
-            panel: &ViewHandle<ProjectPanel>,
-            path: impl AsRef<Path>,
-            cx: &mut TestAppContext,
-        ) {
-            let path = path.as_ref();
-            panel.update(cx, |panel, cx| {
-                for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
-                    let worktree = worktree.read(cx);
-                    if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
-                        let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
-                        panel.toggle_expanded(&ToggleExpanded(entry_id), cx);
-                        return;
-                    }
+        let confirm = panel.update(cx, |panel, cx| {
+            panel
+                .filename_editor
+                .update(cx, |editor, cx| editor.set_text("the-new-filename", cx));
+            panel.confirm(&Confirm, cx).unwrap()
+        });
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..10, cx),
+            &[
+                "v root1",
+                "    > a",
+                "    > b",
+                "    > C",
+                "      [PROCESSING: 'the-new-filename']  <== selected",
+                "      .dockerignore",
+                "v root2",
+                "    > d",
+                "    > e",
+            ]
+        );
+
+        confirm.await.unwrap();
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..10, cx),
+            &[
+                "v root1",
+                "    > a",
+                "    > b",
+                "    > C",
+                "      .dockerignore",
+                "      the-new-filename  <== selected",
+                "v root2",
+                "    > d",
+                "    > e",
+            ]
+        );
+
+        select_path(&panel, "root1/b", cx);
+        panel.update(cx, |panel, cx| panel.add_file(&AddFile, cx));
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..9, cx),
+            &[
+                "v root1",
+                "    > a",
+                "    v b",
+                "        > 3",
+                "        > 4",
+                "          [EDITOR: '']  <== selected",
+                "    > C",
+                "      .dockerignore",
+                "      the-new-filename",
+            ]
+        );
+
+        panel
+            .update(cx, |panel, cx| {
+                panel
+                    .filename_editor
+                    .update(cx, |editor, cx| editor.set_text("another-filename", cx));
+                panel.confirm(&Confirm, cx).unwrap()
+            })
+            .await
+            .unwrap();
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..9, cx),
+            &[
+                "v root1",
+                "    > a",
+                "    v b",
+                "        > 3",
+                "        > 4",
+                "          another-filename  <== selected",
+                "    > C",
+                "      .dockerignore",
+                "      the-new-filename",
+            ]
+        );
+
+        select_path(&panel, "root1/b/another-filename", cx);
+        panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..9, cx),
+            &[
+                "v root1",
+                "    > a",
+                "    v b",
+                "        > 3",
+                "        > 4",
+                "          [EDITOR: 'another-filename']  <== selected",
+                "    > C",
+                "      .dockerignore",
+                "      the-new-filename",
+            ]
+        );
+
+        let confirm = panel.update(cx, |panel, cx| {
+            panel
+                .filename_editor
+                .update(cx, |editor, cx| editor.set_text("a-different-filename", cx));
+            panel.confirm(&Confirm, cx).unwrap()
+        });
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..9, cx),
+            &[
+                "v root1",
+                "    > a",
+                "    v b",
+                "        > 3",
+                "        > 4",
+                "          [PROCESSING: 'a-different-filename']  <== selected",
+                "    > C",
+                "      .dockerignore",
+                "      the-new-filename",
+            ]
+        );
+
+        confirm.await.unwrap();
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..9, cx),
+            &[
+                "v root1",
+                "    > a",
+                "    v b",
+                "        > 3",
+                "        > 4",
+                "          a-different-filename  <== selected",
+                "    > C",
+                "      .dockerignore",
+                "      the-new-filename",
+            ]
+        );
+
+        panel.update(cx, |panel, cx| panel.add_directory(&AddDirectory, cx));
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..9, cx),
+            &[
+                "v root1",
+                "    > a",
+                "    v b",
+                "        > [EDITOR: '']  <== selected",
+                "        > 3",
+                "        > 4",
+                "          a-different-filename",
+                "    > C",
+                "      .dockerignore",
+            ]
+        );
+
+        let confirm = panel.update(cx, |panel, cx| {
+            panel
+                .filename_editor
+                .update(cx, |editor, cx| editor.set_text("new-dir", cx));
+            panel.confirm(&Confirm, cx).unwrap()
+        });
+        panel.update(cx, |panel, cx| panel.select_next(&Default::default(), cx));
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..9, cx),
+            &[
+                "v root1",
+                "    > a",
+                "    v b",
+                "        > [PROCESSING: 'new-dir']",
+                "        > 3  <== selected",
+                "        > 4",
+                "          a-different-filename",
+                "    > C",
+                "      .dockerignore",
+            ]
+        );
+
+        confirm.await.unwrap();
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..9, cx),
+            &[
+                "v root1",
+                "    > a",
+                "    v b",
+                "        > 3  <== selected",
+                "        > 4",
+                "        > new-dir",
+                "          a-different-filename",
+                "    > C",
+                "      .dockerignore",
+            ]
+        );
+    }
+
+    fn toggle_expand_dir(
+        panel: &ViewHandle<ProjectPanel>,
+        path: impl AsRef<Path>,
+        cx: &mut TestAppContext,
+    ) {
+        let path = path.as_ref();
+        panel.update(cx, |panel, cx| {
+            for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
+                let worktree = worktree.read(cx);
+                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
+                    let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
+                    panel.toggle_expanded(&ToggleExpanded(entry_id), cx);
+                    return;
                 }
-                panic!("no worktree for path {:?}", path);
-            });
-        }
+            }
+            panic!("no worktree for path {:?}", path);
+        });
+    }
+
+    fn select_path(
+        panel: &ViewHandle<ProjectPanel>,
+        path: impl AsRef<Path>,
+        cx: &mut TestAppContext,
+    ) {
+        let path = path.as_ref();
+        panel.update(cx, |panel, cx| {
+            for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
+                let worktree = worktree.read(cx);
+                if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
+                    let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
+                    panel.selection = Some(Selection {
+                        worktree_id: worktree.id(),
+                        entry_id,
+                    });
+                    return;
+                }
+            }
+            panic!("no worktree for path {:?}", path);
+        });
+    }
 
-        fn visible_entry_details(
-            panel: &ViewHandle<ProjectPanel>,
-            range: Range<usize>,
-            cx: &mut TestAppContext,
-        ) -> Vec<EntryDetails> {
-            let mut result = Vec::new();
-            let mut project_entries = HashSet::new();
-            panel.update(cx, |panel, cx| {
-                panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
+    fn visible_entries_as_strings(
+        panel: &ViewHandle<ProjectPanel>,
+        range: Range<usize>,
+        cx: &mut TestAppContext,
+    ) -> Vec<String> {
+        let mut result = Vec::new();
+        let mut project_entries = HashSet::new();
+        let mut has_editor = false;
+        panel.update(cx, |panel, cx| {
+            panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
+                if details.is_editing {
+                    assert!(!has_editor, "duplicate editor entry");
+                    has_editor = true;
+                } else {
                     assert!(
                         project_entries.insert(project_entry),
                         "duplicate project entry {:?} {:?}",
                         project_entry,
                         details
                     );
-                    result.push(details);
-                });
+                }
+
+                let indent = "    ".repeat(details.depth);
+                let icon = if matches!(details.kind, EntryKind::Dir | EntryKind::PendingDir) {
+                    if details.is_expanded {
+                        "v "
+                    } else {
+                        "> "
+                    }
+                } else {
+                    "  "
+                };
+                let name = if details.is_editing {
+                    format!("[EDITOR: '{}']", details.filename)
+                } else if details.is_processing {
+                    format!("[PROCESSING: '{}']", details.filename)
+                } else {
+                    details.filename.clone()
+                };
+                let selected = if details.is_selected {
+                    "  <== selected"
+                } else {
+                    ""
+                };
+                result.push(format!("{indent}{icon}{name}{selected}"));
             });
+        });
 
-            result
-        }
+        result
     }
 }

crates/project_symbols/src/project_symbols.rs 🔗

@@ -297,23 +297,12 @@ mod tests {
         let fs = FakeFs::new(cx.background());
         fs.insert_tree("/dir", json!({ "test.rs": "" })).await;
 
-        let project = Project::test(fs.clone(), cx);
-        project.update(cx, |project, _| {
-            project.languages().add(Arc::new(language));
-        });
-
-        let worktree_id = project
-            .update(cx, |project, cx| {
-                project.find_or_create_local_worktree("/dir", true, cx)
-            })
-            .await
-            .unwrap()
-            .0
-            .read_with(cx, |tree, _| tree.id());
+        let project = Project::test(fs.clone(), ["/dir"], cx).await;
+        project.update(cx, |project, _| project.languages().add(Arc::new(language)));
 
         let _buffer = project
             .update(cx, |project, cx| {
-                project.open_buffer((worktree_id, "test.rs"), cx)
+                project.open_local_buffer("/dir/test.rs", cx)
             })
             .await
             .unwrap();

crates/rpc/proto/zed.proto 🔗

@@ -36,57 +36,63 @@ message Envelope {
         RegisterWorktree register_worktree = 28;
         UnregisterWorktree unregister_worktree = 29;
         UpdateWorktree update_worktree = 31;
-        UpdateDiagnosticSummary update_diagnostic_summary = 32;
-        StartLanguageServer start_language_server = 33;
-        UpdateLanguageServer update_language_server = 34;
-
-        OpenBufferById open_buffer_by_id = 35;
-        OpenBufferByPath open_buffer_by_path = 36;
-        OpenBufferResponse open_buffer_response = 37;
-        UpdateBuffer update_buffer = 38;
-        UpdateBufferFile update_buffer_file = 39;
-        SaveBuffer save_buffer = 40;
-        BufferSaved buffer_saved = 41;
-        BufferReloaded buffer_reloaded = 42;
-        ReloadBuffers reload_buffers = 43;
-        ReloadBuffersResponse reload_buffers_response = 44;
-        FormatBuffers format_buffers = 45;
-        FormatBuffersResponse format_buffers_response = 46;
-        GetCompletions get_completions = 47;
-        GetCompletionsResponse get_completions_response = 48;
-        ApplyCompletionAdditionalEdits apply_completion_additional_edits = 49;
-        ApplyCompletionAdditionalEditsResponse apply_completion_additional_edits_response = 50;
-        GetCodeActions get_code_actions = 51;
-        GetCodeActionsResponse get_code_actions_response = 52;
-        ApplyCodeAction apply_code_action = 53;
-        ApplyCodeActionResponse apply_code_action_response = 54;
-        PrepareRename prepare_rename = 55;
-        PrepareRenameResponse prepare_rename_response = 56;
-        PerformRename perform_rename = 57;
-        PerformRenameResponse perform_rename_response = 58;
-        SearchProject search_project = 59;
-        SearchProjectResponse search_project_response = 60;
-
-        GetChannels get_channels = 61;
-        GetChannelsResponse get_channels_response = 62;
-        JoinChannel join_channel = 63;
-        JoinChannelResponse join_channel_response = 64;
-        LeaveChannel leave_channel = 65;
-        SendChannelMessage send_channel_message = 66;
-        SendChannelMessageResponse send_channel_message_response = 67;
-        ChannelMessageSent channel_message_sent = 68;
-        GetChannelMessages get_channel_messages = 69;
-        GetChannelMessagesResponse get_channel_messages_response = 70;
-
-        UpdateContacts update_contacts = 71;
-
-        GetUsers get_users = 72;
-        GetUsersResponse get_users_response = 73;
-
-        Follow follow = 74;
-        FollowResponse follow_response = 75;
-        UpdateFollowers update_followers = 76;
-        Unfollow unfollow = 77;
+
+        CreateProjectEntry create_project_entry = 32;
+        RenameProjectEntry rename_project_entry = 33;
+        DeleteProjectEntry delete_project_entry = 34;
+        ProjectEntryResponse project_entry_response = 35;
+
+        UpdateDiagnosticSummary update_diagnostic_summary = 36;
+        StartLanguageServer start_language_server = 37;
+        UpdateLanguageServer update_language_server = 38;
+
+        OpenBufferById open_buffer_by_id = 39;
+        OpenBufferByPath open_buffer_by_path = 40;
+        OpenBufferResponse open_buffer_response = 41;
+        UpdateBuffer update_buffer = 42;
+        UpdateBufferFile update_buffer_file = 43;
+        SaveBuffer save_buffer = 44;
+        BufferSaved buffer_saved = 45;
+        BufferReloaded buffer_reloaded = 46;
+        ReloadBuffers reload_buffers = 47;
+        ReloadBuffersResponse reload_buffers_response = 48;
+        FormatBuffers format_buffers = 49;
+        FormatBuffersResponse format_buffers_response = 50;
+        GetCompletions get_completions = 51;
+        GetCompletionsResponse get_completions_response = 52;
+        ApplyCompletionAdditionalEdits apply_completion_additional_edits = 53;
+        ApplyCompletionAdditionalEditsResponse apply_completion_additional_edits_response = 54;
+        GetCodeActions get_code_actions = 55;
+        GetCodeActionsResponse get_code_actions_response = 56;
+        ApplyCodeAction apply_code_action = 57;
+        ApplyCodeActionResponse apply_code_action_response = 58;
+        PrepareRename prepare_rename = 59;
+        PrepareRenameResponse prepare_rename_response = 60;
+        PerformRename perform_rename = 61;
+        PerformRenameResponse perform_rename_response = 62;
+        SearchProject search_project = 63;
+        SearchProjectResponse search_project_response = 64;
+
+        GetChannels get_channels = 65;
+        GetChannelsResponse get_channels_response = 66;
+        JoinChannel join_channel = 67;
+        JoinChannelResponse join_channel_response = 68;
+        LeaveChannel leave_channel = 69;
+        SendChannelMessage send_channel_message = 70;
+        SendChannelMessageResponse send_channel_message_response = 71;
+        ChannelMessageSent channel_message_sent = 72;
+        GetChannelMessages get_channel_messages = 73;
+        GetChannelMessagesResponse get_channel_messages_response = 74;
+
+        UpdateContacts update_contacts = 75;
+
+        GetUsers get_users = 76;
+        GetUsersResponse get_users_response = 77;
+
+        Follow follow = 78;
+        FollowResponse follow_response = 79;
+        UpdateFollowers update_followers = 80;
+        Unfollow unfollow = 81;
     }
 }
 
@@ -156,6 +162,30 @@ message UpdateWorktree {
     string root_name = 3;
     repeated Entry updated_entries = 4;
     repeated uint64 removed_entries = 5;
+    uint64 scan_id = 6;
+}
+
+message CreateProjectEntry {
+    uint64 project_id = 1;
+    uint64 worktree_id = 2;
+    bytes path = 3;
+    bool is_directory = 4;
+}
+
+message RenameProjectEntry {
+    uint64 project_id = 1;
+    uint64 entry_id = 2;
+    bytes new_path = 3;
+}
+
+message DeleteProjectEntry {
+    uint64 project_id = 1;
+    uint64 entry_id = 2;
+}
+
+message ProjectEntryResponse {
+    Entry entry = 1;
+    uint64 worktree_scan_id = 2;
 }
 
 message AddProjectCollaborator {
@@ -630,6 +660,7 @@ message Worktree {
     repeated Entry entries = 3;
     repeated DiagnosticSummary diagnostic_summaries = 4;
     bool visible = 5;
+    uint64 scan_id = 6;
 }
 
 message File {
@@ -642,7 +673,7 @@ message File {
 message Entry {
     uint64 id = 1;
     bool is_dir = 2;
-    string path = 3;
+    bytes path = 3;
     uint64 inode = 4;
     Timestamp mtime = 5;
     bool is_symlink = 6;

crates/rpc/src/proto.rs 🔗

@@ -6,13 +6,14 @@ use prost::Message as _;
 use serde::Serialize;
 use std::any::{Any, TypeId};
 use std::{
+    fmt::Debug,
     io,
     time::{Duration, SystemTime, UNIX_EPOCH},
 };
 
 include!(concat!(env!("OUT_DIR"), "/zed.messages.rs"));
 
-pub trait EnvelopedMessage: Clone + Serialize + Sized + Send + Sync + 'static {
+pub trait EnvelopedMessage: Clone + Debug + Serialize + Sized + Send + Sync + 'static {
     const NAME: &'static str;
     const PRIORITY: MessagePriority;
     fn into_envelope(
@@ -147,6 +148,8 @@ messages!(
     (BufferReloaded, Foreground),
     (BufferSaved, Foreground),
     (ChannelMessageSent, Foreground),
+    (CreateProjectEntry, Foreground),
+    (DeleteProjectEntry, Foreground),
     (Error, Foreground),
     (Follow, Foreground),
     (FollowResponse, Foreground),
@@ -174,8 +177,6 @@ messages!(
     (JoinChannelResponse, Foreground),
     (JoinProject, Foreground),
     (JoinProjectResponse, Foreground),
-    (StartLanguageServer, Foreground),
-    (UpdateLanguageServer, Foreground),
     (LeaveChannel, Foreground),
     (LeaveProject, Foreground),
     (OpenBufferById, Background),
@@ -187,6 +188,7 @@ messages!(
     (PerformRenameResponse, Background),
     (PrepareRename, Background),
     (PrepareRenameResponse, Background),
+    (ProjectEntryResponse, Foreground),
     (RegisterProjectResponse, Foreground),
     (Ping, Foreground),
     (RegisterProject, Foreground),
@@ -194,12 +196,14 @@ messages!(
     (ReloadBuffers, Foreground),
     (ReloadBuffersResponse, Foreground),
     (RemoveProjectCollaborator, Foreground),
+    (RenameProjectEntry, Foreground),
     (SaveBuffer, Foreground),
     (SearchProject, Background),
     (SearchProjectResponse, Background),
     (SendChannelMessage, Foreground),
     (SendChannelMessageResponse, Foreground),
     (ShareProject, Foreground),
+    (StartLanguageServer, Foreground),
     (Test, Foreground),
     (Unfollow, Foreground),
     (UnregisterProject, Foreground),
@@ -210,6 +214,7 @@ messages!(
     (UpdateContacts, Foreground),
     (UpdateDiagnosticSummary, Foreground),
     (UpdateFollowers, Foreground),
+    (UpdateLanguageServer, Foreground),
     (UpdateWorktree, Foreground),
 );
 
@@ -219,6 +224,8 @@ request_messages!(
         ApplyCompletionAdditionalEdits,
         ApplyCompletionAdditionalEditsResponse
     ),
+    (CreateProjectEntry, ProjectEntryResponse),
+    (DeleteProjectEntry, ProjectEntryResponse),
     (Follow, FollowResponse),
     (FormatBuffers, FormatBuffersResponse),
     (GetChannelMessages, GetChannelMessagesResponse),
@@ -241,6 +248,7 @@ request_messages!(
     (RegisterProject, RegisterProjectResponse),
     (RegisterWorktree, Ack),
     (ReloadBuffers, ReloadBuffersResponse),
+    (RenameProjectEntry, ProjectEntryResponse),
     (SaveBuffer, BufferSaved),
     (SearchProject, SearchProjectResponse),
     (SendChannelMessage, SendChannelMessageResponse),
@@ -257,6 +265,9 @@ entity_messages!(
     ApplyCompletionAdditionalEdits,
     BufferReloaded,
     BufferSaved,
+    CreateProjectEntry,
+    RenameProjectEntry,
+    DeleteProjectEntry,
     Follow,
     FormatBuffers,
     GetCodeActions,

crates/rpc/src/rpc.rs 🔗

@@ -5,4 +5,4 @@ pub mod proto;
 pub use conn::Connection;
 pub use peer::*;
 
-pub const PROTOCOL_VERSION: u32 = 15;
+pub const PROTOCOL_VERSION: u32 = 16;

crates/search/src/buffer_search.rs 🔗

@@ -292,7 +292,7 @@ impl BufferSearchBar {
                 .with_style(style.container)
                 .boxed()
         })
-        .on_click(move |cx| cx.dispatch_action(ToggleSearchOption(search_option)))
+        .on_click(move |_, cx| cx.dispatch_action(ToggleSearchOption(search_option)))
         .with_cursor_style(CursorStyle::PointingHand)
         .boxed()
     }
@@ -316,7 +316,7 @@ impl BufferSearchBar {
                 .with_style(style.container)
                 .boxed()
         })
-        .on_click(move |cx| match direction {
+        .on_click(move |_, cx| match direction {
             Direction::Prev => cx.dispatch_action(SelectPrevMatch),
             Direction::Next => cx.dispatch_action(SelectNextMatch),
         })

crates/search/src/project_search.rs 🔗

@@ -666,7 +666,7 @@ impl ProjectSearchBar {
                 .with_style(style.container)
                 .boxed()
         })
-        .on_click(move |cx| match direction {
+        .on_click(move |_, cx| match direction {
             Direction::Prev => cx.dispatch_action(SelectPrevMatch),
             Direction::Next => cx.dispatch_action(SelectNextMatch),
         })
@@ -693,7 +693,7 @@ impl ProjectSearchBar {
                 .with_style(style.container)
                 .boxed()
         })
-        .on_click(move |cx| cx.dispatch_action(ToggleSearchOption(option)))
+        .on_click(move |_, cx| cx.dispatch_action(ToggleSearchOption(option)))
         .with_cursor_style(CursorStyle::PointingHand)
         .boxed()
     }
@@ -844,16 +844,7 @@ mod tests {
             }),
         )
         .await;
-        let project = Project::test(fs.clone(), cx);
-        let (tree, _) = project
-            .update(cx, |project, cx| {
-                project.find_or_create_local_worktree("/dir", true, cx)
-            })
-            .await
-            .unwrap();
-        cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
-            .await;
-
+        let project = Project::test(fs.clone(), ["/dir"], cx).await;
         let search = cx.add_model(|cx| ProjectSearch::new(project, cx));
         let search_view = cx.add_view(Default::default(), |cx| {
             ProjectSearchView::new(search.clone(), cx)

crates/sum_tree/src/sum_tree.rs 🔗

@@ -483,17 +483,20 @@ impl<T: Item + PartialEq> PartialEq for SumTree<T> {
 impl<T: Item + Eq> Eq for SumTree<T> {}
 
 impl<T: KeyedItem> SumTree<T> {
-    pub fn insert_or_replace(&mut self, item: T, cx: &<T::Summary as Summary>::Context) -> bool {
-        let mut replaced = false;
+    pub fn insert_or_replace(
+        &mut self,
+        item: T,
+        cx: &<T::Summary as Summary>::Context,
+    ) -> Option<T> {
+        let mut replaced = None;
         *self = {
             let mut cursor = self.cursor::<T::Key>();
             let mut new_tree = cursor.slice(&item.key(), Bias::Left, cx);
-            if cursor
-                .item()
-                .map_or(false, |cursor_item| cursor_item.key() == item.key())
-            {
-                cursor.next(cx);
-                replaced = true;
+            if let Some(cursor_item) = cursor.item() {
+                if cursor_item.key() == item.key() {
+                    replaced = Some(cursor_item.clone());
+                    cursor.next(cx);
+                }
             }
             new_tree.push(item, cx);
             new_tree.push_tree(cursor.suffix(cx), cx);
@@ -502,6 +505,23 @@ impl<T: KeyedItem> SumTree<T> {
         replaced
     }
 
+    pub fn remove(&mut self, key: &T::Key, cx: &<T::Summary as Summary>::Context) -> Option<T> {
+        let mut removed = None;
+        *self = {
+            let mut cursor = self.cursor::<T::Key>();
+            let mut new_tree = cursor.slice(key, Bias::Left, cx);
+            if let Some(item) = cursor.item() {
+                if item.key() == *key {
+                    removed = Some(item.clone());
+                    cursor.next(cx);
+                }
+            }
+            new_tree.push_tree(cursor.suffix(cx), cx);
+            new_tree
+        };
+        removed
+    }
+
     pub fn edit(
         &mut self,
         mut edits: Vec<Edit<T>>,

crates/theme/src/theme.rs 🔗

@@ -204,14 +204,13 @@ pub struct ChatPanel {
     pub hovered_sign_in_prompt: TextStyle,
 }
 
-#[derive(Debug, Deserialize, Default)]
+#[derive(Deserialize, Default)]
 pub struct ProjectPanel {
     #[serde(flatten)]
     pub container: ContainerStyle,
-    pub entry: ProjectPanelEntry,
-    pub hovered_entry: ProjectPanelEntry,
-    pub selected_entry: ProjectPanelEntry,
-    pub hovered_selected_entry: ProjectPanelEntry,
+    pub entry: Interactive<ProjectPanelEntry>,
+    pub filename_editor: FieldEditor,
+    pub indent_width: f32,
 }
 
 #[derive(Debug, Deserialize, Default)]

crates/vim/src/vim_test_context.rs 🔗

@@ -50,7 +50,7 @@ impl<'a> VimTestContext<'a> {
 
         let file = cx.read(|cx| workspace.file_project_paths(cx)[0].clone());
         let item = workspace
-            .update(cx, |workspace, cx| workspace.open_path(file, cx))
+            .update(cx, |workspace, cx| workspace.open_path(file, true, cx))
             .await
             .expect("Could not open test file");
 

crates/workspace/src/lsp_status.rs 🔗

@@ -168,7 +168,7 @@ impl View for LspStatus {
                     self.failed.join(", "),
                     if self.failed.len() > 1 { "s" } else { "" }
                 );
-                handler = Some(|cx: &mut EventContext| cx.dispatch_action(DismissErrorMessage));
+                handler = Some(|_, cx: &mut EventContext| cx.dispatch_action(DismissErrorMessage));
             } else {
                 return Empty::new().boxed();
             }

crates/workspace/src/pane.rs 🔗

@@ -59,7 +59,7 @@ const MAX_NAVIGATION_HISTORY_LEN: usize = 1024;
 
 pub fn init(cx: &mut MutableAppContext) {
     cx.add_action(|pane: &mut Pane, action: &ActivateItem, cx| {
-        pane.activate_item(action.0, true, cx);
+        pane.activate_item(action.0, true, true, cx);
     });
     cx.add_action(|pane: &mut Pane, _: &ActivatePrevItem, cx| {
         pane.activate_prev_item(cx);
@@ -213,7 +213,7 @@ impl Pane {
                 {
                     let prev_active_item_index = pane.active_item_index;
                     pane.nav_history.borrow_mut().set_mode(mode);
-                    pane.activate_item(index, true, cx);
+                    pane.activate_item(index, true, true, cx);
                     pane.nav_history
                         .borrow_mut()
                         .set_mode(NavigationMode::Normal);
@@ -257,6 +257,7 @@ impl Pane {
                                 workspace,
                                 pane.clone(),
                                 project_entry_id,
+                                true,
                                 cx,
                                 build_item,
                             )
@@ -287,6 +288,7 @@ impl Pane {
         workspace: &mut Workspace,
         pane: ViewHandle<Pane>,
         project_entry_id: ProjectEntryId,
+        focus_item: bool,
         cx: &mut ViewContext<Workspace>,
         build_item: impl FnOnce(&mut MutableAppContext) -> Box<dyn ItemHandle>,
     ) -> Box<dyn ItemHandle> {
@@ -294,7 +296,7 @@ impl Pane {
             for (ix, item) in pane.items.iter().enumerate() {
                 if item.project_entry_id(cx) == Some(project_entry_id) {
                     let item = item.boxed_clone();
-                    pane.activate_item(ix, true, cx);
+                    pane.activate_item(ix, true, focus_item, cx);
                     return Some(item);
                 }
             }
@@ -304,7 +306,7 @@ impl Pane {
             existing_item
         } else {
             let item = build_item(cx);
-            Self::add_item(workspace, pane, item.boxed_clone(), true, cx);
+            Self::add_item(workspace, pane, item.boxed_clone(), true, focus_item, cx);
             item
         }
     }
@@ -313,12 +315,15 @@ impl Pane {
         workspace: &mut Workspace,
         pane: ViewHandle<Pane>,
         item: Box<dyn ItemHandle>,
-        local: bool,
+        activate_pane: bool,
+        focus_item: bool,
         cx: &mut ViewContext<Workspace>,
     ) {
         // Prevent adding the same item to the pane more than once.
         if let Some(item_ix) = pane.read(cx).items.iter().position(|i| i.id() == item.id()) {
-            pane.update(cx, |pane, cx| pane.activate_item(item_ix, local, cx));
+            pane.update(cx, |pane, cx| {
+                pane.activate_item(item_ix, activate_pane, focus_item, cx)
+            });
             return;
         }
 
@@ -327,7 +332,7 @@ impl Pane {
         pane.update(cx, |pane, cx| {
             let item_idx = cmp::min(pane.active_item_index + 1, pane.items.len());
             pane.items.insert(item_idx, item);
-            pane.activate_item(item_idx, local, cx);
+            pane.activate_item(item_idx, activate_pane, focus_item, cx);
             cx.notify();
         });
     }
@@ -378,7 +383,13 @@ impl Pane {
         self.items.iter().position(|i| i.id() == item.id())
     }
 
-    pub fn activate_item(&mut self, index: usize, local: bool, cx: &mut ViewContext<Self>) {
+    pub fn activate_item(
+        &mut self,
+        index: usize,
+        activate_pane: bool,
+        focus_item: bool,
+        cx: &mut ViewContext<Self>,
+    ) {
         use NavigationMode::{GoingBack, GoingForward};
         if index < self.items.len() {
             let prev_active_item_ix = mem::replace(&mut self.active_item_index, index);
@@ -387,11 +398,15 @@ impl Pane {
                     && prev_active_item_ix < self.items.len())
             {
                 self.items[prev_active_item_ix].deactivated(cx);
-                cx.emit(Event::ActivateItem { local });
+                cx.emit(Event::ActivateItem {
+                    local: activate_pane,
+                });
             }
             self.update_toolbar(cx);
-            if local {
+            if focus_item {
                 self.focus_active_item(cx);
+            }
+            if activate_pane {
                 self.activate(cx);
             }
             self.autoscroll = true;
@@ -406,7 +421,7 @@ impl Pane {
         } else if self.items.len() > 0 {
             index = self.items.len() - 1;
         }
-        self.activate_item(index, true, cx);
+        self.activate_item(index, true, true, cx);
     }
 
     pub fn activate_next_item(&mut self, cx: &mut ViewContext<Self>) {
@@ -416,7 +431,7 @@ impl Pane {
         } else {
             index = 0;
         }
-        self.activate_item(index, true, cx);
+        self.activate_item(index, true, true, cx);
     }
 
     fn close_active_item(
@@ -498,7 +513,7 @@ impl Pane {
                 if is_last_item_for_entry {
                     if cx.read(|cx| item.has_conflict(cx) && item.can_save(cx)) {
                         let mut answer = pane.update(&mut cx, |pane, cx| {
-                            pane.activate_item(item_to_close_ix, true, cx);
+                            pane.activate_item(item_to_close_ix, true, true, cx);
                             cx.prompt(
                                 PromptLevel::Warning,
                                 CONFLICT_MESSAGE,
@@ -518,7 +533,7 @@ impl Pane {
                     } else if cx.read(|cx| item.is_dirty(cx)) {
                         if cx.read(|cx| item.can_save(cx)) {
                             let mut answer = pane.update(&mut cx, |pane, cx| {
-                                pane.activate_item(item_to_close_ix, true, cx);
+                                pane.activate_item(item_to_close_ix, true, true, cx);
                                 cx.prompt(
                                     PromptLevel::Warning,
                                     DIRTY_MESSAGE,
@@ -535,7 +550,7 @@ impl Pane {
                             }
                         } else if cx.read(|cx| item.can_save_as(cx)) {
                             let mut answer = pane.update(&mut cx, |pane, cx| {
-                                pane.activate_item(item_to_close_ix, true, cx);
+                                pane.activate_item(item_to_close_ix, true, true, cx);
                                 cx.prompt(
                                     PromptLevel::Warning,
                                     DIRTY_MESSAGE,
@@ -737,7 +752,7 @@ impl Pane {
                                             .with_cursor_style(CursorStyle::PointingHand)
                                             .on_click({
                                                 let pane = pane.clone();
-                                                move |cx| {
+                                                move |_, cx| {
                                                     cx.dispatch_action(CloseItem {
                                                         item_id,
                                                         pane: pane.clone(),
@@ -949,7 +964,7 @@ mod tests {
 
         let close_items = workspace.update(cx, |workspace, cx| {
             pane.update(cx, |pane, cx| {
-                pane.activate_item(1, true, cx);
+                pane.activate_item(1, true, true, cx);
                 assert_eq!(pane.active_item().unwrap().id(), item2.id());
             });
 

crates/workspace/src/sidebar.rs 🔗

@@ -203,7 +203,7 @@ impl View for SidebarButtons {
                         .boxed()
                 })
                 .with_cursor_style(CursorStyle::PointingHand)
-                .on_click(move |cx| {
+                .on_click(move |_, cx| {
                     cx.dispatch_action(ToggleSidebarItem {
                         side,
                         item_index: ix,

crates/workspace/src/workspace.rs 🔗

@@ -493,7 +493,7 @@ impl<T: Item> ItemHandle for ViewHandle<T> {
             if T::should_activate_item_on_event(event) {
                 pane.update(cx, |pane, cx| {
                     if let Some(ix) = pane.index_for_item(&item) {
-                        pane.activate_item(ix, true, cx);
+                        pane.activate_item(ix, true, true, cx);
                         pane.activate(cx);
                     }
                 });
@@ -898,7 +898,7 @@ impl Workspace {
                             if fs.is_file(&abs_path).await {
                                 Some(
                                     this.update(&mut cx, |this, cx| {
-                                        this.open_path(project_path, cx)
+                                        this.open_path(project_path, true, cx)
                                     })
                                     .await,
                                 )
@@ -1065,7 +1065,7 @@ impl Workspace {
             Side::Right => &mut self.right_sidebar,
         };
         let active_item = sidebar.update(cx, |sidebar, cx| {
-            sidebar.toggle_item(action.item_index, cx);
+            sidebar.activate_item(action.item_index, cx);
             sidebar.active_item().cloned()
         });
         if let Some(active_item) = active_item {
@@ -1099,12 +1099,13 @@ impl Workspace {
 
     pub fn add_item(&mut self, item: Box<dyn ItemHandle>, cx: &mut ViewContext<Self>) {
         let pane = self.active_pane().clone();
-        Pane::add_item(self, pane, item, true, cx);
+        Pane::add_item(self, pane, item, true, true, cx);
     }
 
     pub fn open_path(
         &mut self,
         path: impl Into<ProjectPath>,
+        focus_item: bool,
         cx: &mut ViewContext<Self>,
     ) -> Task<Result<Box<dyn ItemHandle>, Arc<anyhow::Error>>> {
         let pane = self.active_pane().downgrade();
@@ -1119,6 +1120,7 @@ impl Workspace {
                     this,
                     pane,
                     project_entry_id,
+                    focus_item,
                     cx,
                     build_item,
                 ))
@@ -1187,7 +1189,7 @@ impl Workspace {
         });
         if let Some((pane, ix)) = result {
             self.activate_pane(pane.clone(), cx);
-            pane.update(cx, |pane, cx| pane.activate_item(ix, true, cx));
+            pane.update(cx, |pane, cx| pane.activate_item(ix, true, true, cx));
             true
         } else {
             false
@@ -1277,7 +1279,7 @@ impl Workspace {
         self.activate_pane(new_pane.clone(), cx);
         if let Some(item) = pane.read(cx).active_item() {
             if let Some(clone) = item.clone_on_split(cx.as_mut()) {
-                Pane::add_item(self, new_pane.clone(), clone, true, cx);
+                Pane::add_item(self, new_pane.clone(), clone, true, true, cx);
             }
         }
         self.center.split(&pane, &new_pane, direction).unwrap();
@@ -1584,7 +1586,7 @@ impl Workspace {
                         .with_style(style.container)
                         .boxed()
                 })
-                .on_click(|cx| cx.dispatch_action(Authenticate))
+                .on_click(|_, cx| cx.dispatch_action(Authenticate))
                 .with_cursor_style(CursorStyle::PointingHand)
                 .aligned()
                 .boxed(),
@@ -1635,7 +1637,7 @@ impl Workspace {
         if let Some(peer_id) = peer_id {
             MouseEventHandler::new::<ToggleFollow, _, _>(replica_id.into(), cx, move |_, _| content)
                 .with_cursor_style(CursorStyle::PointingHand)
-                .on_click(move |cx| cx.dispatch_action(ToggleFollow(peer_id)))
+                .on_click(move |_, cx| cx.dispatch_action(ToggleFollow(peer_id)))
                 .boxed()
         } else {
             content
@@ -1667,7 +1669,7 @@ impl Workspace {
                         .boxed()
                 })
                 .with_cursor_style(CursorStyle::PointingHand)
-                .on_click(|cx| cx.dispatch_action(ToggleShare))
+                .on_click(|_, cx| cx.dispatch_action(ToggleShare))
                 .boxed(),
             )
         } else {
@@ -1961,7 +1963,7 @@ impl Workspace {
         }
 
         for (pane, item) in items_to_add {
-            Pane::add_item(self, pane.clone(), item.boxed_clone(), false, cx);
+            Pane::add_item(self, pane.clone(), item.boxed_clone(), false, false, cx);
             if pane == self.active_pane {
                 pane.update(cx, |pane, cx| pane.focus_active_item(cx));
             }

crates/zed/src/zed.rs 🔗

@@ -446,7 +446,7 @@ mod tests {
 
         // Open the first entry
         let entry_1 = workspace
-            .update(cx, |w, cx| w.open_path(file1.clone(), cx))
+            .update(cx, |w, cx| w.open_path(file1.clone(), true, cx))
             .await
             .unwrap();
         cx.read(|cx| {
@@ -460,7 +460,7 @@ mod tests {
 
         // Open the second entry
         workspace
-            .update(cx, |w, cx| w.open_path(file2.clone(), cx))
+            .update(cx, |w, cx| w.open_path(file2.clone(), true, cx))
             .await
             .unwrap();
         cx.read(|cx| {
@@ -474,7 +474,7 @@ mod tests {
 
         // Open the first entry again. The existing pane item is activated.
         let entry_1b = workspace
-            .update(cx, |w, cx| w.open_path(file1.clone(), cx))
+            .update(cx, |w, cx| w.open_path(file1.clone(), true, cx))
             .await
             .unwrap();
         assert_eq!(entry_1.id(), entry_1b.id());
@@ -492,7 +492,7 @@ mod tests {
         workspace
             .update(cx, |w, cx| {
                 w.split_pane(w.active_pane().clone(), SplitDirection::Right, cx);
-                w.open_path(file2.clone(), cx)
+                w.open_path(file2.clone(), true, cx)
             })
             .await
             .unwrap();
@@ -511,8 +511,8 @@ mod tests {
         // Open the third entry twice concurrently. Only one pane item is added.
         let (t1, t2) = workspace.update(cx, |w, cx| {
             (
-                w.open_path(file3.clone(), cx),
-                w.open_path(file3.clone(), cx),
+                w.open_path(file3.clone(), true, cx),
+                w.open_path(file3.clone(), true, cx),
             )
         });
         t1.await.unwrap();
@@ -780,6 +780,7 @@ mod tests {
                         worktree_id: worktree.read(cx).id(),
                         path: Path::new("the-new-name.rs").into(),
                     },
+                    true,
                     cx,
                 )
             })
@@ -875,7 +876,7 @@ mod tests {
         let pane_1 = cx.read(|cx| workspace.read(cx).active_pane().clone());
 
         workspace
-            .update(cx, |w, cx| w.open_path(file1.clone(), cx))
+            .update(cx, |w, cx| w.open_path(file1.clone(), true, cx))
             .await
             .unwrap();
 
@@ -955,7 +956,7 @@ mod tests {
         let file3 = entries[2].clone();
 
         let editor1 = workspace
-            .update(cx, |w, cx| w.open_path(file1.clone(), cx))
+            .update(cx, |w, cx| w.open_path(file1.clone(), true, cx))
             .await
             .unwrap()
             .downcast::<Editor>()
@@ -964,13 +965,13 @@ mod tests {
             editor.select_display_ranges(&[DisplayPoint::new(10, 0)..DisplayPoint::new(10, 0)], cx);
         });
         let editor2 = workspace
-            .update(cx, |w, cx| w.open_path(file2.clone(), cx))
+            .update(cx, |w, cx| w.open_path(file2.clone(), true, cx))
             .await
             .unwrap()
             .downcast::<Editor>()
             .unwrap();
         let editor3 = workspace
-            .update(cx, |w, cx| w.open_path(file3.clone(), cx))
+            .update(cx, |w, cx| w.open_path(file3.clone(), true, cx))
             .await
             .unwrap()
             .downcast::<Editor>()

styles/src/styleTree/projectPanel.ts 🔗

@@ -1,34 +1,35 @@
 import Theme from "../themes/theme";
-import { Color } from "../utils/color";
 import { panel } from "./app";
-import { backgroundColor, iconColor, text, TextColor } from "./components";
+import { backgroundColor, iconColor, player, text } from "./components";
 
 export default function projectPanel(theme: Theme) {
-  function entry(theme: Theme, textColor: TextColor, background?: Color) {
-    return {
+  return {
+    ...panel,
+    padding: { left: 12, right: 12, top: 6, bottom: 6 },
+    indentWidth: 20,
+    entry: {
       height: 24,
-      background,
       iconColor: iconColor(theme, "muted"),
       iconSize: 8,
       iconSpacing: 8,
-      text: text(theme, "mono", textColor, { size: "sm" }),
-    };
-  }
-
-  return {
-    ...panel,
-    entry: entry(theme, "muted"),
-    hoveredEntry: entry(
-      theme,
-      "primary",
-      backgroundColor(theme, 300, "hovered")
-    ),
-    selectedEntry: entry(theme, "primary"),
-    hoveredSelectedEntry: entry(
-      theme,
-      "active",
-      backgroundColor(theme, 300, "hovered")
-    ),
-    padding: { left: 12, right: 12, top: 6, bottom: 6 },
+      text: text(theme, "mono", "muted", { size: "sm" }),
+      hover: {
+        background: backgroundColor(theme, 300, "hovered"),
+        text: text(theme, "mono", "primary", { size: "sm" }),
+      },
+      active: {
+        background: backgroundColor(theme, 300, "active"),
+        text: text(theme, "mono", "primary", { size: "sm" }),
+      },
+      activeHover: {
+        background: backgroundColor(theme, 300, "hovered"),
+        text: text(theme, "mono", "active", { size: "sm" }),
+      }
+    },
+    filenameEditor: {
+      background: backgroundColor(theme, 500, "active"),
+      text: text(theme, "mono", "primary", { size: "sm" }),
+      selection: player(theme, 1).selection,
+    },
   };
 }