Detailed changes
@@ -142,7 +142,7 @@ impl GitRepository for FakeGitRepository {
_commit: String,
_cx: AsyncApp,
) -> BoxFuture<'_, Result<git::repository::CommitDiff>> {
- 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<git::repository::FileHistory>> {
- self.file_history_paginated(path, 0, None)
- }
-
- fn file_history_paginated(
- &self,
- path: RepoPath,
- _skip: usize,
- _limit: Option<usize>,
- ) -> BoxFuture<'_, Result<git::repository::FileHistory>> {
- async move {
- Ok(git::repository::FileHistory {
- entries: Vec::new(),
- path,
- })
- }
- .boxed()
- }
-
fn stage_paths(
&self,
paths: Vec<RepoPath>,
@@ -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<FileHistoryEntry>,
- pub path: RepoPath,
-}
-
#[derive(Debug)]
pub struct CommitDiff {
pub files: Vec<CommitFile>,
@@ -751,13 +735,6 @@ pub trait GitRepository: Send + Sync {
content: Rope,
line_ending: LineEnding,
) -> BoxFuture<'_, Result<crate::blame::Blame>>;
- fn file_history(&self, path: RepoPath) -> BoxFuture<'_, Result<FileHistory>>;
- fn file_history_paginated(
- &self,
- path: RepoPath,
- skip: usize,
- limit: Option<usize>,
- ) -> BoxFuture<'_, Result<FileHistory>>;
/// 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/<name>`).
@@ -1846,92 +1823,6 @@ impl GitRepository for RealGitRepository {
.boxed()
}
- fn file_history(&self, path: RepoPath) -> BoxFuture<'_, Result<FileHistory>> {
- self.file_history_paginated(path, 0, None)
- }
-
- fn file_history_paginated(
- &self,
- path: RepoPath,
- skip: usize,
- limit: Option<usize>,
- ) -> BoxFuture<'_, Result<FileHistory>> {
- 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!("<<COMMIT_END-", "3f8a9c2e-7d4b-4e1a-9f6c-8b5d2a1e4c3f>>",);
-
- 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<String>> {
let git_binary = self.git_binary();
self.executor
@@ -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<Self>) {
@@ -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<gpui::Result<Entity<Self>>> {
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<String> {
+ 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<i32>,
+ pub log_source_value: Option<String>,
+ pub log_order: Option<i32>,
+ pub selected_sha: Option<String>,
+ pub search_query: Option<String>,
+ pub search_case_sensitive: Option<bool>,
+ }
+
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<i32>,
+ log_source_value: Option<String>,
+ log_order: Option<i32>,
+ selected_sha: Option<String>,
+ search_query: Option<String>,
+ search_case_sensitive: Option<bool>
) -> 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<Option<PathBuf>> {
- SELECT repo_working_path
+ ) -> Result<Option<(
+ PathBuf,
+ Option<i32>,
+ Option<String>,
+ Option<i32>,
+ Option<String>,
+ Option<String>,
+ Option<bool>
+ )>> {
+ 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| {
+ <GitGraph as workspace::SerializableItem>::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);
@@ -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<Repository>,
- git_store: WeakEntity<GitStore>,
- workspace: WeakEntity<Workspace>,
- remote: Option<GitRemote>,
- selected_entry: Option<usize>,
- scroll_handle: UniformListScrollHandle,
- focus_handle: FocusHandle,
- loading_more: bool,
- has_more: bool,
-}
-
-impl FileHistoryView {
- pub fn open(
- path: RepoPath,
- git_store: WeakEntity<GitStore>,
- repo: WeakEntity<Repository>,
- workspace: WeakEntity<Workspace>,
- 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::<FileHistoryView>();
- 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<GitStore>,
- repository: Entity<Repository>,
- workspace: WeakEntity<Workspace>,
- _project: Entity<Project>,
- _window: &mut Window,
- cx: &mut Context<Self>,
- ) -> 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<Self>) {
- 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<Self>) {
- 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<Self>,
- ) {
- 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<Self>) {
- 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<Self>) {
- 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<usize>, cx: &mut Context<Self>) {
- 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>) {
- self.open_commit_view(window, cx);
- }
-
- fn open_commit_view(&mut self, window: &mut Window, cx: &mut Context<Self>) {
- 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<SharedString>,
- 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<Self>,
- ) -> AnyElement {
- let pr_number = entry
- .subject
- .rfind("(#")
- .and_then(|start| {
- let rest = &entry.subject[start + 2..];
- rest.find(')')
- .and_then(|end| rest[..end].parse::<u32>().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<ItemEvent> 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<Self>) -> 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<SharedString> {
- Some(format!("Git history for {}", self.history.path.as_unix_str()).into())
- }
-
- fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
- Some(Icon::new(IconName::GitBranch))
- }
-
- fn telemetry_event_text(&self) -> Option<&'static str> {
- Some("file history")
- }
-
- fn clone_on_split(
- &self,
- _workspace_id: Option<workspace::WorkspaceId>,
- _window: &mut Window,
- _cx: &mut Context<Self>,
- ) -> Task<Option<Entity<Self>>> {
- Task::ready(None)
- }
-
- fn navigate(
- &mut self,
- _: Arc<dyn Any + Send>,
- _window: &mut Window,
- _: &mut Context<Self>,
- ) -> bool {
- false
- }
-
- fn deactivated(&mut self, _window: &mut Window, _: &mut Context<Self>) {}
-
- fn can_save(&self, _: &App) -> bool {
- false
- }
-
- fn save(
- &mut self,
- _options: SaveOptions,
- _project: Entity<Project>,
- _window: &mut Window,
- _: &mut Context<Self>,
- ) -> Task<Result<()>> {
- Task::ready(Ok(()))
- }
-
- fn save_as(
- &mut self,
- _project: Entity<Project>,
- _path: ProjectPath,
- _window: &mut Window,
- _: &mut Context<Self>,
- ) -> Task<Result<()>> {
- Task::ready(Ok(()))
- }
-
- fn reload(
- &mut self,
- _project: Entity<Project>,
- _window: &mut Window,
- _: &mut Context<Self>,
- ) -> Task<Result<()>> {
- Task::ready(Ok(()))
- }
-
- fn is_dirty(&self, _: &App) -> bool {
- false
- }
-
- fn has_conflict(&self, _: &App) -> bool {
- false
- }
-
- fn breadcrumbs(
- &self,
- _cx: &App,
- ) -> Option<(Vec<workspace::item::HighlightedText>, Option<gpui::Font>)> {
- None
- }
-
- fn added_to_workspace(
- &mut self,
- _workspace: &mut Workspace,
- window: &mut Window,
- cx: &mut Context<Self>,
- ) {
- window.focus(&self.focus_handle, cx);
- }
-
- fn show_toolbar(&self) -> bool {
- true
- }
-
- fn pixel_position_of_cursor(&self, _: &App) -> Option<gpui::Point<gpui::Pixels>> {
- None
- }
-
- fn set_nav_history(
- &mut self,
- _: workspace::ItemNavHistory,
- _window: &mut Window,
- _: &mut Context<Self>,
- ) {
- }
-
- fn act_as_type<'a>(
- &'a self,
- type_id: TypeId,
- self_handle: &'a Entity<Self>,
- _: &'a App,
- ) -> Option<AnyEntity> {
- if type_id == TypeId::of::<Self>() {
- Some(self_handle.clone().into())
- } else {
- None
- }
- }
-}
@@ -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;
@@ -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<Repository>,
- path: RepoPath,
- cx: &mut App,
- ) -> Task<Result<git::repository::FileHistory>> {
- 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<Repository>,
- path: RepoPath,
- skip: usize,
- limit: Option<usize>,
- cx: &mut App,
- ) -> Task<Result<git::repository::FileHistory>> {
- 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<Buffer>,
@@ -2767,40 +2742,6 @@ impl GitStore {
})
}
- async fn handle_file_history(
- this: Entity<Self>,
- envelope: TypedEnvelope<proto::GitFileHistory>,
- mut cx: AsyncApp,
- ) -> Result<proto::GitFileHistoryResponse> {
- 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<Self>,
envelope: TypedEnvelope<proto::GitReset>,
@@ -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<Result<git::repository::FileHistory>> {
- self.file_history_paginated(path, 0, None)
- }
-
- pub fn file_history_paginated(
- &mut self,
- path: RepoPath,
- skip: usize,
- limit: Option<usize>,
- ) -> oneshot::Receiver<Result<git::repository::FileHistory>> {
- 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,
@@ -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;
@@ -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;
@@ -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,