From 9abfeab186fa092d3d8bd53d718e48fc5ee7f6ab Mon Sep 17 00:00:00 2001 From: Mikhail Pertsev Date: Tue, 2 Jun 2026 21:13:17 +0200 Subject: [PATCH] git_ui: Move `git_graph` into `git_ui` (#57503) cc @Anthony-Eid ## Why This is the first step in moving the Git Graph work into the Git UI crate before continuing with follow-up refactors and feature work. The goal is for Git UI components and shared Git UI helpers to live in one crate, so future changes to the Git Graph can reuse existing `git_ui` code instead of duplicating it. This PR is not only a filesystem move. While moving `git_graph` into `git_ui`, a few small dependency and helper boundaries had to change: - `git_graph` and `git_ui` both needed the same remote parsing and commit tooltip construction behavior, so those pieces are now shared from `git_ui`. - `git_graph` previously depended on `project_panel` to resolve file-history actions from the project panel selection. After moving `git_graph` into `git_ui`, keeping that dependency would create an undesirable `git_ui` -> `project_panel` relationship. The project-panel-specific action forwarding now lives in `project_panel`, and calls into exported `git_ui::git_graph` helpers instead. - `git_graph` initialization now happens through `git_ui::init`, so downstream crates only need to initialize `git_ui`. This prepares the codebase for the next planned PRs: splitting the large `git_graph.rs` implementation into smaller pieces, then adding Git Graph features such as keeping the main branch lane at index `0`. ## License removal The removed `crates/git_graph/LICENSE-GPL` file was a symlink to the repository root `LICENSE-GPL`. The moved code is now inside `git_ui`, which is also licensed as `GPL-3.0-or-later` and has its own `LICENSE-GPL` symlink to the same root license file. The code did not move to a differently licensed crate; it remains covered by the same GPL license. Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A --------- Co-authored-by: Anthony Eid --- Cargo.lock | 39 +-- Cargo.toml | 2 - crates/collab/Cargo.toml | 1 - crates/collab/tests/integration/git_tests.rs | 6 +- crates/git_graph/Cargo.toml | 60 ---- crates/git_graph/LICENSE-GPL | 1 - crates/git_ui/Cargo.toml | 7 + crates/git_ui/src/commit_view.rs | 2 +- crates/{git_graph => git_ui}/src/git_graph.rs | 241 +++++++-------- crates/git_ui/src/git_panel.rs | 23 +- crates/git_ui/src/git_ui.rs | 2 + crates/project_panel/Cargo.toml | 1 + crates/project_panel/src/project_panel.rs | 44 +++ .../project_panel/src/project_panel_tests.rs | 276 +++++++++++++++++- crates/zed/Cargo.toml | 1 - crates/zed/src/main.rs | 1 - crates/zed/src/zed.rs | 1 - 17 files changed, 451 insertions(+), 257 deletions(-) delete mode 100644 crates/git_graph/Cargo.toml delete mode 120000 crates/git_graph/LICENSE-GPL rename crates/{git_graph => git_ui}/src/git_graph.rs (97%) diff --git a/Cargo.lock b/Cargo.lock index d84cdc4cb2..1ec44169d5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3373,7 +3373,6 @@ dependencies = [ "fs", "futures 0.3.32", "git", - "git_graph", "git_hosting_providers", "git_ui", "gpui", @@ -7279,40 +7278,6 @@ dependencies = [ "ztracing", ] -[[package]] -name = "git_graph" -version = "0.1.0" -dependencies = [ - "anyhow", - "async-channel 2.5.0", - "collections", - "db", - "editor", - "fs", - "git", - "git_ui", - "gpui", - "language", - "language_model", - "menu", - "picker", - "project", - "project_panel", - "rand 0.9.4", - "release_channel", - "remote_connection", - "search", - "serde_json", - "settings", - "smallvec", - "task", - "theme", - "theme_settings", - "time", - "ui", - "workspace", -] - [[package]] name = "git_hosting_providers" version = "0.1.0" @@ -7342,6 +7307,7 @@ dependencies = [ "agent_settings", "anyhow", "askpass", + "async-channel 2.5.0", "buffer_diff", "call", "collections", @@ -7374,9 +7340,11 @@ dependencies = [ "prompt_store", "proto", "rand 0.9.4", + "release_channel", "remote", "remote_connection", "schemars 1.0.4", + "search", "serde", "serde_json", "settings", @@ -22887,7 +22855,6 @@ dependencies = [ "fs", "futures 0.3.32", "git", - "git_graph", "git_hosting_providers", "git_ui", "go_to_line", diff --git a/Cargo.toml b/Cargo.toml index a293adfb82..2d448c1b6f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -83,7 +83,6 @@ members = [ "crates/fuzzy", "crates/fuzzy_nucleo", "crates/git", - "crates/git_graph", "crates/git_hosting_providers", "crates/git_ui", "crates/go_to_line", @@ -339,7 +338,6 @@ fs = { path = "crates/fs" } fuzzy = { path = "crates/fuzzy" } fuzzy_nucleo = { path = "crates/fuzzy_nucleo" } git = { path = "crates/git" } -git_graph = { path = "crates/git_graph" } git_hosting_providers = { path = "crates/git_hosting_providers" } git_ui = { path = "crates/git_ui" } go_to_line = { path = "crates/go_to_line" } diff --git a/crates/collab/Cargo.toml b/crates/collab/Cargo.toml index 67403f7b59..9ad0949e15 100644 --- a/crates/collab/Cargo.toml +++ b/crates/collab/Cargo.toml @@ -97,7 +97,6 @@ extension.workspace = true file_finder.workspace = true fs = { workspace = true, features = ["test-support"] } git = { workspace = true, features = ["test-support"] } -git_graph = { workspace = true, features = ["test-support"] } git_hosting_providers.workspace = true git_ui = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } diff --git a/crates/collab/tests/integration/git_tests.rs b/crates/collab/tests/integration/git_tests.rs index 26faeb7c5f..d5f71085d5 100644 --- a/crates/collab/tests/integration/git_tests.rs +++ b/crates/collab/tests/integration/git_tests.rs @@ -11,7 +11,7 @@ use git::{ repository::{CommitData, InitialGraphCommitData, RepoPath, Worktree as GitWorktree}, status::{DiffStat, FileStatus, StatusCode, TrackedStatus}, }; -use git_graph::GitGraph; +use git_ui::git_graph::GitGraph; use git_ui::{git_panel::GitPanel, project_diff::ProjectDiff}; use gpui::{ AppContext as _, BackgroundExecutor, Entity, IntoElement as _, SharedString, TestAppContext, @@ -763,11 +763,9 @@ async fn test_remote_git_graph_data_and_search( .await; cx_a.update(|cx| { git_ui::init(cx); - git_graph::init(cx); }); cx_b.update(|cx| { git_ui::init(cx); - git_graph::init(cx); }); let active_call_a = cx_a.read(ActiveCall::global); @@ -781,7 +779,7 @@ async fn test_remote_git_graph_data_and_search( let search_query = "graph search match"; let mut rng = StdRng::seed_from_u64(7); - let commits = git_graph::generate_random_commit_dag(&mut rng, 12, true); + let commits = git_ui::git_graph::generate_random_commit_dag(&mut rng, 12, true); let dot_git = Path::new(path!("/project/.git")); client_a.fs().set_graph_commits(dot_git, commits.clone()); diff --git a/crates/git_graph/Cargo.toml b/crates/git_graph/Cargo.toml deleted file mode 100644 index 2922453c46..0000000000 --- a/crates/git_graph/Cargo.toml +++ /dev/null @@ -1,60 +0,0 @@ -[package] -name = "git_graph" -version = "0.1.0" -edition.workspace = true -publish.workspace = true -license = "GPL-3.0-or-later" - -[lints] -workspace = true - -[lib] -path = "src/git_graph.rs" - -[features] -default = [] -test-support = [ - "dep:rand", - "project/test-support", - "gpui/test-support", - "remote_connection/test-support", -] - -[dependencies] -anyhow.workspace = true -async-channel.workspace = true -collections.workspace = true -db.workspace = true -editor.workspace = true -git.workspace = true -git_ui.workspace = true -gpui.workspace = true -language.workspace = true -menu.workspace = true -picker.workspace = true -project.workspace = true -project_panel.workspace = true -rand = { workspace = true, optional = true } -release_channel.workspace = true -search.workspace = true -settings.workspace = true -smallvec.workspace = true -task.workspace = true -theme.workspace = true -theme_settings.workspace = true -time.workspace = true -ui.workspace = true -workspace.workspace = true - -[dev-dependencies] -db = { workspace = true, features = ["test-support"] } -fs = { workspace = true, features = ["test-support"] } -git = { workspace = true, features = ["test-support"] } -gpui = { workspace = true, features = ["test-support"] } -language_model.workspace = true -project = { workspace = true, features = ["test-support"] } -rand.workspace = true -remote_connection = { workspace = true, features = ["test-support"] } -serde_json.workspace = true -settings = { workspace = true, features = ["test-support"] } -workspace = { workspace = true, features = ["test-support"] } diff --git a/crates/git_graph/LICENSE-GPL b/crates/git_graph/LICENSE-GPL deleted file mode 120000 index 89e542f750..0000000000 --- a/crates/git_graph/LICENSE-GPL +++ /dev/null @@ -1 +0,0 @@ -../../LICENSE-GPL \ No newline at end of file diff --git a/crates/git_ui/Cargo.toml b/crates/git_ui/Cargo.toml index 3273277c79..829ba4f9d8 100644 --- a/crates/git_ui/Cargo.toml +++ b/crates/git_ui/Cargo.toml @@ -19,6 +19,7 @@ test-support = ["multi_buffer/test-support", "remote_connection/test-support"] agent_settings.workspace = true anyhow.workspace = true askpass.workspace = true +async-channel.workspace = true buffer_diff.workspace = true call.workspace = true collections.workspace = true @@ -48,15 +49,18 @@ project.workspace = true prompt_store.workspace = true proto.workspace = true rand.workspace = true +release_channel.workspace = true remote_connection.workspace = true remote.workspace = true schemars.workspace = true +search.workspace = true serde.workspace = true serde_json.workspace = true settings.workspace = true smallvec.workspace = true strum.workspace = true sysinfo.workspace = true +task.workspace = true telemetry.workspace = true terminal.workspace = true theme.workspace = true @@ -78,7 +82,10 @@ windows.workspace = true [dev-dependencies] ctor.workspace = true +db = { workspace = true, features = ["test-support"] } editor = { workspace = true, features = ["test-support"] } +fs = { workspace = true, features = ["test-support"] } +git = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } indoc.workspace = true pretty_assertions.workspace = true diff --git a/crates/git_ui/src/commit_view.rs b/crates/git_ui/src/commit_view.rs index 247c98c569..cb3ae8b348 100644 --- a/crates/git_ui/src/commit_view.rs +++ b/crates/git_ui/src/commit_view.rs @@ -1306,7 +1306,7 @@ impl Render for CommitViewToolbar { .tooltip(Tooltip::text("Show in Git Graph")) .on_click(move |_, window, cx| { window.dispatch_action( - Box::new(crate::git_panel::OpenAtCommit { + Box::new(crate::git_graph::OpenAtCommit { sha: sha_for_graph.clone(), }), cx, diff --git a/crates/git_graph/src/git_graph.rs b/crates/git_ui/src/git_graph.rs similarity index 97% rename from crates/git_graph/src/git_graph.rs rename to crates/git_ui/src/git_graph.rs index e176a34e2b..62685586bf 100644 --- a/crates/git_graph/src/git_graph.rs +++ b/crates/git_ui/src/git_graph.rs @@ -1,3 +1,8 @@ +use crate::{ + commit_tooltip::{CommitAvatar, CommitDetails, CommitTooltip}, + commit_view::CommitView, + git_status_icon, +}; use collections::{BTreeMap, HashMap, IndexSet}; use editor::Editor; use git::{ @@ -10,11 +15,6 @@ use git::{ }, status::{FileStatus, StatusCode, TrackedStatus}, }; -use git_ui::{ - commit_tooltip::{CommitAvatar, CommitDetails, CommitTooltip}, - commit_view::CommitView, - git_status_icon, -}; use gpui::{ Action, Anchor, AnyElement, App, Bounds, ClickEvent, ClipboardItem, DefiniteLength, DismissEvent, DragMoveEvent, ElementId, Empty, Entity, EventEmitter, FocusHandle, Focusable, @@ -33,7 +33,6 @@ use project::{ RepositoryEvent, RepositoryId, }, }; -use project_panel::ProjectPanel; use search::{ SearchOption, SearchOptions, SearchSource, SelectNextMatch, SelectPreviousMatch, ToggleCaseSensitive, buffer_search, @@ -340,24 +339,24 @@ struct SearchState { case_sensitive: bool, editor: Entity, state: QueryState, - pub matches: IndexSet, - pub selected_index: Option, + matches: IndexSet, + selected_index: Option, } -pub struct SplitState { +struct SplitState { left_ratio: f32, visible_left_ratio: f32, } impl SplitState { - pub fn new() -> Self { + fn new() -> Self { Self { left_ratio: 1.0, visible_left_ratio: 1.0, } } - pub fn right_ratio(&self) -> f32 { + fn right_ratio(&self) -> f32 { 1.0 - self.visible_left_ratio } @@ -391,6 +390,8 @@ impl SplitState { actions!( git_graph, [ + /// Opens the Git Graph Tab. + Open, /// Copies the SHA of the selected commit to the clipboard. CopyCommitSha, /// Copies a tag from the selected commit to the clipboard. @@ -410,6 +411,13 @@ actions!( ] ); +/// Opens the Git Graph Tab at a specific commit. +#[derive(Clone, PartialEq, serde::Deserialize, schemars::JsonSchema, gpui::Action)] +#[action(namespace = git_graph)] +pub struct OpenAtCommit { + pub sha: String, +} + fn timestamp_format() -> &'static [BorrowedFormatItem<'static>] { static FORMAT: OnceLock>> = OnceLock::new(); FORMAT.get_or_init(|| { @@ -893,7 +901,7 @@ pub fn init(cx: &mut App) { div.on_action({ let workspace = workspace.clone(); - move |_: &git_ui::git_panel::Open, window, cx| { + move |_: &Open, window, cx| { workspace .update(cx, |workspace, cx| { let Some(repo) = @@ -918,33 +926,29 @@ pub fn init(cx: &mut App) { .ok(); } }) - .on_action( - move |action: &git_ui::git_panel::OpenAtCommit, window, cx| { - let sha = action.sha.clone(); - workspace - .update(cx, |workspace, cx| { - let Some(repo) = - workspace.project().read(cx).active_repository(cx) - else { - return; - }; - let selected_repo_id = repo.read(cx).id; + .on_action(move |action: &OpenAtCommit, window, cx| { + let sha = action.sha.clone(); + workspace + .update(cx, |workspace, cx| { + let Some(repo) = workspace.project().read(cx).active_repository(cx) + else { + return; + }; + let selected_repo_id = repo.read(cx).id; - let git_store = - workspace.project().read(cx).git_store().clone(); - open_or_reuse_graph( - workspace, - selected_repo_id, - git_store, - LogSource::All, - Some(sha), - window, - cx, - ); - }) - .ok(); - }, - ) + let git_store = workspace.project().read(cx).git_store().clone(); + open_or_reuse_graph( + workspace, + selected_repo_id, + git_store, + LogSource::All, + Some(sha), + window, + cx, + ); + }) + .ok(); + }) }, ) }); @@ -952,28 +956,32 @@ pub fn init(cx: &mut App) { .detach(); } +/// Resolves a `git::FileHistory` target from a known project path (used by +/// callers like `project_panel` that own a focused selection but cannot be +/// referenced from this module due to dependency direction). +pub fn resolve_file_history_target_from_project_path( + workspace: &Workspace, + project_path: &ProjectPath, + cx: &App, +) -> Option<(RepositoryId, LogSource)> { + let git_store = workspace.project().read(cx).git_store(); + let (repo, repo_path) = git_store + .read(cx) + .repository_and_path_for_project_path(project_path, cx)?; + let log_source = if repo_path.is_empty() { + LogSource::All + } else { + LogSource::Path(repo_path) + }; + Some((repo.read(cx).id, log_source)) +} + fn resolve_file_history_target( workspace: &Workspace, window: &Window, cx: &App, ) -> Option<(RepositoryId, LogSource)> { - if let Some(panel) = workspace.panel::(cx) - && panel.read(cx).focus_handle(cx).contains_focused(window, cx) - && let Some(project_path) = panel.read(cx).selected_entry_project_path(cx) - { - let git_store = workspace.project().read(cx).git_store(); - let (repo, repo_path) = git_store - .read(cx) - .repository_and_path_for_project_path(&project_path, cx)?; - let log_source = if repo_path.is_empty() { - LogSource::All - } else { - LogSource::Path(repo_path) - }; - return Some((repo.read(cx).id, log_source)); - } - - if let Some(panel) = workspace.panel::(cx) + if let Some(panel) = workspace.panel::(cx) && panel.read(cx).focus_handle(cx).contains_focused(window, cx) && let Some((repository, repo_path)) = panel.read(cx).selected_file_history_target() { @@ -997,7 +1005,7 @@ fn resolve_file_history_target( Some((repo.read(cx).id, LogSource::Path(repo_path))) } -fn open_or_reuse_graph( +pub fn open_or_reuse_graph( workspace: &mut Workspace, repo_id: RepositoryId, git_store: Entity, @@ -2314,22 +2322,6 @@ impl GitGraph { cx.notify(); } - fn get_remote( - &self, - repository: &Repository, - _window: &mut Window, - cx: &mut App, - ) -> Option { - let remote_url = repository.default_remote_url()?; - let provider_registry = GitHostingProviderRegistry::default_global(cx); - let (provider, parsed) = parse_git_remote_url(provider_registry, &remote_url)?; - Some(GitRemote { - host: provider, - owner: parsed.owner.into(), - repo: parsed.repo.into(), - }) - } - fn render_search_bar(&self, cx: &mut Context) -> impl IntoElement { let color = cx.theme().colors(); let query_focus_handle = self @@ -2536,7 +2528,16 @@ impl GitGraph { }) .unwrap_or_default(); - let remote = repository.update(cx, |repo, cx| self.get_remote(repo, window, cx)); + let remote = repository.update(cx, |repo, cx| { + let remote_url = repo.default_remote_url()?; + let provider_registry = GitHostingProviderRegistry::default_global(cx); + let (provider, parsed) = parse_git_remote_url(provider_registry, &remote_url)?; + Some(GitRemote { + host: provider, + owner: parsed.owner.into(), + repo: parsed.repo.into(), + }) + }); let avatar = { let author_email_for_avatar = if author_email.is_empty() { @@ -2733,7 +2734,7 @@ impl GitGraph { }) .when_some(remote.clone(), |this, remote| { let provider_name = remote.host.name(); - let icon = git_ui::get_provider_icon(provider_name.as_str()); + let icon = crate::get_provider_icon(provider_name.as_str()); let parsed_remote = ParsedGitRemote { owner: remote.owner.as_ref().into(), repo: remote.repo.as_ref().into(), @@ -4084,6 +4085,11 @@ impl GitGraph { .map(|commit| commit.data.clone()) .collect() } + + #[cfg(any(test, feature = "test-support"))] + pub fn log_source_for_test(&self) -> &LogSource { + &self.log_source + } } /// Generates a random commit DAG suitable for testing git graph rendering. @@ -4219,9 +4225,7 @@ mod tests { cx.set_global(settings_store); theme_settings::init(theme::LoadThemes::JustBase, cx); language_model::init(cx); - git_ui::init(cx); - project_panel::init(cx); - init(cx); + crate::init(cx); }); } @@ -4794,24 +4798,14 @@ mod tests { }); repository.update(cx, |repo, cx| { - repo.graph_data( - crate::LogSource::default(), - crate::LogOrder::default(), - 0..usize::MAX, - cx, - ); + repo.graph_data(LogSource::default(), LogOrder::default(), 0..usize::MAX, cx); }); cx.run_until_parked(); let graph_commits: Vec> = repository.update(cx, |repo, cx| { - repo.graph_data( - crate::LogSource::default(), - crate::LogOrder::default(), - 0..usize::MAX, - cx, - ) - .commits - .to_vec() + repo.graph_data(LogSource::default(), LogOrder::default(), 0..usize::MAX, cx) + .commits + .to_vec() }); let mut graph_data = GraphData::new(8); @@ -4865,12 +4859,7 @@ mod tests { }); repository.update(cx, |repo, cx| { - repo.graph_data( - crate::LogSource::default(), - crate::LogOrder::default(), - 0..usize::MAX, - cx, - ); + repo.graph_data(LogSource::default(), LogOrder::default(), 0..usize::MAX, cx); }); project @@ -4888,7 +4877,7 @@ mod tests { "initial repository scan should emit HeadChanged" ); let commit_count_after = repository.read_with(cx, |repo, _| { - repo.get_graph_data(crate::LogSource::default(), crate::LogOrder::default()) + repo.get_graph_data(LogSource::default(), LogOrder::default()) .map(|data| data.commit_data.len()) .unwrap() }); @@ -4927,18 +4916,13 @@ mod tests { }); repository.update(cx, |repo, cx| { - repo.graph_data( - crate::LogSource::default(), - crate::LogOrder::default(), - 0..usize::MAX, - cx, - ); + repo.graph_data(LogSource::default(), LogOrder::default(), 0..usize::MAX, cx); }); cx.run_until_parked(); let error = repository.read_with(cx, |repo, _| { - repo.get_graph_data(crate::LogSource::default(), crate::LogOrder::default()) + repo.get_graph_data(LogSource::default(), LogOrder::default()) .and_then(|data| data.error.clone()) }); @@ -5070,14 +5054,12 @@ mod tests { } #[gpui::test] - async fn test_file_history_action_uses_focused_source_and_reuses_matching_graph( - cx: &mut TestAppContext, - ) { + async fn test_file_history_action_uses_git_panel_and_editor_sources(cx: &mut TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( - Path::new("/project"), + Path::new(util::path!("/project")), json!({ ".git": {}, "tracked1.txt": "tracked 1", @@ -5085,15 +5067,22 @@ mod tests { }), ) .await; + fs.set_status_for_repo( + Path::new(util::path!("/project/.git")), + &[ + ("tracked1.txt", StatusCode::Modified.worktree()), + ("tracked2.txt", StatusCode::Modified.worktree()), + ], + ); let commits = vec![Arc::new(InitialGraphCommitData { sha: Oid::from_bytes(&[1; 20]).unwrap(), parents: smallvec![], ref_names: vec!["HEAD".into(), "refs/heads/main".into()], })]; - fs.set_graph_commits(Path::new("/project/.git"), commits); + fs.set_graph_commits(Path::new(util::path!("/project/.git")), commits); - let project = Project::test(fs.clone(), [Path::new("/project")], cx).await; + let project = Project::test(fs.clone(), [Path::new(util::path!("/project"))], cx).await; cx.run_until_parked(); let repository = project.read_with(cx, |project, cx| { @@ -5127,18 +5116,10 @@ mod tests { }) .expect("window should be available"); cx.background_executor.allow_parking(); - let project_panel = cx - .foreground_executor() - .clone() - .block_test(ProjectPanel::load( - weak_workspace.clone(), - async_window_cx.clone(), - )) - .expect("project panel should load"); let git_panel = cx .foreground_executor() .clone() - .block_test(git_ui::git_panel::GitPanel::load( + .block_test(crate::git_panel::GitPanel::load( weak_workspace, async_window_cx, )) @@ -5149,21 +5130,20 @@ mod tests { .update(cx, |multi, window, cx| { let workspace = multi.workspace(); workspace.update(cx, |workspace, cx| { - workspace.add_panel(project_panel.clone(), window, cx); workspace.add_panel(git_panel.clone(), window, cx); }); }) .expect("workspace window should be available"); + cx.executor().advance_clock(Duration::from_millis(100)); cx.run_until_parked(); workspace_window - .update(cx, |multi, window, cx| { - let workspace = multi.workspace(); - project_panel.update(cx, |panel, cx| { - panel.select_path_for_test(tracked1.clone(), cx) + .update(cx, |_, window, cx| { + git_panel.update(cx, |panel, cx| { + panel.select_entry_by_path(tracked1.clone(), window, cx); }); - workspace.update(cx, |workspace, cx| { - workspace.focus_panel::(window, cx); + git_panel.update(cx, |panel, cx| { + panel.focus_handle(cx).focus(window, cx); }); }) .expect("workspace window should be available"); @@ -5185,13 +5165,12 @@ mod tests { }); workspace_window - .update(cx, |multi, window, cx| { - let workspace = multi.workspace(); + .update(cx, |_, window, cx| { git_panel.update(cx, |panel, cx| { panel.select_entry_by_path(tracked1.clone(), window, cx); }); - workspace.update(cx, |workspace, cx| { - workspace.focus_panel::(window, cx); + git_panel.update(cx, |panel, cx| { + panel.focus_handle(cx).focus(window, cx); }); }) .expect("workspace window should be available"); diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 194b531e91..70a9f7bb56 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -134,21 +134,6 @@ actions!( ] ); -actions!( - git_graph, - [ - /// Opens the Git Graph Tab. - Open, - ] -); - -/// Opens the Git Graph Tab at a specific commit. -#[derive(Clone, PartialEq, serde::Deserialize, schemars::JsonSchema, gpui::Action)] -#[action(namespace = git_graph)] -pub struct OpenAtCommit { - pub sha: String, -} - fn prompt( msg: &str, detail: Option<&str>, @@ -5027,10 +5012,14 @@ impl GitPanel { IconButton::new("git-graph-button", IconName::GitGraph) .icon_size(IconSize::Small) .tooltip(|_window, cx| { - Tooltip::for_action("Open Git Graph", &Open, cx) + Tooltip::for_action( + "Open Git Graph", + &crate::git_graph::Open, + cx, + ) }) .on_click(|_, window, cx| { - window.dispatch_action(Open.boxed_clone(), cx) + window.dispatch_action(crate::git_graph::Open.boxed_clone(), cx) }), ), ), diff --git a/crates/git_ui/src/git_ui.rs b/crates/git_ui/src/git_ui.rs index d19ac55206..44eb2a2204 100644 --- a/crates/git_ui/src/git_ui.rs +++ b/crates/git_ui/src/git_ui.rs @@ -36,6 +36,7 @@ pub mod commit_tooltip; pub mod commit_view; mod conflict_view; pub mod file_diff_view; +pub mod git_graph; pub mod git_panel; mod git_panel_settings; pub mod git_picker; @@ -69,6 +70,7 @@ pub fn get_provider_icon(name: &str) -> IconName { pub fn init(cx: &mut App) { editor::set_blame_renderer(blame_ui::GitBlameRenderer, cx); commit_view::init(cx); + git_graph::init(cx); cx.observe_new(|editor: &mut Editor, _, cx| { conflict_view::register_editor(editor, editor.buffer().clone(), cx); diff --git a/crates/project_panel/Cargo.toml b/crates/project_panel/Cargo.toml index 62ebe3eb9f..d20b09107d 100644 --- a/crates/project_panel/Cargo.toml +++ b/crates/project_panel/Cargo.toml @@ -55,6 +55,7 @@ fs.workspace = true client = { workspace = true, features = ["test-support"] } criterion.workspace = true editor = { workspace = true, features = ["test-support"] } +git_ui = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } language = { workspace = true, features = ["test-support"] } remote_connection = { workspace = true, features = ["test-support"] } diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 8cce8ab442..3cd6285258 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -527,6 +527,50 @@ pub fn init(cx: &mut App) { panel.update(cx, |panel, cx| panel.delete(action, window, cx)); } }); + + // Forwards `git::FileHistory` to `git_ui::git_graph` when the project + // panel is the focused source of selection. Lives here (and not in + // `git_ui`) so that `git_ui` does not need to depend on + // `project_panel`, which would create a dependency cycle. + workspace.register_action_renderer(|div, workspace, window, cx| { + let Some(panel) = workspace.panel::(cx) else { + return div; + }; + if !panel.read(cx).focus_handle(cx).contains_focused(window, cx) { + return div; + } + if panel.read(cx).selected_entry_project_path(cx).is_none() { + return div; + } + let workspace = workspace.weak_handle(); + div.capture_action(move |_: &git::FileHistory, window, cx| { + workspace + .update(cx, |workspace, cx| { + let Some(panel) = workspace.panel::(cx) else { + return; + }; + let Some(project_path) = panel.read(cx).selected_entry_project_path(cx) + else { + return; + }; + let Some((repo_id, log_source)) = + git_ui::git_graph::resolve_file_history_target_from_project_path( + workspace, + &project_path, + cx, + ) + else { + return; + }; + let git_store = workspace.project().read(cx).git_store().clone(); + git_ui::git_graph::open_or_reuse_graph( + workspace, repo_id, git_store, log_source, None, window, cx, + ); + }) + .log_err(); + cx.stop_propagation(); + }) + }); }) .detach(); } diff --git a/crates/project_panel/src/project_panel_tests.rs b/crates/project_panel/src/project_panel_tests.rs index 448f5183b2..8a745af785 100644 --- a/crates/project_panel/src/project_panel_tests.rs +++ b/crates/project_panel/src/project_panel_tests.rs @@ -1,13 +1,18 @@ use super::*; // use crate::undo::tests::{build_create_operation, build_rename_operation}; use collections::HashSet; -use editor::MultiBufferOffset; +use editor::{Editor, MultiBufferOffset}; +use git::{ + Oid, + repository::{InitialGraphCommitData, LogSource, RepoPath}, +}; use gpui::{Empty, Entity, TestAppContext, VisualTestContext}; use menu::Cancel; use pretty_assertions::assert_eq; use project::{FakeFs, ProjectPath}; use serde_json::json; use settings::{ProjectPanelAutoOpenSettings, SettingsStore}; +use smallvec::smallvec; use std::path::{Path, PathBuf}; use util::{path, paths::PathStyle, rel_path::rel_path}; use workspace::{ @@ -167,6 +172,254 @@ async fn test_opening_file(cx: &mut gpui::TestAppContext) { ensure_single_file_is_opened(&workspace, "test/second.rs", cx); } +#[gpui::test] +async fn test_file_history_action_uses_focused_project_panel_selection( + cx: &mut gpui::TestAppContext, +) { + init_test_with_git_ui(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + Path::new("/project"), + json!({ + ".git": {}, + "tracked1.txt": "tracked 1", + "tracked2.txt": "tracked 2", + }), + ) + .await; + + let commits = vec![Arc::new(InitialGraphCommitData { + sha: Oid::from_bytes(&[1; 20]).unwrap(), + parents: smallvec![], + ref_names: vec!["HEAD".into(), "refs/heads/main".into()], + })]; + fs.set_graph_commits(Path::new("/project/.git"), commits); + + let project = Project::test(fs.clone(), [Path::new("/project")], cx).await; + cx.run_until_parked(); + + let repository = project.read_with(cx, |project, cx| { + project + .active_repository(cx) + .expect("should have active repository") + }); + let project_panel_repo_path = RepoPath::new(&"tracked1.txt").unwrap(); + let editor_repo_path = RepoPath::new(&"tracked2.txt").unwrap(); + let project_panel_path = repository + .read_with(cx, |repository, cx| { + repository.repo_path_to_project_path(&project_panel_repo_path, cx) + }) + .expect("project panel path should resolve"); + let editor_path = repository + .read_with(cx, |repository, cx| { + repository.repo_path_to_project_path(&editor_repo_path, cx) + }) + .expect("editor path should resolve"); + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(&*cx, |multi_workspace, _| { + multi_workspace.workspace().clone() + }); + let project_panel = multi_workspace.update_in(cx, |multi_workspace, window, cx| { + let workspace = multi_workspace.workspace(); + workspace.update(cx, |workspace, cx| { + let project_panel = ProjectPanel::new(workspace, window, cx); + workspace.add_panel(project_panel.clone(), window, cx); + project_panel + }) + }); + cx.run_until_parked(); + + let editor_buffer = project + .update(cx, |project, cx| { + project.open_buffer(editor_path.clone(), cx) + }) + .await + .expect("editor buffer should open"); + multi_workspace.update_in(cx, |multi_workspace, window, cx| { + let workspace = multi_workspace.workspace(); + let multibuffer = cx.new(|cx| { + let mut multibuffer = editor::MultiBuffer::new(language::Capability::ReadWrite); + multibuffer.set_excerpts_for_buffer( + editor_buffer.clone(), + [Default::default()..editor_buffer.read(cx).max_point()], + 0, + cx, + ); + multibuffer + }); + let editor = + cx.new(|cx| Editor::for_multibuffer(multibuffer, Some(project.clone()), window, cx)); + workspace.update(cx, |workspace, cx| { + workspace.add_item_to_active_pane(Box::new(editor.clone()), None, true, window, cx); + }); + editor.update(cx, |editor, cx| { + window.focus(&editor.focus_handle(cx), cx); + }); + }); + cx.run_until_parked(); + + multi_workspace.update_in(cx, |_multi_workspace, window, cx| { + project_panel.update(cx, |panel, cx| { + panel.select_path_for_test(project_panel_path.clone(), cx); + }); + project_panel.update(cx, |panel, cx| { + panel.focus_handle(cx).focus(window, cx); + }); + }); + cx.run_until_parked(); + + cx.update(|window, cx| { + window.dispatch_action(Box::new(git::FileHistory), cx); + }); + cx.run_until_parked(); + + workspace.read_with(&*cx, |workspace, cx| { + let graphs = workspace + .items_of_type::(cx) + .collect::>(); + assert_eq!(graphs.len(), 1); + assert_eq!( + graphs[0].read(cx).log_source_for_test(), + &LogSource::Path(project_panel_repo_path) + ); + }); +} + +#[gpui::test] +async fn test_file_history_action_does_not_fall_back_to_editor_when_focused_project_panel_selection_has_no_git_repo( + cx: &mut gpui::TestAppContext, +) { + init_test_with_git_ui(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + Path::new("/git-project"), + json!({ + ".git": {}, + "tracked.txt": "tracked", + }), + ) + .await; + fs.insert_tree( + Path::new("/plain-project"), + json!({ + "plain.txt": "plain", + }), + ) + .await; + + fs.set_graph_commits( + Path::new("/git-project/.git"), + vec![Arc::new(InitialGraphCommitData { + sha: Oid::from_bytes(&[1; 20]).unwrap(), + parents: smallvec![], + ref_names: vec!["HEAD".into(), "refs/heads/main".into()], + })], + ); + + let project = Project::test( + fs.clone(), + [Path::new("/git-project"), Path::new("/plain-project")], + cx, + ) + .await; + cx.run_until_parked(); + + let repository = project.read_with(cx, |project, cx| { + project + .active_repository(cx) + .expect("should have active repository") + }); + let editor_repo_path = RepoPath::new(&"tracked.txt").unwrap(); + let editor_path = repository + .read_with(cx, |repository, cx| { + repository.repo_path_to_project_path(&editor_repo_path, cx) + }) + .expect("editor path should resolve"); + let plain_worktree_id = project.read_with(cx, |project, cx| { + project + .worktree_for_root_name("plain-project", cx) + .expect("plain worktree should exist") + .read(cx) + .id() + }); + let plain_project_path = ProjectPath { + worktree_id: plain_worktree_id, + path: rel_path("plain.txt").into(), + }; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(&*cx, |multi_workspace, _| { + multi_workspace.workspace().clone() + }); + let project_panel = multi_workspace.update_in(cx, |multi_workspace, window, cx| { + let workspace = multi_workspace.workspace(); + workspace.update(cx, |workspace, cx| { + let project_panel = ProjectPanel::new(workspace, window, cx); + workspace.add_panel(project_panel.clone(), window, cx); + project_panel + }) + }); + cx.run_until_parked(); + + let editor_buffer = project + .update(cx, |project, cx| { + project.open_buffer(editor_path.clone(), cx) + }) + .await + .expect("editor buffer should open"); + multi_workspace.update_in(cx, |multi_workspace, window, cx| { + let workspace = multi_workspace.workspace(); + let multibuffer = cx.new(|cx| { + let mut multibuffer = editor::MultiBuffer::new(language::Capability::ReadWrite); + multibuffer.set_excerpts_for_buffer( + editor_buffer.clone(), + [Default::default()..editor_buffer.read(cx).max_point()], + 0, + cx, + ); + multibuffer + }); + let editor = + cx.new(|cx| Editor::for_multibuffer(multibuffer, Some(project.clone()), window, cx)); + workspace.update(cx, |workspace, cx| { + workspace.add_item_to_active_pane(Box::new(editor.clone()), None, true, window, cx); + }); + editor.update(cx, |editor, cx| { + window.focus(&editor.focus_handle(cx), cx); + }); + }); + cx.run_until_parked(); + + multi_workspace.update_in(cx, |_multi_workspace, window, cx| { + project_panel.update(cx, |panel, cx| { + panel.select_path_for_test(plain_project_path.clone(), cx); + }); + project_panel.update(cx, |panel, cx| { + panel.focus_handle(cx).focus(window, cx); + }); + }); + cx.run_until_parked(); + + cx.update(|window, cx| { + window.dispatch_action(Box::new(git::FileHistory), cx); + }); + cx.run_until_parked(); + + workspace.read_with(&*cx, |workspace, cx| { + assert_eq!( + workspace + .items_of_type::(cx) + .count(), + 0 + ); + }); +} + #[gpui::test] async fn test_exclusions_in_visible_list(cx: &mut gpui::TestAppContext) { init_test(cx); @@ -10412,6 +10665,27 @@ fn init_test_with_editor(cx: &mut TestAppContext) { }); } +fn init_test_with_git_ui(cx: &mut TestAppContext) { + cx.update(|cx| { + let app_state = AppState::test(cx); + theme_settings::init(theme::LoadThemes::JustBase, cx); + editor::init(cx); + git_ui::init(cx); + crate::init(cx); + workspace::init(app_state, cx); + + cx.update_global::(|store, cx| { + store.update_user_settings(cx, |settings| { + settings + .project_panel + .get_or_insert_default() + .auto_fold_dirs = Some(false); + settings.project.worktree.file_scan_exclusions = Some(Vec::new()) + }); + }); + }); +} + fn set_auto_open_settings( cx: &mut TestAppContext, auto_open_settings: ProjectPanelAutoOpenSettings, diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 14f5140926..b0cbcb4d08 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -113,7 +113,6 @@ file_finder.workspace = true fs.workspace = true futures.workspace = true git.workspace = true -git_graph.workspace = true git_hosting_providers.workspace = true git_ui.workspace = true go_to_line.workspace = true diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 8476392b7a..e9fa128e49 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -767,7 +767,6 @@ fn main() { notifications::init(app_state.client.clone(), app_state.user_store.clone(), cx); collab_ui::init(&app_state, cx); git_ui::init(cx); - git_graph::init(cx); feedback::init(cx); markdown_preview::init(cx); csv_preview::init(cx); diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index f9c04b7c91..7bc26f4679 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -5478,7 +5478,6 @@ mod tests { ); language_models::init(app_state.user_store.clone(), app_state.client.clone(), cx); web_search::init(cx); - git_graph::init(cx); web_search_providers::init(app_state.client.clone(), app_state.user_store.clone(), cx); let prompt_builder = PromptBuilder::load(app_state.fs.clone(), false, cx); project::AgentRegistryStore::init_global(