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. image With the restore file option: image 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)) })