git: Use worktree paths in the panel (#25950)

This PR changes the git panel to use worktree-relative paths for its
entries, instead of repository-relative paths as before. Paths that lie
outside the active repository's worktree are no longer shown in the
panel. Note that in both respects this is how the project diff editor
already works, so this PR brings those two pieces of UI into harmony.

Release Notes:

- N/A
This commit is contained in:
Cole Miller 2025-03-03 18:32:03 -05:00 committed by GitHub
parent 6faa7cd722
commit e7b3b8bf03
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 233 additions and 96 deletions

1
Cargo.lock generated
View file

@ -5439,6 +5439,7 @@ dependencies = [
"panel",
"picker",
"postage",
"pretty_assertions",
"project",
"schemars",
"serde",

View file

@ -61,6 +61,7 @@ ctor.workspace = true
env_logger.workspace = true
editor = { workspace = true, features = ["test-support"] }
gpui = { workspace = true, features = ["test-support"] }
pretty_assertions.workspace = true
project = { workspace = true, features = ["test-support"] }
settings = { workspace = true, features = ["test-support"] }
unindent.workspace = true

View file

@ -18,7 +18,14 @@ use git::repository::{
};
use git::{repository::RepoPath, status::FileStatus, Commit, ToggleStaged};
use git::{RestoreTrackedFiles, StageAll, TrashUntrackedFiles, UnstageAll};
use gpui::*;
use gpui::{
actions, anchored, deferred, hsla, percentage, point, uniform_list, Action, Animation,
AnimationExt as _, AnyView, BoxShadow, ClickEvent, Corner, DismissEvent, Entity, EventEmitter,
FocusHandle, Focusable, KeyContext, ListHorizontalSizingBehavior, ListSizingBehavior,
Modifiers, ModifiersChangedEvent, MouseButton, MouseDownEvent, Point, PromptLevel,
ScrollStrategy, Stateful, Subscription, Task, Transformation, UniformListScrollHandle,
WeakEntity,
};
use itertools::Itertools;
use language::{Buffer, File};
use menu::{Confirm, SecondaryConfirm, SelectFirst, SelectLast, SelectNext, SelectPrevious};
@ -35,6 +42,7 @@ use settings::Settings as _;
use smallvec::smallvec;
use std::cell::RefCell;
use std::future::Future;
use std::path::Path;
use std::rc::Rc;
use std::{collections::HashSet, sync::Arc, time::Duration, usize};
use strum::{IntoEnumIterator, VariantNames};
@ -44,6 +52,7 @@ use ui::{
Scrollbar, ScrollbarState, Tooltip,
};
use util::{maybe, post_inc, ResultExt, TryFutureExt};
use workspace::AppState;
use workspace::{
dock::{DockPosition, Panel, PanelEvent},
@ -63,7 +72,12 @@ actions!(
]
);
fn prompt<T>(msg: &str, detail: Option<&str>, window: &mut Window, cx: &mut App) -> Task<Result<T>>
fn prompt<T>(
msg: &str,
detail: Option<&str>,
window: &mut Window,
cx: &mut App,
) -> Task<anyhow::Result<T>>
where
T: IntoEnumIterator + VariantNames + 'static,
{
@ -165,6 +179,7 @@ impl GitListEntry {
#[derive(Debug, PartialEq, Eq, Clone)]
pub struct GitStatusEntry {
pub(crate) repo_path: RepoPath,
pub(crate) worktree_path: Arc<Path>,
pub(crate) status: FileStatus,
pub(crate) is_staged: Option<bool>,
}
@ -262,96 +277,94 @@ pub(crate) fn commit_message_editor(
impl GitPanel {
pub fn new(
workspace: &mut Workspace,
workspace: Entity<Workspace>,
project: Entity<Project>,
app_state: Arc<AppState>,
window: &mut Window,
cx: &mut Context<Workspace>,
) -> Entity<Self> {
let fs = workspace.app_state().fs.clone();
let project = workspace.project().clone();
cx: &mut Context<Self>,
) -> Self {
let fs = app_state.fs.clone();
let git_store = project.read(cx).git_store().clone();
let active_repository = project.read(cx).active_repository(cx);
let workspace = cx.entity().downgrade();
let workspace = workspace.downgrade();
cx.new(|cx| {
let focus_handle = cx.focus_handle();
cx.on_focus(&focus_handle, window, Self::focus_in).detach();
cx.on_focus_out(&focus_handle, window, |this, _, window, cx| {
this.hide_scrollbar(window, cx);
})
.detach();
// just to let us render a placeholder editor.
// Once the active git repo is set, this buffer will be replaced.
let temporary_buffer = cx.new(|cx| Buffer::local("", cx));
let commit_editor = cx.new(|cx| {
commit_message_editor(temporary_buffer, None, project.clone(), true, window, cx)
});
commit_editor.update(cx, |editor, cx| {
editor.clear(window, cx);
});
let scroll_handle = UniformListScrollHandle::new();
cx.subscribe_in(
&git_store,
window,
move |this, git_store, event, window, cx| match event {
GitEvent::FileSystemUpdated => {
this.schedule_update(false, window, cx);
}
GitEvent::ActiveRepositoryChanged | GitEvent::GitStateUpdated => {
this.active_repository = git_store.read(cx).active_repository();
this.schedule_update(true, window, cx);
}
},
)
.detach();
let scrollbar_state =
ScrollbarState::new(scroll_handle.clone()).parent_entity(&cx.entity());
let repository_selector =
cx.new(|cx| RepositorySelector::new(project.clone(), window, cx));
let mut git_panel = Self {
pending_remote_operations: Default::default(),
remote_operation_id: 0,
active_repository,
commit_editor,
suggested_commit_message: None,
conflicted_count: 0,
conflicted_staged_count: 0,
current_modifiers: window.modifiers(),
add_coauthors: true,
entries: Vec::new(),
focus_handle: cx.focus_handle(),
fs,
hide_scrollbar_task: None,
new_count: 0,
new_staged_count: 0,
pending: Vec::new(),
pending_commit: None,
pending_serialization: Task::ready(None),
project,
repository_selector,
scroll_handle,
scrollbar_state,
selected_entry: None,
marked_entries: Vec::new(),
show_scrollbar: false,
tracked_count: 0,
tracked_staged_count: 0,
update_visible_entries_task: Task::ready(()),
width: Some(px(360.)),
context_menu: None,
workspace,
modal_open: false,
};
git_panel.schedule_update(false, window, cx);
git_panel.show_scrollbar = git_panel.should_show_scrollbar(cx);
git_panel
let focus_handle = cx.focus_handle();
cx.on_focus(&focus_handle, window, Self::focus_in).detach();
cx.on_focus_out(&focus_handle, window, |this, _, window, cx| {
this.hide_scrollbar(window, cx);
})
.detach();
// just to let us render a placeholder editor.
// Once the active git repo is set, this buffer will be replaced.
let temporary_buffer = cx.new(|cx| Buffer::local("", cx));
let commit_editor = cx.new(|cx| {
commit_message_editor(temporary_buffer, None, project.clone(), true, window, cx)
});
commit_editor.update(cx, |editor, cx| {
editor.clear(window, cx);
});
let scroll_handle = UniformListScrollHandle::new();
cx.subscribe_in(
&git_store,
window,
move |this, git_store, event, window, cx| match event {
GitEvent::FileSystemUpdated => {
this.schedule_update(false, window, cx);
}
GitEvent::ActiveRepositoryChanged | GitEvent::GitStateUpdated => {
this.active_repository = git_store.read(cx).active_repository();
this.schedule_update(true, window, cx);
}
},
)
.detach();
let scrollbar_state =
ScrollbarState::new(scroll_handle.clone()).parent_entity(&cx.entity());
let repository_selector = cx.new(|cx| RepositorySelector::new(project.clone(), window, cx));
let mut git_panel = Self {
pending_remote_operations: Default::default(),
remote_operation_id: 0,
active_repository,
commit_editor,
suggested_commit_message: None,
conflicted_count: 0,
conflicted_staged_count: 0,
current_modifiers: window.modifiers(),
add_coauthors: true,
entries: Vec::new(),
focus_handle: cx.focus_handle(),
fs,
hide_scrollbar_task: None,
new_count: 0,
new_staged_count: 0,
pending: Vec::new(),
pending_commit: None,
pending_serialization: Task::ready(None),
project,
repository_selector,
scroll_handle,
scrollbar_state,
selected_entry: None,
marked_entries: Vec::new(),
show_scrollbar: false,
tracked_count: 0,
tracked_staged_count: 0,
update_visible_entries_task: Task::ready(()),
width: Some(px(360.)),
context_menu: None,
workspace,
modal_open: false,
};
git_panel.schedule_update(false, window, cx);
git_panel.show_scrollbar = git_panel.should_show_scrollbar(cx);
git_panel
}
pub fn entry_by_path(&self, path: &RepoPath) -> Option<usize> {
@ -1457,7 +1470,7 @@ impl GitPanel {
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) -> impl Future<Output = Result<Option<Remote>>> {
) -> impl Future<Output = anyhow::Result<Option<Remote>>> {
let repo = self.active_repository.clone();
let workspace = self.workspace.clone();
let mut cx = window.to_async(cx);
@ -1694,10 +1707,8 @@ impl GitPanel {
return;
};
// First pass - collect all paths
let repo = repo.read(cx);
// Second pass - create entries with proper depth calculation
for entry in repo.status() {
let is_conflict = repo.has_conflict(&entry.repo_path);
let is_new = entry.status.is_created();
@ -1711,8 +1722,12 @@ impl GitPanel {
continue;
}
let Some(worktree_path) = repo.repository_entry.unrelativize(&entry.repo_path) else {
continue;
};
let entry = GitStatusEntry {
repo_path: entry.repo_path.clone(),
worktree_path,
status: entry.status,
is_staged,
};
@ -2363,7 +2378,7 @@ impl GitPanel {
&self,
sha: &str,
cx: &mut Context<Self>,
) -> Task<Result<CommitDetails>> {
) -> Task<anyhow::Result<CommitDetails>> {
let Some(repo) = self.active_repository.clone() else {
return Task::ready(Err(anyhow::anyhow!("no active repo")));
};
@ -2448,12 +2463,12 @@ impl GitPanel {
cx: &Context<Self>,
) -> AnyElement {
let display_name = entry
.repo_path
.worktree_path
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| entry.repo_path.to_string_lossy().into_owned());
.unwrap_or_else(|| entry.worktree_path.to_string_lossy().into_owned());
let repo_path = entry.repo_path.clone();
let worktree_path = entry.worktree_path.clone();
let selected = self.selected_entry == Some(ix);
let marked = self.marked_entries.contains(&ix);
let status_style = GitPanelSettings::get_global(cx).status_style;
@ -2619,7 +2634,7 @@ impl GitPanel {
h_flex()
.items_center()
.overflow_hidden()
.when_some(repo_path.parent(), |this, parent| {
.when_some(worktree_path.parent(), |this, parent| {
let parent_str = parent.to_string_lossy();
if !parent_str.is_empty() {
this.child(
@ -3667,3 +3682,119 @@ impl ComponentPreview for PanelRepoFooter {
.into_any_element()
}
}
#[cfg(test)]
mod tests {
use git::status::StatusCode;
use gpui::TestAppContext;
use project::{FakeFs, WorktreeSettings};
use serde_json::json;
use settings::SettingsStore;
use theme::LoadThemes;
use util::path;
use super::*;
fn init_test(cx: &mut gpui::TestAppContext) {
if std::env::var("RUST_LOG").is_ok() {
env_logger::try_init().ok();
}
cx.update(|cx| {
let settings_store = SettingsStore::test(cx);
cx.set_global(settings_store);
WorktreeSettings::register(cx);
workspace::init_settings(cx);
theme::init(LoadThemes::JustBase, cx);
language::init(cx);
editor::init(cx);
Project::init_settings(cx);
crate::init(cx);
});
}
#[gpui::test]
async fn test_entry_worktree_paths(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.background_executor.clone());
fs.insert_tree(
"/root",
json!({
"zed": {
".git": {},
"crates": {
"gpui": {
"gpui.rs": "fn main() {}"
},
"util": {
"util.rs": "fn do_it() {}"
}
}
},
}),
)
.await;
fs.set_status_for_repo_via_git_operation(
Path::new("/root/zed/.git"),
&[
(
Path::new("crates/gpui/gpui.rs"),
StatusCode::Modified.worktree(),
),
(
Path::new("crates/util/util.rs"),
StatusCode::Modified.worktree(),
),
],
);
let project =
Project::test(fs.clone(), [path!("/root/zed/crates/gpui").as_ref()], cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
cx.read(|cx| {
project
.read(cx)
.worktrees(cx)
.nth(0)
.unwrap()
.read(cx)
.as_local()
.unwrap()
.scan_complete()
})
.await;
cx.executor().run_until_parked();
let app_state = workspace.update(cx, |workspace, _| workspace.app_state().clone());
let panel = cx.new_window_entity(|window, cx| {
GitPanel::new(workspace, project, app_state, window, cx)
});
let handle = cx.update_window_entity(&panel, |panel, window, cx| {
panel.schedule_update(false, window, cx);
std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
});
cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
handle.await;
let entries = panel.update(cx, |panel, _| panel.entries.clone());
pretty_assertions::assert_eq!(
entries,
[
GitListEntry::Header(GitHeaderEntry {
header: Section::Tracked
}),
GitListEntry::GitStatusEntry(GitStatusEntry {
repo_path: "crates/gpui/gpui.rs".into(),
worktree_path: Path::new("gpui.rs").into(),
status: StatusCode::Modified.worktree(),
is_staged: Some(false),
})
],
)
}
}

View file

@ -22,6 +22,7 @@ use editor::ProposedChangesEditorToolbar;
use editor::{scroll::Autoscroll, Editor, MultiBuffer};
use feature_flags::{FeatureFlagAppExt, FeatureFlagViewExt, GitUiFeatureFlag};
use futures::{channel::mpsc, select_biased, StreamExt};
use git_ui::git_panel::GitPanel;
use git_ui::project_diff::ProjectDiffToolbar;
use gpui::{
actions, point, px, Action, App, AppContext as _, AsyncApp, Context, DismissEvent, Element,
@ -429,7 +430,10 @@ fn initialize_panels(
workspace.add_panel(chat_panel, window, cx);
workspace.add_panel(notification_panel, window, cx);
cx.when_flag_enabled::<GitUiFeatureFlag>(window, |workspace, window, cx| {
let git_panel = git_ui::git_panel::GitPanel::new(workspace, window, cx);
let entity = cx.entity();
let project = workspace.project().clone();
let app_state = workspace.app_state().clone();
let git_panel = cx.new(|cx| GitPanel::new(entity, project, app_state, window, cx));
workspace.add_panel(git_panel, window, cx);
});
})?;