From 826ab39c53cc78c2b5fe2070438ea1c1a42925c2 Mon Sep 17 00:00:00 2001 From: Anthony Eid Date: Tue, 7 Apr 2026 06:12:24 -0400 Subject: [PATCH] Remove file history view and make git graph fields persistent The graph now remembers it's search text, case sensitive toggle, selected entry, and the log source/order variant. --- crates/fs/src/fake_git_repo.rs | 21 +- crates/git/src/repository.rs | 109 ----- crates/git_graph/src/git_graph.rs | 458 +++++++++++++++++- crates/git_ui/src/file_history_view.rs | 613 ------------------------- crates/git_ui/src/git_ui.rs | 1 - crates/project/src/git_store.rs | 113 +---- crates/proto/proto/git.proto | 23 - crates/proto/proto/zed.proto | 3 +- crates/proto/src/proto.rs | 4 - 9 files changed, 449 insertions(+), 896 deletions(-) delete mode 100644 crates/git_ui/src/file_history_view.rs diff --git a/crates/fs/src/fake_git_repo.rs b/crates/fs/src/fake_git_repo.rs index 751796fb83164b78dc5d6789f0ae7870eff16ce1..f238706f4f6a6d1a9bed52db264ba63d348951c7 100644 --- a/crates/fs/src/fake_git_repo.rs +++ b/crates/fs/src/fake_git_repo.rs @@ -142,7 +142,7 @@ impl GitRepository for FakeGitRepository { _commit: String, _cx: AsyncApp, ) -> BoxFuture<'_, Result> { - unimplemented!() + async { Ok(git::repository::CommitDiff { files: Vec::new() }) }.boxed() } fn set_index_text( @@ -782,25 +782,6 @@ impl GitRepository for FakeGitRepository { }) } - fn file_history(&self, path: RepoPath) -> BoxFuture<'_, Result> { - self.file_history_paginated(path, 0, None) - } - - fn file_history_paginated( - &self, - path: RepoPath, - _skip: usize, - _limit: Option, - ) -> BoxFuture<'_, Result> { - async move { - Ok(git::repository::FileHistory { - entries: Vec::new(), - path, - }) - } - .boxed() - } - fn stage_paths( &self, paths: Vec, diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index 1c116c9fb52023e7c4fd71a6603337f1c0e3582c..6961ffcc97d19ddda58305baa337ad1fc21b1ca9 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -396,22 +396,6 @@ pub struct CommitDetails { pub author_name: SharedString, } -#[derive(Clone, Debug, Hash, PartialEq, Eq)] -pub struct FileHistoryEntry { - pub sha: SharedString, - pub subject: SharedString, - pub message: SharedString, - pub commit_timestamp: i64, - pub author_name: SharedString, - pub author_email: SharedString, -} - -#[derive(Debug, Clone)] -pub struct FileHistory { - pub entries: Vec, - pub path: RepoPath, -} - #[derive(Debug)] pub struct CommitDiff { pub files: Vec, @@ -751,13 +735,6 @@ pub trait GitRepository: Send + Sync { content: Rope, line_ending: LineEnding, ) -> BoxFuture<'_, Result>; - fn file_history(&self, path: RepoPath) -> BoxFuture<'_, Result>; - fn file_history_paginated( - &self, - path: RepoPath, - skip: usize, - limit: Option, - ) -> BoxFuture<'_, Result>; /// Returns the absolute path to the repository. For worktrees, this will be the path to the /// worktree's gitdir within the main repository (typically `.git/worktrees/`). @@ -1846,92 +1823,6 @@ impl GitRepository for RealGitRepository { .boxed() } - fn file_history(&self, path: RepoPath) -> BoxFuture<'_, Result> { - self.file_history_paginated(path, 0, None) - } - - fn file_history_paginated( - &self, - path: RepoPath, - skip: usize, - limit: Option, - ) -> BoxFuture<'_, Result> { - let git_binary = self.git_binary(); - self.executor - .spawn(async move { - let git = git_binary?; - // Use a unique delimiter with a hardcoded UUID to separate commits - // This essentially eliminates any chance of encountering the delimiter in actual commit data - let commit_delimiter = - concat!("<>",); - - let format_string = format!( - "--pretty=format:%H%x00%s%x00%B%x00%at%x00%an%x00%ae{}", - commit_delimiter - ); - - let mut args = vec!["log", "--follow", &format_string]; - - let skip_str; - let limit_str; - if skip > 0 { - skip_str = skip.to_string(); - args.push("--skip"); - args.push(&skip_str); - } - if let Some(n) = limit { - limit_str = n.to_string(); - args.push("-n"); - args.push(&limit_str); - } - - args.push("--"); - - let output = git - .build_command(&args) - .arg(path.as_unix_str()) - .output() - .await?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - bail!("git log failed: {stderr}"); - } - - let stdout = std::str::from_utf8(&output.stdout)?; - let mut entries = Vec::new(); - - for commit_block in stdout.split(commit_delimiter) { - let commit_block = commit_block.trim(); - if commit_block.is_empty() { - continue; - } - - let fields: Vec<&str> = commit_block.split('\0').collect(); - if fields.len() >= 6 { - let sha = fields[0].trim().to_string().into(); - let subject = fields[1].trim().to_string().into(); - let message = fields[2].trim().to_string().into(); - let commit_timestamp = fields[3].trim().parse().unwrap_or(0); - let author_name = fields[4].trim().to_string().into(); - let author_email = fields[5].trim().to_string().into(); - - entries.push(FileHistoryEntry { - sha, - subject, - message, - commit_timestamp, - author_name, - author_email, - }); - } - } - - Ok(FileHistory { entries, path }) - }) - .boxed() - } - fn diff(&self, diff: DiffType) -> BoxFuture<'_, Result> { let git_binary = self.git_binary(); self.executor diff --git a/crates/git_graph/src/git_graph.rs b/crates/git_graph/src/git_graph.rs index a4bdf807ee22785e7a39a774aa176c7758a4ad1a..4ee5d3addfa2aed01f666e4479069f6ae278cbd5 100644 --- a/crates/git_graph/src/git_graph.rs +++ b/crates/git_graph/src/git_graph.rs @@ -997,6 +997,7 @@ impl GitGraph { self.search_state.matches.clear(); self.search_state.selected_index = None; self.search_state.state.next_state(); + cx.emit(ItemEvent::Edit); cx.notify(); } @@ -1250,17 +1251,18 @@ impl GitGraph { } } RepositoryEvent::HeadChanged | RepositoryEvent::BranchListChanged => { - self.pending_select_sha = None; // Only invalidate if we scanned atleast once, // meaning we are not inside the initial repo loading state // NOTE: this fixes an loading performance regression if repository.read(cx).scan_id > 1 { + self.pending_select_sha = None; self.invalidate_state(cx); } } RepositoryEvent::StashEntriesChanged if self.log_source == LogSource::All => { - self.pending_select_sha = None; - if repository.read(cx).scan_id > 1 { + // Stash entries initial's scan id is 2, so we don't want to invalidate the graph before that + if repository.read(cx).scan_id > 2 { + self.pending_select_sha = None; self.invalidate_state(cx); } } @@ -1435,6 +1437,7 @@ impl GitGraph { self.selected_entry_idx = None; self.selected_commit_diff = None; self.selected_commit_diff_stats = None; + cx.emit(ItemEvent::Edit); cx.notify(); } @@ -1544,6 +1547,7 @@ impl GitGraph { }); self.search_state.state = QueryState::Confirmed((query, search_task)); + cx.emit(ItemEvent::Edit); } fn confirm_search(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context) { @@ -1595,6 +1599,7 @@ impl GitGraph { } })); + cx.emit(ItemEvent::Edit); cx.notify(); } @@ -2926,6 +2931,7 @@ impl Render for GitGraph { .on_action(cx.listener(|this, _: &ToggleCaseSensitive, _window, cx| { this.search_state.case_sensitive = !this.search_state.case_sensitive; this.search_state.state.next_state(); + cx.emit(ItemEvent::Edit); cx.notify(); })) .child( @@ -3053,10 +3059,28 @@ impl workspace::SerializableItem for GitGraph { cx: &mut App, ) -> Task>> { let db = persistence::GitGraphsDb::global(cx); - let Some(repo_work_path) = db.get_git_graph(item_id, workspace_id).ok().flatten() else { + let Some(( + repo_work_path, + log_source_type, + log_source_value, + log_order, + selected_sha, + search_query, + search_case_sensitive, + )) = db.get_git_graph(item_id, workspace_id).ok().flatten() + else { return Task::ready(Err(anyhow::anyhow!("No git graph to deserialize"))); }; + let state = persistence::SerializedGitGraphState { + log_source_type, + log_source_value, + log_order, + selected_sha, + search_query, + search_case_sensitive, + }; + let window_handle = window.window_handle(); let project = project.read(cx); let git_store = project.git_store().clone(); @@ -3081,7 +3105,37 @@ impl workspace::SerializableItem for GitGraph { return Err(anyhow::anyhow!("Repository not found for path: {:?}", path)); }; - Ok(cx.new(|cx| GitGraph::new(repo_id, git_store, workspace, None, window, cx))) + let log_source = persistence::deserialize_log_source(&state); + let log_order = persistence::deserialize_log_order(&state); + + let git_graph = cx.new(|cx| { + let mut graph = + GitGraph::new(repo_id, git_store, workspace, Some(log_source), window, cx); + graph.log_order = log_order; + + if let Some(sha) = &state.selected_sha { + graph.select_commit_by_sha(sha.as_str(), cx); + } + + graph + }); + + git_graph.update(cx, |graph, cx| { + graph.search_state.case_sensitive = + state.search_case_sensitive.unwrap_or(false); + + if let Some(query) = &state.search_query + && !query.is_empty() + { + graph + .search_state + .editor + .update(cx, |editor, cx| editor.set_text(query.as_str(), window, cx)); + graph.search(query.clone().into(), cx); + } + }); + + Ok(git_graph) })? }) } @@ -3103,26 +3157,60 @@ impl workspace::SerializableItem for GitGraph { .to_string_lossy() .to_string(); + let selected_sha = self + .selected_entry_idx + .and_then(|idx| self.graph_data.commits.get(idx)) + .map(|commit| commit.data.sha.to_string()); + + let search_query = self.search_state.editor.read(cx).text(cx); + let search_query = if search_query.is_empty() { + None + } else { + Some(search_query) + }; + + let log_source_type = Some(persistence::serialize_log_source_type(&self.log_source)); + let log_source_value = persistence::serialize_log_source_value(&self.log_source); + let log_order = Some(persistence::serialize_log_order(&self.log_order)); + let search_case_sensitive = Some(self.search_state.case_sensitive); + let db = persistence::GitGraphsDb::global(cx); Some(cx.background_spawn(async move { - db.save_git_graph(item_id, workspace_id, repo_working_path) - .await + db.save_git_graph( + item_id, + workspace_id, + repo_working_path, + log_source_type, + log_source_value, + log_order, + selected_sha, + search_query, + search_case_sensitive, + ) + .await })) } fn should_serialize(&self, event: &Self::Event) -> bool { - event == &ItemEvent::UpdateTab + match event { + ItemEvent::UpdateTab | ItemEvent::Edit => true, + _ => false, + } } } mod persistence { - use std::path::PathBuf; + use std::{path::PathBuf, str::FromStr}; use db::{ query, sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection}, sqlez_macros::sql, }; + use git::{ + Oid, + repository::{LogOrder, LogSource, RepoPath}, + }; use workspace::WorkspaceDb; pub struct GitGraphsDb(ThreadSafeConnection); @@ -3145,20 +3233,119 @@ mod persistence { sql!( ALTER TABLE git_graphs ADD COLUMN repo_working_path TEXT; ), + sql!( + ALTER TABLE git_graphs ADD COLUMN log_source_type TEXT; + ALTER TABLE git_graphs ADD COLUMN log_source_value TEXT; + ALTER TABLE git_graphs ADD COLUMN log_order TEXT; + ALTER TABLE git_graphs ADD COLUMN selected_sha TEXT; + ALTER TABLE git_graphs ADD COLUMN search_query TEXT; + ALTER TABLE git_graphs ADD COLUMN search_case_sensitive INTEGER; + ), ]; } db::static_connection!(GitGraphsDb, [WorkspaceDb]); + pub const LOG_SOURCE_ALL: i32 = 0; + pub const LOG_SOURCE_BRANCH: i32 = 1; + pub const LOG_SOURCE_SHA: i32 = 2; + pub const LOG_SOURCE_FILE: i32 = 3; + + pub const LOG_ORDER_DATE: i32 = 0; + pub const LOG_ORDER_TOPO: i32 = 1; + pub const LOG_ORDER_AUTHOR_DATE: i32 = 2; + pub const LOG_ORDER_REVERSE: i32 = 3; + + pub fn serialize_log_source_type(log_source: &LogSource) -> i32 { + match log_source { + LogSource::All => LOG_SOURCE_ALL, + LogSource::Branch(_) => LOG_SOURCE_BRANCH, + LogSource::Sha(_) => LOG_SOURCE_SHA, + LogSource::File(_) => LOG_SOURCE_FILE, + } + } + + pub fn serialize_log_source_value(log_source: &LogSource) -> Option { + match log_source { + LogSource::All => None, + LogSource::Branch(branch) => Some(branch.to_string()), + LogSource::Sha(oid) => Some(oid.to_string()), + LogSource::File(path) => Some(path.as_unix_str().to_string()), + } + } + + pub fn serialize_log_order(log_order: &LogOrder) -> i32 { + match log_order { + LogOrder::DateOrder => LOG_ORDER_DATE, + LogOrder::TopoOrder => LOG_ORDER_TOPO, + LogOrder::AuthorDateOrder => LOG_ORDER_AUTHOR_DATE, + LogOrder::ReverseChronological => LOG_ORDER_REVERSE, + } + } + + pub fn deserialize_log_source(state: &SerializedGitGraphState) -> LogSource { + match state.log_source_type { + Some(LOG_SOURCE_ALL) => LogSource::All, + Some(LOG_SOURCE_BRANCH) => state + .log_source_value + .as_ref() + .map(|v| LogSource::Branch(v.clone().into())) + .unwrap_or_default(), + Some(LOG_SOURCE_SHA) => state + .log_source_value + .as_ref() + .and_then(|v| Oid::from_str(v).ok()) + .map(LogSource::Sha) + .unwrap_or_default(), + Some(LOG_SOURCE_FILE) => state + .log_source_value + .as_ref() + .and_then(|v| RepoPath::new(v).ok()) + .map(LogSource::File) + .unwrap_or_default(), + None | Some(_) => LogSource::default(), + } + } + + pub fn deserialize_log_order(state: &SerializedGitGraphState) -> LogOrder { + match state.log_order { + Some(LOG_ORDER_DATE) => LogOrder::DateOrder, + Some(LOG_ORDER_TOPO) => LogOrder::TopoOrder, + Some(LOG_ORDER_AUTHOR_DATE) => LogOrder::AuthorDateOrder, + Some(LOG_ORDER_REVERSE) => LogOrder::ReverseChronological, + _ => LogOrder::default(), + } + } + + #[derive(Debug, Default, Clone)] + pub struct SerializedGitGraphState { + pub log_source_type: Option, + pub log_source_value: Option, + pub log_order: Option, + pub selected_sha: Option, + pub search_query: Option, + pub search_case_sensitive: Option, + } + impl GitGraphsDb { query! { pub async fn save_git_graph( item_id: workspace::ItemId, workspace_id: workspace::WorkspaceId, - repo_working_path: String + repo_working_path: String, + log_source_type: Option, + log_source_value: Option, + log_order: Option, + selected_sha: Option, + search_query: Option, + search_case_sensitive: Option ) -> Result<()> { - INSERT OR REPLACE INTO git_graphs(item_id, workspace_id, repo_working_path) - VALUES (?, ?, ?) + INSERT OR REPLACE INTO git_graphs( + item_id, workspace_id, repo_working_path, + log_source_type, log_source_value, log_order, + selected_sha, search_query, search_case_sensitive + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) } } @@ -3166,8 +3353,23 @@ mod persistence { pub fn get_git_graph( item_id: workspace::ItemId, workspace_id: workspace::WorkspaceId - ) -> Result> { - SELECT repo_working_path + ) -> Result, + Option, + Option, + Option, + Option, + Option + )>> { + SELECT + repo_working_path, + log_source_type, + log_source_value, + log_order, + selected_sha, + search_query, + search_case_sensitive FROM git_graphs WHERE item_id = ? AND workspace_id = ? } @@ -4251,6 +4453,234 @@ mod tests { }); } + #[gpui::test] + fn test_serialized_state_roundtrip(_cx: &mut TestAppContext) { + use persistence::SerializedGitGraphState; + + let file_path = RepoPath::new(&"src/main.rs").unwrap(); + let sha = Oid::from_bytes(&[0xab; 20]).unwrap(); + + let state = SerializedGitGraphState { + log_source_type: Some(persistence::LOG_SOURCE_FILE), + log_source_value: Some("src/main.rs".to_string()), + log_order: Some(persistence::LOG_ORDER_TOPO), + selected_sha: Some(sha.to_string()), + search_query: Some("fix bug".to_string()), + search_case_sensitive: Some(true), + }; + + assert_eq!( + persistence::deserialize_log_source(&state), + LogSource::File(file_path) + ); + assert!(matches!( + persistence::deserialize_log_order(&state), + LogOrder::TopoOrder + )); + assert_eq!( + state.selected_sha.as_deref(), + Some(sha.to_string()).as_deref() + ); + assert_eq!(state.search_query.as_deref(), Some("fix bug")); + assert_eq!(state.search_case_sensitive, Some(true)); + + let all_state = SerializedGitGraphState { + log_source_type: Some(persistence::LOG_SOURCE_ALL), + log_source_value: None, + log_order: Some(persistence::LOG_ORDER_DATE), + selected_sha: None, + search_query: None, + search_case_sensitive: None, + }; + assert_eq!( + persistence::deserialize_log_source(&all_state), + LogSource::All + ); + assert!(matches!( + persistence::deserialize_log_order(&all_state), + LogOrder::DateOrder + )); + + let branch_state = SerializedGitGraphState { + log_source_type: Some(persistence::LOG_SOURCE_BRANCH), + log_source_value: Some("refs/heads/main".to_string()), + ..Default::default() + }; + assert_eq!( + persistence::deserialize_log_source(&branch_state), + LogSource::Branch("refs/heads/main".into()) + ); + + let sha_state = SerializedGitGraphState { + log_source_type: Some(persistence::LOG_SOURCE_SHA), + log_source_value: Some(sha.to_string()), + ..Default::default() + }; + assert_eq!( + persistence::deserialize_log_source(&sha_state), + LogSource::Sha(sha) + ); + + let empty_state = SerializedGitGraphState::default(); + assert_eq!( + persistence::deserialize_log_source(&empty_state), + LogSource::All + ); + assert!(matches!( + persistence::deserialize_log_order(&empty_state), + LogOrder::DateOrder + )); + } + + #[gpui::test] + async fn test_git_graph_state_persists_across_serialization_roundtrip(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + Path::new("/project"), + json!({ + ".git": {}, + "file.txt": "content", + }), + ) + .await; + + let mut rng = StdRng::seed_from_u64(99); + let commits = generate_random_commit_dag(&mut rng, 20, false); + fs.set_graph_commits(Path::new("/project/.git"), commits.clone()); + + 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 a repository") + }); + + let (multi_workspace, cx) = cx.add_window_view(|window, cx| { + workspace::MultiWorkspace::test_new(project.clone(), window, cx) + }); + let workspace_weak = + multi_workspace.read_with(&*cx, |multi, _| multi.workspace().downgrade()); + + let git_graph = cx.new_window_entity(|window, cx| { + GitGraph::new( + repository.read(cx).id, + project.read(cx).git_store().clone(), + workspace_weak.clone(), + None, + window, + cx, + ) + }); + cx.run_until_parked(); + + cx.draw( + point(px(0.), px(0.)), + gpui::size(px(1200.), px(800.)), + |_, _| git_graph.clone().into_any_element(), + ); + cx.run_until_parked(); + + let commit_count = git_graph.read_with(&*cx, |graph, _| graph.graph_data.commits.len()); + assert!(commit_count > 0, "graph should have loaded commits, got 0"); + + let target_sha = commits[5].sha; + git_graph.update(cx, |graph, _| { + graph.selected_entry_idx = Some(5); + }); + + let selected_sha = git_graph.read_with(&*cx, |graph, _| { + graph + .selected_entry_idx + .and_then(|idx| graph.graph_data.commits.get(idx)) + .map(|c| c.data.sha.to_string()) + }); + assert_eq!(selected_sha, Some(target_sha.to_string())); + + let item_id = workspace::ItemId::from(999_u64); + let workspace_db = cx.read(|cx| workspace::WorkspaceDb::global(cx)); + let workspace_id = workspace_db + .next_id() + .await + .expect("should create workspace id"); + let db = cx.read(|cx| persistence::GitGraphsDb::global(cx)); + db.save_git_graph( + item_id, + workspace_id, + "/project".to_string(), + Some(persistence::LOG_SOURCE_ALL), + None, + Some(persistence::LOG_ORDER_DATE), + selected_sha.clone(), + Some("some query".to_string()), + Some(true), + ) + .await + .expect("save should succeed"); + + let restored_graph = cx + .update(|window, cx| { + ::deserialize( + project.clone(), + workspace_weak, + workspace_id, + item_id, + window, + cx, + ) + }) + .await + .expect("deserialization should succeed"); + cx.run_until_parked(); + + cx.draw( + point(px(0.), px(0.)), + gpui::size(px(1200.), px(800.)), + |_, _| restored_graph.clone().into_any_element(), + ); + cx.run_until_parked(); + + let restored_commit_count = + restored_graph.read_with(&*cx, |graph, _| graph.graph_data.commits.len()); + assert_eq!( + restored_commit_count, commit_count, + "restored graph should have the same number of commits" + ); + + restored_graph.read_with(&*cx, |graph, _| { + assert_eq!( + graph.log_source, + LogSource::All, + "log_source should be restored" + ); + + let restored_selected_sha = graph + .selected_entry_idx + .and_then(|idx| graph.graph_data.commits.get(idx)) + .map(|c| c.data.sha.to_string()); + assert_eq!( + restored_selected_sha, selected_sha, + "selected commit should be restored via pending_select_sha" + ); + + assert_eq!( + graph.search_state.case_sensitive, true, + "search case sensitivity should be restored" + ); + }); + + restored_graph.read_with(&*cx, |graph, cx| { + let editor_text = graph.search_state.editor.read(cx).text(cx); + assert_eq!( + editor_text, "some query", + "search query text should be restored in editor" + ); + }); + } + #[gpui::test] async fn test_graph_data_reloaded_after_stash_change(cx: &mut TestAppContext) { init_test(cx); diff --git a/crates/git_ui/src/file_history_view.rs b/crates/git_ui/src/file_history_view.rs deleted file mode 100644 index e0cee4ef1d66b7c09ff249d2323fc9fa72abbd7c..0000000000000000000000000000000000000000 --- a/crates/git_ui/src/file_history_view.rs +++ /dev/null @@ -1,613 +0,0 @@ -use anyhow::Result; - -use git::repository::{FileHistory, FileHistoryEntry, RepoPath}; -use git::{GitHostingProviderRegistry, GitRemote, parse_git_remote_url}; -use gpui::{ - AnyElement, AnyEntity, App, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement, - Render, ScrollStrategy, Task, UniformListScrollHandle, WeakEntity, Window, uniform_list, -}; -use project::{ - Project, ProjectPath, - git_store::{GitStore, Repository}, -}; -use std::any::{Any, TypeId}; -use std::sync::Arc; - -use time::OffsetDateTime; -use ui::{Chip, Divider, ListItem, WithScrollbar, prelude::*}; -use util::ResultExt; -use workspace::{ - Item, Workspace, - item::{ItemEvent, SaveOptions}, -}; - -use crate::commit_tooltip::CommitAvatar; -use crate::commit_view::CommitView; - -const PAGE_SIZE: usize = 50; - -pub struct FileHistoryView { - history: FileHistory, - repository: WeakEntity, - git_store: WeakEntity, - workspace: WeakEntity, - remote: Option, - selected_entry: Option, - scroll_handle: UniformListScrollHandle, - focus_handle: FocusHandle, - loading_more: bool, - has_more: bool, -} - -impl FileHistoryView { - pub fn open( - path: RepoPath, - git_store: WeakEntity, - repo: WeakEntity, - workspace: WeakEntity, - window: &mut Window, - cx: &mut App, - ) { - let file_history_task = git_store - .update(cx, |git_store, cx| { - repo.upgrade().map(|repo| { - git_store.file_history_paginated(&repo, path.clone(), 0, Some(PAGE_SIZE), cx) - }) - }) - .ok() - .flatten(); - - window - .spawn(cx, async move |cx| { - let file_history = file_history_task?.await.log_err()?; - let repo = repo.upgrade()?; - - workspace - .update_in(cx, |workspace, window, cx| { - let project = workspace.project(); - let view = cx.new(|cx| { - FileHistoryView::new( - file_history, - git_store.clone(), - repo.clone(), - workspace.weak_handle(), - project.clone(), - window, - cx, - ) - }); - - let pane = workspace.active_pane(); - pane.update(cx, |pane, cx| { - let ix = pane.items().position(|item| { - let view = item.downcast::(); - view.is_some_and(|v| v.read(cx).history.path == path) - }); - if let Some(ix) = ix { - pane.activate_item(ix, true, true, window, cx); - } else { - pane.add_item(Box::new(view), true, true, None, window, cx); - } - }) - }) - .log_err() - }) - .detach(); - } - - fn new( - history: FileHistory, - git_store: WeakEntity, - repository: Entity, - workspace: WeakEntity, - _project: Entity, - _window: &mut Window, - cx: &mut Context, - ) -> Self { - let focus_handle = cx.focus_handle(); - let scroll_handle = UniformListScrollHandle::new(); - let has_more = history.entries.len() >= PAGE_SIZE; - - let snapshot = repository.read(cx).snapshot(); - let remote_url = snapshot - .remote_upstream_url - .as_ref() - .or(snapshot.remote_origin_url.as_ref()); - - let remote = remote_url.and_then(|url| { - let provider_registry = GitHostingProviderRegistry::default_global(cx); - parse_git_remote_url(provider_registry, url).map(|(host, parsed)| GitRemote { - host, - owner: parsed.owner.into(), - repo: parsed.repo.into(), - }) - }); - - Self { - history, - git_store, - repository: repository.downgrade(), - workspace, - remote, - selected_entry: None, - scroll_handle, - focus_handle, - loading_more: false, - has_more, - } - } - - fn load_more(&mut self, window: &mut Window, cx: &mut Context) { - if self.loading_more || !self.has_more { - return; - } - - self.loading_more = true; - cx.notify(); - - let current_count = self.history.entries.len(); - let path = self.history.path.clone(); - let git_store = self.git_store.clone(); - let repo = self.repository.clone(); - - let this = cx.weak_entity(); - let task = window.spawn(cx, async move |cx| { - let file_history_task = git_store - .update(cx, |git_store, cx| { - repo.upgrade().map(|repo| { - git_store.file_history_paginated( - &repo, - path, - current_count, - Some(PAGE_SIZE), - cx, - ) - }) - }) - .ok() - .flatten(); - - if let Some(task) = file_history_task { - if let Ok(more_history) = task.await { - this.update(cx, |this, cx| { - this.loading_more = false; - this.has_more = more_history.entries.len() >= PAGE_SIZE; - this.history.entries.extend(more_history.entries); - cx.notify(); - }) - .ok(); - } - } - }); - - task.detach(); - } - - fn select_next(&mut self, _: &menu::SelectNext, _: &mut Window, cx: &mut Context) { - let entry_count = self.history.entries.len(); - let ix = match self.selected_entry { - _ if entry_count == 0 => None, - None => Some(0), - Some(ix) => { - if ix == entry_count - 1 { - Some(0) - } else { - Some(ix + 1) - } - } - }; - self.select_ix(ix, cx); - } - - fn select_previous( - &mut self, - _: &menu::SelectPrevious, - _: &mut Window, - cx: &mut Context, - ) { - let entry_count = self.history.entries.len(); - let ix = match self.selected_entry { - _ if entry_count == 0 => None, - None => Some(entry_count - 1), - Some(ix) => { - if ix == 0 { - Some(entry_count - 1) - } else { - Some(ix - 1) - } - } - }; - self.select_ix(ix, cx); - } - - fn select_first(&mut self, _: &menu::SelectFirst, _: &mut Window, cx: &mut Context) { - let entry_count = self.history.entries.len(); - let ix = if entry_count != 0 { Some(0) } else { None }; - self.select_ix(ix, cx); - } - - fn select_last(&mut self, _: &menu::SelectLast, _: &mut Window, cx: &mut Context) { - let entry_count = self.history.entries.len(); - let ix = if entry_count != 0 { - Some(entry_count - 1) - } else { - None - }; - self.select_ix(ix, cx); - } - - fn select_ix(&mut self, ix: Option, cx: &mut Context) { - self.selected_entry = ix; - if let Some(ix) = ix { - self.scroll_handle.scroll_to_item(ix, ScrollStrategy::Top); - } - cx.notify(); - } - - fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) { - self.open_commit_view(window, cx); - } - - fn open_commit_view(&mut self, window: &mut Window, cx: &mut Context) { - let Some(entry) = self - .selected_entry - .and_then(|ix| self.history.entries.get(ix)) - else { - return; - }; - - if let Some(repo) = self.repository.upgrade() { - let sha_str = entry.sha.to_string(); - CommitView::open( - sha_str, - repo.downgrade(), - self.workspace.clone(), - None, - Some(self.history.path.clone()), - window, - cx, - ); - } - } - - fn render_commit_avatar( - &self, - sha: &SharedString, - author_email: Option, - window: &mut Window, - cx: &mut App, - ) -> AnyElement { - CommitAvatar::new(sha, author_email, self.remote.as_ref()) - .size(rems_from_px(20.)) - .render(window, cx) - } - - fn render_commit_entry( - &self, - ix: usize, - entry: &FileHistoryEntry, - window: &mut Window, - cx: &mut Context, - ) -> AnyElement { - let pr_number = entry - .subject - .rfind("(#") - .and_then(|start| { - let rest = &entry.subject[start + 2..]; - rest.find(')') - .and_then(|end| rest[..end].parse::().ok()) - }) - .map(|num| format!("#{}", num)) - .unwrap_or_else(|| { - if entry.sha.len() >= 7 { - entry.sha[..7].to_string() - } else { - entry.sha.to_string() - } - }); - - let commit_time = OffsetDateTime::from_unix_timestamp(entry.commit_timestamp) - .unwrap_or_else(|_| OffsetDateTime::UNIX_EPOCH); - let relative_timestamp = time_format::format_localized_timestamp( - commit_time, - OffsetDateTime::now_utc(), - time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC), - time_format::TimestampFormat::Relative, - ); - - ListItem::new(("commit", ix)) - .toggle_state(Some(ix) == self.selected_entry) - .child( - h_flex() - .h_8() - .w_full() - .pl_0p5() - .pr_2p5() - .gap_2() - .child( - div() - .w(rems_from_px(52.)) - .flex_none() - .child(Chip::new(pr_number)), - ) - .child(self.render_commit_avatar( - &entry.sha, - Some(entry.author_email.clone()), - window, - cx, - )) - .child( - h_flex() - .min_w_0() - .w_full() - .justify_between() - .child( - h_flex() - .min_w_0() - .w_full() - .gap_1() - .child( - Label::new(entry.author_name.clone()) - .size(LabelSize::Small) - .color(Color::Default) - .truncate(), - ) - .child( - Label::new(&entry.subject) - .size(LabelSize::Small) - .color(Color::Muted) - .truncate(), - ), - ) - .child( - h_flex().flex_none().child( - Label::new(relative_timestamp) - .size(LabelSize::Small) - .color(Color::Muted), - ), - ), - ), - ) - .on_click(cx.listener(move |this, _, window, cx| { - this.selected_entry = Some(ix); - cx.notify(); - - this.open_commit_view(window, cx); - })) - .into_any_element() - } -} - -impl EventEmitter for FileHistoryView {} - -impl Focusable for FileHistoryView { - fn focus_handle(&self, _cx: &App) -> FocusHandle { - self.focus_handle.clone() - } -} - -impl Render for FileHistoryView { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let _file_name = self.history.path.file_name().unwrap_or("File"); - let entry_count = self.history.entries.len(); - - v_flex() - .id("file_history_view") - .key_context("FileHistoryView") - .track_focus(&self.focus_handle) - .on_action(cx.listener(Self::select_next)) - .on_action(cx.listener(Self::select_previous)) - .on_action(cx.listener(Self::select_first)) - .on_action(cx.listener(Self::select_last)) - .on_action(cx.listener(Self::confirm)) - .size_full() - .bg(cx.theme().colors().editor_background) - .child( - h_flex() - .h(rems_from_px(41.)) - .pl_3() - .pr_2() - .justify_between() - .border_b_1() - .border_color(cx.theme().colors().border_variant) - .child( - Label::new(self.history.path.as_unix_str().to_string()) - .color(Color::Muted) - .buffer_font(cx), - ) - .child( - h_flex() - .gap_1p5() - .child( - Label::new(format!("{} commits", entry_count)) - .size(LabelSize::Small) - .color(Color::Muted) - .when(self.has_more, |this| this.mr_1()), - ) - .when(self.has_more, |this| { - this.child(Divider::vertical()).child( - Button::new("load-more", "Load More") - .disabled(self.loading_more) - .label_size(LabelSize::Small) - .start_icon( - Icon::new(IconName::ArrowCircle) - .size(IconSize::Small) - .color(Color::Muted), - ) - .on_click(cx.listener(|this, _, window, cx| { - this.load_more(window, cx); - })), - ) - }), - ), - ) - .child( - v_flex() - .flex_1() - .size_full() - .child({ - let view = cx.weak_entity(); - uniform_list( - "file-history-list", - entry_count, - move |range, window, cx| { - let Some(view) = view.upgrade() else { - return Vec::new(); - }; - view.update(cx, |this, cx| { - let mut items = Vec::with_capacity(range.end - range.start); - for ix in range { - if let Some(entry) = this.history.entries.get(ix) { - items.push( - this.render_commit_entry(ix, entry, window, cx), - ); - } - } - items - }) - }, - ) - .flex_1() - .size_full() - .track_scroll(&self.scroll_handle) - }) - .vertical_scrollbar_for(&self.scroll_handle, window, cx), - ) - } -} - -impl Item for FileHistoryView { - type Event = ItemEvent; - - fn to_item_events(event: &Self::Event, f: &mut dyn FnMut(ItemEvent)) { - f(*event) - } - - fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString { - let file_name = self - .history - .path - .file_name() - .map(|name| name.to_string()) - .unwrap_or_else(|| "File".to_string()); - format!("History: {}", file_name).into() - } - - fn tab_tooltip_text(&self, _cx: &App) -> Option { - Some(format!("Git history for {}", self.history.path.as_unix_str()).into()) - } - - fn tab_icon(&self, _window: &Window, _cx: &App) -> Option { - Some(Icon::new(IconName::GitBranch)) - } - - fn telemetry_event_text(&self) -> Option<&'static str> { - Some("file history") - } - - fn clone_on_split( - &self, - _workspace_id: Option, - _window: &mut Window, - _cx: &mut Context, - ) -> Task>> { - Task::ready(None) - } - - fn navigate( - &mut self, - _: Arc, - _window: &mut Window, - _: &mut Context, - ) -> bool { - false - } - - fn deactivated(&mut self, _window: &mut Window, _: &mut Context) {} - - fn can_save(&self, _: &App) -> bool { - false - } - - fn save( - &mut self, - _options: SaveOptions, - _project: Entity, - _window: &mut Window, - _: &mut Context, - ) -> Task> { - Task::ready(Ok(())) - } - - fn save_as( - &mut self, - _project: Entity, - _path: ProjectPath, - _window: &mut Window, - _: &mut Context, - ) -> Task> { - Task::ready(Ok(())) - } - - fn reload( - &mut self, - _project: Entity, - _window: &mut Window, - _: &mut Context, - ) -> Task> { - Task::ready(Ok(())) - } - - fn is_dirty(&self, _: &App) -> bool { - false - } - - fn has_conflict(&self, _: &App) -> bool { - false - } - - fn breadcrumbs( - &self, - _cx: &App, - ) -> Option<(Vec, Option)> { - None - } - - fn added_to_workspace( - &mut self, - _workspace: &mut Workspace, - window: &mut Window, - cx: &mut Context, - ) { - window.focus(&self.focus_handle, cx); - } - - fn show_toolbar(&self) -> bool { - true - } - - fn pixel_position_of_cursor(&self, _: &App) -> Option> { - None - } - - fn set_nav_history( - &mut self, - _: workspace::ItemNavHistory, - _window: &mut Window, - _: &mut Context, - ) { - } - - fn act_as_type<'a>( - &'a self, - type_id: TypeId, - self_handle: &'a Entity, - _: &'a App, - ) -> Option { - if type_id == TypeId::of::() { - Some(self_handle.clone().into()) - } else { - None - } - } -} diff --git a/crates/git_ui/src/git_ui.rs b/crates/git_ui/src/git_ui.rs index 4e6669823a9d8f78c3c6113906d6b927c3f1fc70..464316630a27b7e58e3a0e8094cb5bab1de26231 100644 --- a/crates/git_ui/src/git_ui.rs +++ b/crates/git_ui/src/git_ui.rs @@ -33,7 +33,6 @@ pub mod commit_tooltip; pub mod commit_view; mod conflict_view; pub mod file_diff_view; -pub mod file_history_view; pub mod git_panel; mod git_panel_settings; pub mod git_picker; diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index e7e84ffe673881d898a56b64892887b9c8d6c809..404591e9976612179c52c7d9b9e542575a3e49cf 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -573,7 +573,6 @@ impl GitStore { client.add_entity_request_handler(Self::handle_compare_checkpoints); client.add_entity_request_handler(Self::handle_diff_checkpoints); client.add_entity_request_handler(Self::handle_load_commit_diff); - client.add_entity_request_handler(Self::handle_file_history); client.add_entity_request_handler(Self::handle_checkout_files); client.add_entity_request_handler(Self::handle_open_commit_message_buffer); client.add_entity_request_handler(Self::handle_set_index_text); @@ -1218,30 +1217,6 @@ impl GitStore { }) } - pub fn file_history( - &self, - repo: &Entity, - path: RepoPath, - cx: &mut App, - ) -> Task> { - let rx = repo.update(cx, |repo, _| repo.file_history(path)); - - cx.spawn(|_: &mut AsyncApp| async move { rx.await? }) - } - - pub fn file_history_paginated( - &self, - repo: &Entity, - path: RepoPath, - skip: usize, - limit: Option, - cx: &mut App, - ) -> Task> { - let rx = repo.update(cx, |repo, _| repo.file_history_paginated(path, skip, limit)); - - cx.spawn(|_: &mut AsyncApp| async move { rx.await? }) - } - pub fn get_permalink_to_line( &self, buffer: &Entity, @@ -2767,40 +2742,6 @@ impl GitStore { }) } - async fn handle_file_history( - this: Entity, - envelope: TypedEnvelope, - mut cx: AsyncApp, - ) -> Result { - let repository_id = RepositoryId::from_proto(envelope.payload.repository_id); - let repository_handle = Self::repository_for_request(&this, repository_id, &mut cx)?; - let path = RepoPath::from_proto(&envelope.payload.path)?; - let skip = envelope.payload.skip as usize; - let limit = envelope.payload.limit.map(|l| l as usize); - - let file_history = repository_handle - .update(&mut cx, |repository_handle, _| { - repository_handle.file_history_paginated(path, skip, limit) - }) - .await??; - - Ok(proto::GitFileHistoryResponse { - entries: file_history - .entries - .into_iter() - .map(|entry| proto::FileHistoryEntry { - sha: entry.sha.to_string(), - subject: entry.subject.to_string(), - message: entry.message.to_string(), - commit_timestamp: entry.commit_timestamp, - author_name: entry.author_name.to_string(), - author_email: entry.author_email.to_string(), - }) - .collect(), - path: file_history.path.to_proto(), - }) - } - async fn handle_reset( this: Entity, envelope: TypedEnvelope, @@ -4079,14 +4020,15 @@ impl Repository { }) .shared(); + // todo(git_graph_remote): Make this subscription on both remote/local repo cx.subscribe_self(move |this, event: &RepositoryEvent, _| match event { RepositoryEvent::HeadChanged | RepositoryEvent::BranchListChanged => { - if this.scan_id > 1 { + if this.scan_id > 2 { this.initial_graph_data.clear(); } } RepositoryEvent::StashEntriesChanged => { - if this.scan_id > 1 { + if this.scan_id > 2 { this.initial_graph_data .retain(|(log_source, _), _| *log_source != LogSource::All); } @@ -4667,55 +4609,6 @@ impl Repository { }) } - pub fn file_history( - &mut self, - path: RepoPath, - ) -> oneshot::Receiver> { - self.file_history_paginated(path, 0, None) - } - - pub fn file_history_paginated( - &mut self, - path: RepoPath, - skip: usize, - limit: Option, - ) -> oneshot::Receiver> { - let id = self.id; - self.send_job(None, move |git_repo, _cx| async move { - match git_repo { - RepositoryState::Local(LocalRepositoryState { backend, .. }) => { - backend.file_history_paginated(path, skip, limit).await - } - RepositoryState::Remote(RemoteRepositoryState { client, project_id }) => { - let response = client - .request(proto::GitFileHistory { - project_id: project_id.0, - repository_id: id.to_proto(), - path: path.to_proto(), - skip: skip as u64, - limit: limit.map(|l| l as u64), - }) - .await?; - Ok(git::repository::FileHistory { - entries: response - .entries - .into_iter() - .map(|entry| git::repository::FileHistoryEntry { - sha: entry.sha.into(), - subject: entry.subject.into(), - message: entry.message.into(), - commit_timestamp: entry.commit_timestamp, - author_name: entry.author_name.into(), - author_email: entry.author_email.into(), - }) - .collect(), - path: RepoPath::from_proto(&response.path)?, - }) - } - } - }) - } - pub fn get_graph_data( &self, log_source: LogSource, diff --git a/crates/proto/proto/git.proto b/crates/proto/proto/git.proto index 9324feb21b1f50ac1041ed0afc8b59cb9b7fe2c6..40d1868d73b0100b6d95c00b91dc3aaec8c87e05 100644 --- a/crates/proto/proto/git.proto +++ b/crates/proto/proto/git.proto @@ -310,29 +310,6 @@ message GitCheckoutFiles { repeated string paths = 5; } -message GitFileHistory { - uint64 project_id = 1; - reserved 2; - uint64 repository_id = 3; - string path = 4; - uint64 skip = 5; - optional uint64 limit = 6; -} - -message GitFileHistoryResponse { - repeated FileHistoryEntry entries = 1; - string path = 2; -} - -message FileHistoryEntry { - string sha = 1; - string subject = 2; - string message = 3; - int64 commit_timestamp = 4; - string author_name = 5; - string author_email = 6; -} - // Move to `git.proto` once collab's min version is >=0.171.0. message StatusEntry { string repo_path = 1; diff --git a/crates/proto/proto/zed.proto b/crates/proto/proto/zed.proto index 8b62754d7af40b7c4f5e1a87ad42899d682ba453..9d49d961d79a368ab1590f19d8a8f17e6225613d 100644 --- a/crates/proto/proto/zed.proto +++ b/crates/proto/proto/zed.proto @@ -423,8 +423,7 @@ message Envelope { OpenImageResponse open_image_response = 392; CreateImageForPeer create_image_for_peer = 393; - GitFileHistory git_file_history = 397; - GitFileHistoryResponse git_file_history_response = 398; + // 397-398 reserved (was GitFileHistory) RunGitHook run_git_hook = 399; diff --git a/crates/proto/src/proto.rs b/crates/proto/src/proto.rs index b77bd02313c13a9b04eb7762a97f9e77ac8cbaf8..25a7b797aed9454720a00ad1fc9a52a3c7e41d59 100644 --- a/crates/proto/src/proto.rs +++ b/crates/proto/src/proto.rs @@ -292,8 +292,6 @@ messages!( (GitCheckoutFiles, Background), (GitShow, Background), (GitCommitDetails, Background), - (GitFileHistory, Background), - (GitFileHistoryResponse, Background), (GitCreateCheckpoint, Background), (GitCreateCheckpointResponse, Background), (GitRestoreCheckpoint, Background), @@ -522,7 +520,6 @@ request_messages!( (InstallExtension, Ack), (RegisterBufferWithLanguageServers, Ack), (GitShow, GitCommitDetails), - (GitFileHistory, GitFileHistoryResponse), (GitCreateCheckpoint, GitCreateCheckpointResponse), (GitRestoreCheckpoint, Ack), (GitCompareCheckpoints, GitCompareCheckpointsResponse), @@ -709,7 +706,6 @@ entity_messages!( CancelLanguageServerWork, RegisterBufferWithLanguageServers, GitShow, - GitFileHistory, GitCreateCheckpoint, GitRestoreCheckpoint, GitCompareCheckpoints,