From e4e656fb42a01cd8db7aea53c48ae0f38377db57 Mon Sep 17 00:00:00 2001
From: Korbin de Man <113640462+korbindeman@users.noreply.github.com>
Date: Thu, 23 Apr 2026 07:46:58 +0200
Subject: [PATCH] Add "Add to .gitignore" action in project_panel (#47377)
This PR adds a "Add to .gitignore" action to the project panel's
right-click context menu. Similar to the "Restore File" action that I
previously added, I frequently find myself wanting this in the project
panel.
With the restore file option:
Notes:
- **Implementation**: The `add_to_gitignore` function is essentially
copy-pasted from `git_panel.rs`.
- **Error handling**: Added toast notification on error, which is
consistent with `restore_file` in project_panel and `perform_checkout`
in git_panel.
Note that `add_to_gitignore` in git_panel does NOT show a toast (just
uses `detach_and_log_err`). I don't know if this is on purpose.
To follow up, I can either: match the project_panel implementation to
the git_panel one (no toast), or update the git_panel implementation to
also show a toast on error.
- **Menu grouping**: Previously "Restore File" and "View File History"
were in separate sections, but both relate to git. With this third git
action, I grouped all three together under a single separator (see
screenshot).
We could also keep "View File History" separate and only group "Restore
File" + "Add to .gitignore" together (both modify the working tree state
in some way), if we don't want to alter the existing UI too much.
Release Notes:
- Added "Add to .gitignore" option to the project panel context menu for
files in git repositories.
---------
Co-authored-by: Chris Biscardi
---
crates/git_ui/src/git_panel.rs | 54 ++++---------------
crates/project/src/git_store.rs | 49 +++++++++++++++++
crates/project_panel/src/project_panel.rs | 66 ++++++++++++++++++++---
3 files changed, 117 insertions(+), 52 deletions(-)
diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs
index 8f66350a306..6f5463d65a3 100644
--- a/crates/git_ui/src/git_panel.rs
+++ b/crates/git_ui/src/git_panel.rs
@@ -1456,55 +1456,21 @@ impl GitPanel {
return Some(());
}
- let project = self.project.downgrade();
+ let active_repository = self.active_repository.clone()?;
+ let workspace = self.workspace.clone();
let repo_path = entry.repo_path;
- let active_repository = self.active_repository.as_ref()?.downgrade();
+
+ let receiver = active_repository
+ .update(cx, |repo, _| repo.add_path_to_gitignore(&repo_path, false));
cx.spawn(async move |_, cx| {
- let file_path_str = repo_path.as_ref().display(PathStyle::Posix);
-
- let repo_root = active_repository.read_with(cx, |repository, _| {
- repository.snapshot().work_directory_abs_path
- })?;
-
- let gitignore_abs_path = repo_root.join(".gitignore");
-
- let buffer: Entity = project
- .update(cx, |project, cx| {
- project.open_local_buffer(gitignore_abs_path, cx)
- })?
- .await?;
-
- let mut should_save = false;
- buffer.update(cx, |buffer, cx| {
- let existing_content = buffer.text();
-
- if existing_content
- .lines()
- .any(|line: &str| line.trim() == file_path_str)
- {
- return;
+ if let Err(e) = receiver.await? {
+ if let Some(workspace) = workspace.upgrade() {
+ cx.update(|cx| {
+ show_error_toast(workspace, "add to .gitignore", e, cx);
+ });
}
-
- let insert_position = existing_content.len();
- let new_entry = if existing_content.is_empty() {
- format!("{}\n", file_path_str)
- } else if existing_content.ends_with('\n') {
- format!("{}\n", file_path_str)
- } else {
- format!("\n{}\n", file_path_str)
- };
-
- buffer.edit([(insert_position..insert_position, new_entry)], None, cx);
- should_save = true;
- });
-
- if should_save {
- project
- .update(cx, |project, cx| project.save_buffer(buffer, cx))?
- .await?;
}
-
anyhow::Ok(())
})
.detach_and_log_err(cx);
diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs
index e57441520a4..3bfd10fc481 100644
--- a/crates/project/src/git_store.rs
+++ b/crates/project/src/git_store.rs
@@ -5822,6 +5822,55 @@ impl Repository {
})
}
+ pub fn add_path_to_gitignore(
+ &mut self,
+ repo_path: &RepoPath,
+ is_dir: bool,
+ ) -> oneshot::Receiver> {
+ let work_dir = self.snapshot.work_directory_abs_path.clone();
+ let path_display = repo_path.as_ref().display(PathStyle::Posix);
+ let file_path_str = if is_dir {
+ format!("{}/", path_display)
+ } else {
+ path_display.to_string()
+ };
+
+ self.send_job(None, move |git_repo, _cx| async move {
+ match git_repo {
+ RepositoryState::Local(LocalRepositoryState { fs, .. }) => {
+ let gitignore_path = work_dir.join(".gitignore");
+
+ let existing_content = fs.load(&gitignore_path).await.unwrap_or_default();
+
+ if existing_content
+ .lines()
+ .any(|line| line.trim() == file_path_str)
+ {
+ return Ok(());
+ }
+
+ let new_content = if existing_content.is_empty() {
+ format!("{}\n", file_path_str)
+ } else if existing_content.ends_with('\n') {
+ format!("{}{}\n", existing_content, file_path_str)
+ } else {
+ format!("{}\n{}\n", existing_content, file_path_str)
+ };
+
+ fs.save(
+ &gitignore_path,
+ &text::Rope::from(new_content.as_str()),
+ text::LineEnding::Unix,
+ )
+ .await
+ }
+ RepositoryState::Remote(_) => Err(anyhow::anyhow!(
+ "Cannot modify .gitignore on remote repository"
+ )),
+ }
+ })
+ }
+
pub fn stash_drop(
&mut self,
index: Option,
diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs
index 990040ac4bf..7a2a07dd968 100644
--- a/crates/project_panel/src/project_panel.rs
+++ b/crates/project_panel/src/project_panel.rs
@@ -1119,7 +1119,7 @@ impl ProjectPanel {
|| (settings.hide_root && visible_worktrees_count == 1));
let should_show_compare = !is_dir && self.file_abs_paths_to_diff(cx).is_some();
- let has_git_repo = !is_dir && {
+ let has_git_repo = {
let project_path = project::ProjectPath {
worktree_id,
path: entry.path.clone(),
@@ -1195,15 +1195,18 @@ impl ProjectPanel {
"Copy Relative Path",
Box::new(zed_actions::workspace::CopyRelativePath),
)
- .when(!is_dir && self.has_git_changes(entry_id), |menu| {
- menu.separator().action(
- "Restore File",
- Box::new(git::RestoreFile { skip_prompt: false }),
- )
- })
.when(has_git_repo, |menu| {
menu.separator()
- .action("View File History", Box::new(git::FileHistory))
+ .when(!is_dir && self.has_git_changes(entry_id), |menu| {
+ menu.action(
+ "Restore File",
+ Box::new(git::RestoreFile { skip_prompt: false }),
+ )
+ })
+ .action("Add to .gitignore", Box::new(git::AddToGitignore))
+ .when(!is_dir, |menu| {
+ menu.action("View File History", Box::new(git::FileHistory))
+ })
})
.when(!should_hide_rename, |menu| {
menu.separator().action("Rename", Box::new(Rename))
@@ -2323,6 +2326,52 @@ impl ProjectPanel {
});
}
+ fn add_to_gitignore(
+ &mut self,
+ _: &git::AddToGitignore,
+ _window: &mut Window,
+ cx: &mut Context,
+ ) {
+ maybe!({
+ let selection = self.selection?;
+ let (_, entry) = self.selected_sub_entry(cx)?;
+ let is_dir = entry.is_dir();
+ let project = self.project.read(cx);
+
+ let project_path = project.path_for_entry(selection.entry_id, cx)?;
+
+ let git_store = project.git_store();
+ let (repository, repo_path) = git_store
+ .read(cx)
+ .repository_and_path_for_project_path(&project_path, cx)?;
+
+ let workspace = self.workspace.clone();
+ let receiver =
+ repository.update(cx, |repo, _| repo.add_path_to_gitignore(&repo_path, is_dir));
+
+ cx.spawn(async move |_, cx| {
+ if let Err(e) = receiver.await? {
+ if let Some(workspace) = workspace.upgrade() {
+ cx.update(|cx| {
+ let message = format!("Failed to add to .gitignore: {}", e);
+ let toast = StatusToast::new(message, cx, |this, _| {
+ this.icon(Icon::new(IconName::XCircle).color(Color::Error))
+ .dismiss_button(true)
+ });
+ workspace.update(cx, |workspace, cx| {
+ workspace.toggle_status_toast(toast, cx);
+ });
+ });
+ }
+ }
+ anyhow::Ok(())
+ })
+ .detach_and_log_err(cx);
+
+ Some(())
+ });
+ }
+
fn remove(
&mut self,
trash: bool,
@@ -6687,6 +6736,7 @@ impl Render for ProjectPanel {
.on_action(cx.listener(Self::paste))
.on_action(cx.listener(Self::duplicate))
.on_action(cx.listener(Self::restore_file))
+ .on_action(cx.listener(Self::add_to_gitignore))
.when(!project.is_remote(), |el| {
el.on_action(cx.listener(Self::trash))
})