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.

<img width="380" height="391" alt="image"
src="https://github.com/user-attachments/assets/e4438fbe-b070-40c8-9e57-84b003fa5c15"
/>

With the restore file option:
<img width="382" height="408" alt="image"
src="https://github.com/user-attachments/assets/84425de8-04e5-4969-8991-edc46e6420dc"
/>

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 <chris@christopherbiscardi.com>
This commit is contained in:
Korbin de Man 2026-04-23 07:46:58 +02:00 committed by GitHub
parent debf4c9988
commit e4e656fb42
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 117 additions and 52 deletions

View file

@ -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<Buffer> = 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);

View file

@ -5822,6 +5822,55 @@ impl Repository {
})
}
pub fn add_path_to_gitignore(
&mut self,
repo_path: &RepoPath,
is_dir: bool,
) -> oneshot::Receiver<Result<()>> {
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<usize>,

View file

@ -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<Self>,
) {
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))
})