diff --git a/Cargo.lock b/Cargo.lock index fd93fbfeb3..3531ac8877 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5113,6 +5113,7 @@ dependencies = [ "collections", "db", "editor", + "futures 0.3.31", "git", "gpui", "language", @@ -5123,6 +5124,7 @@ dependencies = [ "serde_derive", "serde_json", "settings", + "sum_tree", "theme", "ui", "util", diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index ac4b27f2b0..86fb9a8aa1 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -682,6 +682,38 @@ "space": "project_panel::Open" } }, + { + "context": "GitPanel && !CommitEditor", + "use_key_equivalents": true, + "bindings": { + "escape": "git_panel::Close" + } + }, + { + "context": "GitPanel && ChangesList", + "use_key_equivalents": true, + "bindings": { + "up": "menu::SelectPrev", + "down": "menu::SelectNext", + "cmd-up": "menu::SelectFirst", + "cmd-down": "menu::SelectLast", + "enter": "menu::Confirm", + "space": "git::ToggleStaged", + "cmd-shift-space": "git::StageAll", + "ctrl-shift-space": "git::UnstageAll", + "alt-down": "git_panel::FocusEditor" + } + }, + { + "context": "GitPanel && CommitEditor > Editor", + "use_key_equivalents": true, + "bindings": { + "alt-up": "git_panel::FocusChanges", + "escape": "git_panel::FocusChanges", + "cmd-enter": "git::CommitChanges", + "cmd-alt-enter": "git::CommitAllChanges" + } + }, { "context": "CollabPanel && not_editing", "use_key_equivalents": true, diff --git a/crates/collab/src/tests/random_project_collaboration_tests.rs b/crates/collab/src/tests/random_project_collaboration_tests.rs index 8b8837fc19..23b380ddaa 100644 --- a/crates/collab/src/tests/random_project_collaboration_tests.rs +++ b/crates/collab/src/tests/random_project_collaboration_tests.rs @@ -1221,7 +1221,7 @@ impl RandomizedTest for ProjectCollaborationTest { id, guest_project.remote_id(), ); - assert_eq!(guest_snapshot.repositories().collect::>(), host_snapshot.repositories().collect::>(), + assert_eq!(guest_snapshot.repositories().iter().collect::>(), host_snapshot.repositories().iter().collect::>(), "{} has different repositories than the host for worktree {:?} and project {:?}", client.username, host_snapshot.abs_path(), diff --git a/crates/editor/src/git/project_diff.rs b/crates/editor/src/git/project_diff.rs index f06841e445..4acadad41e 100644 --- a/crates/editor/src/git/project_diff.rs +++ b/crates/editor/src/git/project_diff.rs @@ -197,9 +197,10 @@ impl ProjectDiffEditor { let snapshot = worktree.read(cx).snapshot(); let applicable_entries = snapshot .repositories() + .iter() .flat_map(|entry| { entry.status().map(|git_entry| { - (git_entry.status, entry.join(git_entry.repo_path)) + (git_entry.combined_status(), entry.join(git_entry.repo_path)) }) }) .filter_map(|(status, path)| { diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index bb890150e5..c5ce533026 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -1,6 +1,7 @@ +use crate::status::GitStatusPair; use crate::GitHostingProviderRegistry; use crate::{blame::Blame, status::GitStatus}; -use anyhow::{Context, Result}; +use anyhow::{anyhow, Context, Result}; use collections::{HashMap, HashSet}; use git2::BranchType; use gpui::SharedString; @@ -15,6 +16,7 @@ use std::{ sync::Arc, }; use sum_tree::MapSeekTarget; +use util::command::new_std_command; use util::ResultExt; #[derive(Clone, Debug, Hash, PartialEq)] @@ -51,6 +53,8 @@ pub trait GitRepository: Send + Sync { /// Returns the path to the repository, typically the `.git` folder. fn dot_git_dir(&self) -> PathBuf; + + fn update_index(&self, stage: &[RepoPath], unstage: &[RepoPath]) -> Result<()>; } impl std::fmt::Debug for dyn GitRepository { @@ -152,7 +156,7 @@ impl GitRepository for RealGitRepository { Ok(_) => Ok(true), Err(e) => match e.code() { git2::ErrorCode::NotFound => Ok(false), - _ => Err(anyhow::anyhow!(e)), + _ => Err(anyhow!(e)), }, } } @@ -196,7 +200,7 @@ impl GitRepository for RealGitRepository { repo.set_head( revision .name() - .ok_or_else(|| anyhow::anyhow!("Branch name could not be retrieved"))?, + .ok_or_else(|| anyhow!("Branch name could not be retrieved"))?, )?; Ok(()) } @@ -228,6 +232,36 @@ impl GitRepository for RealGitRepository { self.hosting_provider_registry.clone(), ) } + + fn update_index(&self, stage: &[RepoPath], unstage: &[RepoPath]) -> Result<()> { + let working_directory = self + .repository + .lock() + .workdir() + .context("failed to read git work directory")? + .to_path_buf(); + if !stage.is_empty() { + let add = new_std_command(&self.git_binary_path) + .current_dir(&working_directory) + .args(["add", "--"]) + .args(stage.iter().map(|p| p.as_ref())) + .status()?; + if !add.success() { + return Err(anyhow!("Failed to stage files: {add}")); + } + } + if !unstage.is_empty() { + let rm = new_std_command(&self.git_binary_path) + .current_dir(&working_directory) + .args(["restore", "--staged", "--"]) + .args(unstage.iter().map(|p| p.as_ref())) + .status()?; + if !rm.success() { + return Err(anyhow!("Failed to unstage files: {rm}")); + } + } + Ok(()) + } } #[derive(Debug, Clone)] @@ -298,18 +332,24 @@ impl GitRepository for FakeGitRepository { let mut entries = state .worktree_statuses .iter() - .filter_map(|(repo_path, status)| { + .filter_map(|(repo_path, status_worktree)| { if path_prefixes .iter() .any(|path_prefix| repo_path.0.starts_with(path_prefix)) { - Some((repo_path.to_owned(), *status)) + Some(( + repo_path.to_owned(), + GitStatusPair { + index_status: None, + worktree_status: Some(*status_worktree), + }, + )) } else { None } }) .collect::>(); - entries.sort_unstable_by(|a, b| a.0.cmp(&b.0)); + entries.sort_unstable_by(|(a, _), (b, _)| a.cmp(&b)); Ok(GitStatus { entries: entries.into(), @@ -363,6 +403,10 @@ impl GitRepository for FakeGitRepository { .with_context(|| format!("failed to get blame for {:?}", path)) .cloned() } + + fn update_index(&self, _stage: &[RepoPath], _unstage: &[RepoPath]) -> Result<()> { + unimplemented!() + } } fn check_path_to_repo_path_errors(relative_file_path: &Path) -> Result<()> { @@ -398,6 +442,7 @@ fn check_path_to_repo_path_errors(relative_file_path: &Path) -> Result<()> { pub enum GitFileStatus { Added, Modified, + // TODO conflicts should be represented by the GitStatusPair Conflict, Deleted, Untracked, @@ -426,6 +471,16 @@ impl GitFileStatus { _ => None, } } + + pub fn from_byte(byte: u8) -> Option { + match byte { + b'M' => Some(GitFileStatus::Modified), + b'A' => Some(GitFileStatus::Added), + b'D' => Some(GitFileStatus::Deleted), + b'?' => Some(GitFileStatus::Untracked), + _ => None, + } + } } pub static WORK_DIRECTORY_REPO_PATH: LazyLock = @@ -453,6 +508,12 @@ impl RepoPath { } } +impl std::fmt::Display for RepoPath { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.to_string_lossy().fmt(f) + } +} + impl From<&Path> for RepoPath { fn from(value: &Path) -> Self { RepoPath::new(value.into()) diff --git a/crates/git/src/status.rs b/crates/git/src/status.rs index 0d62cfaae9..de574f5d21 100644 --- a/crates/git/src/status.rs +++ b/crates/git/src/status.rs @@ -2,9 +2,33 @@ use crate::repository::{GitFileStatus, RepoPath}; use anyhow::{anyhow, Result}; use std::{path::Path, process::Stdio, sync::Arc}; +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GitStatusPair { + // Not both `None`. + pub index_status: Option, + pub worktree_status: Option, +} + +impl GitStatusPair { + pub fn is_staged(&self) -> Option { + match (self.index_status, self.worktree_status) { + (Some(_), None) => Some(true), + (None, Some(_)) => Some(false), + (Some(GitFileStatus::Untracked), Some(GitFileStatus::Untracked)) => Some(false), + (Some(_), Some(_)) => None, + (None, None) => unreachable!(), + } + } + + // TODO reconsider uses of this + pub fn combined(&self) -> GitFileStatus { + self.index_status.or(self.worktree_status).unwrap() + } +} + #[derive(Clone)] pub struct GitStatus { - pub entries: Arc<[(RepoPath, GitFileStatus)]>, + pub entries: Arc<[(RepoPath, GitStatusPair)]>, } impl GitStatus { @@ -20,6 +44,7 @@ impl GitStatus { "status", "--porcelain=v1", "--untracked-files=all", + "--no-renames", "-z", ]) .args(path_prefixes.iter().map(|path_prefix| { @@ -47,36 +72,32 @@ impl GitStatus { let mut entries = stdout .split('\0') .filter_map(|entry| { - if entry.is_char_boundary(3) { - let (status, path) = entry.split_at(3); - let status = status.trim(); - Some(( - RepoPath(Path::new(path).into()), - match status { - "A" => GitFileStatus::Added, - "M" => GitFileStatus::Modified, - "D" => GitFileStatus::Deleted, - "??" => GitFileStatus::Untracked, - _ => return None, - }, - )) - } else { - None + let sep = entry.get(2..3)?; + if sep != " " { + return None; + }; + let path = &entry[3..]; + let status = entry[0..2].as_bytes(); + let index_status = GitFileStatus::from_byte(status[0]); + let worktree_status = GitFileStatus::from_byte(status[1]); + if (index_status, worktree_status) == (None, None) { + return None; } + let path = RepoPath(Path::new(path).into()); + Some(( + path, + GitStatusPair { + index_status, + worktree_status, + }, + )) }) .collect::>(); - entries.sort_unstable_by(|a, b| a.0.cmp(&b.0)); + entries.sort_unstable_by(|(a, _), (b, _)| a.cmp(&b)); Ok(Self { entries: entries.into(), }) } - - pub fn get(&self, path: &Path) -> Option { - self.entries - .binary_search_by(|(repo_path, _)| repo_path.0.as_ref().cmp(path)) - .ok() - .map(|index| self.entries[index].1) - } } impl Default for GitStatus { diff --git a/crates/git_ui/Cargo.toml b/crates/git_ui/Cargo.toml index 120ca92857..0c357cb436 100644 --- a/crates/git_ui/Cargo.toml +++ b/crates/git_ui/Cargo.toml @@ -17,6 +17,7 @@ anyhow.workspace = true collections.workspace = true db.workspace = true editor.workspace = true +futures.workspace = true git.workspace = true gpui.workspace = true language.workspace = true @@ -27,6 +28,7 @@ serde.workspace = true serde_derive.workspace = true serde_json.workspace = true settings.workspace = true +sum_tree.workspace = true theme.workspace = true ui.workspace = true util.workspace = true diff --git a/crates/git_ui/TODO.md b/crates/git_ui/TODO.md deleted file mode 100644 index efbdcf494c..0000000000 --- a/crates/git_ui/TODO.md +++ /dev/null @@ -1,45 +0,0 @@ -### General - -- [x] Disable staging and committing actions for read-only projects - -### List - -- [x] Add uniform list -- [x] Git status item -- [ ] Directory item -- [x] Scrollbar -- [ ] Add indent size setting -- [ ] Add tree settings - -### List Items - -- [x] Checkbox for staging -- [x] Git status icon -- [ ] Context menu - - [ ] Discard Changes - - --- - - [ ] Ignore - - [ ] Ignore directory - - --- - - [ ] Copy path - - [ ] Copy relative path - - --- - - [ ] Reveal in Finder - -### Commit Editor - -- [ ] Add commit editor -- [ ] Add commit message placeholder & add commit message to store -- [ ] Add a way to get the current collaborators & automatically add them to the commit message as co-authors -- [ ] Add action to clear commit message -- [x] Swap commit button between "Commit" and "Commit All" based on modifier key - -### Component Updates - -- [ ] ChangedLineCount (new) - - takes `lines_added: usize, lines_removed: usize`, returns a added/removed badge -- [x] GitStatusIcon (new) -- [ ] Checkbox - - update checkbox design -- [ ] ScrollIndicator - - shows a gradient overlay when more content is available to be scrolled diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index e57145f988..c6ff3fe495 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -1,31 +1,21 @@ +use crate::{first_repository_in_project, first_worktree_repository}; use crate::{ - git_status_icon, settings::GitPanelSettings, CommitAllChanges, CommitStagedChanges, GitState, - RevertAll, StageAll, UnstageAll, + git_status_icon, settings::GitPanelSettings, CommitAllChanges, CommitChanges, GitState, + GitViewMode, RevertAll, StageAll, ToggleStaged, UnstageAll, }; use anyhow::{Context as _, Result}; use db::kvp::KEY_VALUE_STORE; use editor::Editor; -use git::{ - diff::DiffHunk, - repository::{GitFileStatus, RepoPath}, -}; +use git::repository::{GitFileStatus, RepoPath}; +use git::status::GitStatusPair; use gpui::*; use language::Buffer; -use menu::{SelectNext, SelectPrev}; -use project::{EntryKind, Fs, Project, ProjectEntryId, WorktreeId}; +use menu::{SelectFirst, SelectLast, SelectNext, SelectPrev}; +use project::{Fs, Project}; use serde::{Deserialize, Serialize}; use settings::Settings as _; -use std::{ - cell::OnceCell, - collections::HashSet, - ffi::OsStr, - ops::{Deref, Range}, - path::PathBuf, - rc::Rc, - sync::Arc, - time::Duration, - usize, -}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::{collections::HashSet, ops::Range, path::PathBuf, sync::Arc, time::Duration, usize}; use theme::ThemeSettings; use ui::{ prelude::*, Checkbox, Divider, DividerColor, ElevationIndex, Scrollbar, ScrollbarState, Tooltip, @@ -35,9 +25,18 @@ use workspace::{ dock::{DockPosition, Panel, PanelEvent}, Workspace, }; -use worktree::StatusEntry; -actions!(git_panel, [ToggleFocus, OpenEntryMenu]); +actions!( + git_panel, + [ + Close, + ToggleFocus, + OpenMenu, + OpenSelected, + FocusEditor, + FocusChanges + ] +); const GIT_PANEL_KEY: &str = "GitPanel"; @@ -59,35 +58,21 @@ pub enum Event { Focus, } -#[derive(Default, Debug, PartialEq, Eq, Clone)] -pub enum ViewMode { - #[default] - List, - Tree, -} - -pub struct GitStatusEntry {} - -#[derive(Debug, PartialEq, Eq, Clone)] -struct EntryDetails { - filename: String, - display_name: String, - path: RepoPath, - kind: EntryKind, - depth: usize, - is_expanded: bool, - status: Option, - hunks: Rc>>, - index: usize, -} - #[derive(Serialize, Deserialize)] struct SerializedGitPanel { width: Option, } +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct GitListEntry { + depth: usize, + display_name: String, + repo_path: RepoPath, + status: GitStatusPair, + toggle_state: ToggleState, +} + pub struct GitPanel { - // workspace: WeakView, current_modifiers: Modifiers, focus_handle: FocusHandle, fs: Arc, @@ -96,53 +81,25 @@ pub struct GitPanel { project: Model, scroll_handle: UniformListScrollHandle, scrollbar_state: ScrollbarState, - selected_item: Option, - view_mode: ViewMode, + selected_entry: Option, show_scrollbar: bool, - // TODO Reintroduce expanded directories, once we're deriving directories from paths - // expanded_dir_ids: HashMap>, + rebuild_requested: Arc, git_state: Model, commit_editor: View, - // The entries that are currently shown in the panel, aka - // not hidden by folding or such - visible_entries: Vec, + /// The visible entries in the list, accounting for folding & expanded state. + /// + /// At this point it doesn't matter what repository the entry belongs to, + /// as only one repositories' entries are visible in the list at a time. + visible_entries: Vec, width: Option, - // git_diff_editor: Option>, - // git_diff_editor_updates: Task<()>, reveal_in_editor: Task<()>, } -#[derive(Debug, Clone)] -struct WorktreeEntries { - worktree_id: WorktreeId, - // TODO support multiple repositories per worktree - // work_directory: worktree::WorkDirectory, - visible_entries: Vec, - paths: Rc>>, -} - -#[derive(Debug, Clone)] -struct GitPanelEntry { - entry: worktree::StatusEntry, - hunks: Rc>>, -} - -impl Deref for GitPanelEntry { - type Target = worktree::StatusEntry; - - fn deref(&self) -> &Self::Target { - &self.entry - } -} - -impl WorktreeEntries { - fn paths(&self) -> &HashSet { - self.paths.get_or_init(|| { - self.visible_entries - .iter() - .map(|e| (e.entry.repo_path.clone())) - .collect() - }) +fn status_to_toggle_state(status: &GitStatusPair) -> ToggleState { + match status.is_staged() { + Some(true) => ToggleState::Selected, + Some(false) => ToggleState::Unselected, + None => ToggleState::Indeterminate, } } @@ -155,12 +112,14 @@ impl GitPanel { } pub fn new(workspace: &mut Workspace, cx: &mut ViewContext) -> View { - let git_state = GitState::get_global(cx); - let fs = workspace.app_state().fs.clone(); - // let weak_workspace = workspace.weak_handle(); let project = workspace.project().clone(); let language_registry = workspace.app_state().languages.clone(); + let git_state = GitState::get_global(cx); + let current_commit_message = { + let state = git_state.read(cx); + state.commit_message.clone() + }; let git_panel = cx.new_view(|cx: &mut ViewContext| { let focus_handle = cx.focus_handle(); @@ -169,36 +128,103 @@ impl GitPanel { this.hide_scrollbar(cx); }) .detach(); - cx.subscribe(&project, |this, _, event, cx| match event { - project::Event::WorktreeRemoved(_id) => { - // this.expanded_dir_ids.remove(id); - this.update_visible_entries(None, None, cx); - cx.notify(); - } - project::Event::WorktreeOrderChanged => { - this.update_visible_entries(None, None, cx); - cx.notify(); - } - project::Event::WorktreeUpdatedEntries(id, _) - | project::Event::WorktreeAdded(id) - | project::Event::WorktreeUpdatedGitRepositories(id) => { - this.update_visible_entries(Some(*id), None, cx); - cx.notify(); - } - project::Event::Closed => { - // this.git_diff_editor_updates = Task::ready(()); - this.reveal_in_editor = Task::ready(()); - // this.expanded_dir_ids.clear(); - this.visible_entries.clear(); - // this.git_diff_editor = None; - } - _ => {} + cx.subscribe(&project, move |this, project, event, cx| { + use project::Event; + + let first_worktree_id = project.read(cx).worktrees(cx).next().map(|worktree| { + let snapshot = worktree.read(cx).snapshot(); + snapshot.id() + }); + let first_repo_in_project = first_repository_in_project(&project, cx); + + // TODO: Don't get another git_state here + // was running into a borrow issue + let git_state = GitState::get_global(cx); + + match event { + project::Event::WorktreeRemoved(id) => { + git_state.update(cx, |state, _| { + state.all_repositories.remove(id); + let Some((worktree_id, _, _)) = state.active_repository.as_ref() else { + return; + }; + if worktree_id == id { + state.active_repository = first_repo_in_project; + this.schedule_update(); + } + }); + } + project::Event::WorktreeOrderChanged => { + // activate the new first worktree if the first was moved + let Some(first_id) = first_worktree_id else { + return; + }; + git_state.update(cx, |state, _| { + if !state + .active_repository + .as_ref() + .is_some_and(|(id, _, _)| id == &first_id) + { + state.active_repository = first_repo_in_project; + this.schedule_update(); + } + }); + } + Event::WorktreeAdded(id) => { + git_state.update(cx, |state, cx| { + let Some(worktree) = project.read(cx).worktree_for_id(*id, cx) else { + return; + }; + let snapshot = worktree.read(cx).snapshot(); + state + .all_repositories + .insert(*id, snapshot.repositories().clone()); + }); + let Some(first_id) = first_worktree_id else { + return; + }; + git_state.update(cx, |state, _| { + if !state + .active_repository + .as_ref() + .is_some_and(|(id, _, _)| id == &first_id) + { + state.active_repository = first_repo_in_project; + this.schedule_update(); + } + }); + } + project::Event::WorktreeUpdatedEntries(id, _) => { + git_state.update(cx, |state, _| { + if state + .active_repository + .as_ref() + .is_some_and(|(active_id, _, _)| active_id == id) + { + state.active_repository = first_repo_in_project; + this.schedule_update(); + } + }); + } + project::Event::WorktreeUpdatedGitRepositories(_) => { + let Some(first) = first_repo_in_project else { + return; + }; + git_state.update(cx, |state, _| { + state.active_repository = Some(first); + this.schedule_update(); + }); + } + project::Event::Closed => { + this.reveal_in_editor = Task::ready(()); + this.visible_entries.clear(); + // TODO cancel/clear task? + } + _ => {} + }; }) .detach(); - let state = git_state.read(cx); - let current_commit_message = state.commit_message.clone(); - let commit_editor = cx.new_view(|cx| { let theme = ThemeSettings::get_global(cx); @@ -220,7 +246,6 @@ impl GitPanel { } else { commit_editor.set_text("", cx); } - // commit_editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx); commit_editor.set_use_autoclose(false); commit_editor.set_show_gutter(false, cx); commit_editor.set_show_wrap_guides(false, cx); @@ -250,29 +275,59 @@ impl GitPanel { let scroll_handle = UniformListScrollHandle::new(); + git_state.update(cx, |state, cx| { + let mut visible_worktrees = project.read(cx).visible_worktrees(cx); + let Some(first_worktree) = visible_worktrees.next() else { + return; + }; + drop(visible_worktrees); + let snapshot = first_worktree.read(cx).snapshot(); + + if let Some((repo, git_repo)) = + first_worktree_repository(&project, snapshot.id(), cx) + { + state.activate_repository(snapshot.id(), repo, git_repo); + } + }); + + let rebuild_requested = Arc::new(AtomicBool::new(false)); + let flag = rebuild_requested.clone(); + let handle = cx.view().downgrade(); + cx.spawn(|_, mut cx| async move { + loop { + cx.background_executor().timer(UPDATE_DEBOUNCE).await; + if flag.load(Ordering::Relaxed) { + if let Some(this) = handle.upgrade() { + this.update(&mut cx, |this, cx| { + this.update_visible_entries(cx); + }) + .ok(); + } + flag.store(false, Ordering::Relaxed); + } + } + }) + .detach(); + let mut git_panel = Self { - // workspace: weak_workspace, focus_handle: cx.focus_handle(), fs, pending_serialization: Task::ready(None), visible_entries: Vec::new(), current_modifiers: cx.modifiers(), - // expanded_dir_ids: Default::default(), width: Some(px(360.)), scrollbar_state: ScrollbarState::new(scroll_handle.clone()).parent_view(cx.view()), scroll_handle, - selected_item: None, - view_mode: ViewMode::default(), + selected_entry: None, show_scrollbar: !Self::should_autohide_scrollbar(cx), hide_scrollbar_task: None, - // git_diff_editor: Some(diff_display_editor(cx)), - // git_diff_editor_updates: Task::ready(()), + rebuild_requested, commit_editor, git_state, reveal_in_editor: Task::ready(()), project, }; - git_panel.update_visible_entries(None, None, cx); + git_panel.schedule_update(); git_panel }); @@ -280,6 +335,7 @@ impl GitPanel { } fn serialize(&mut self, cx: &mut ViewContext) { + // TODO: we can store stage status here let width = self.width; self.pending_serialization = cx.background_executor().spawn( async move { @@ -295,14 +351,31 @@ impl GitPanel { ); } - fn dispatch_context(&self) -> KeyContext { + fn dispatch_context(&self, cx: &ViewContext) -> KeyContext { let mut dispatch_context = KeyContext::new_with_defaults(); dispatch_context.add("GitPanel"); - dispatch_context.add("menu"); + + if self.is_focused(cx) { + dispatch_context.add("menu"); + dispatch_context.add("ChangesList"); + } + + if self.commit_editor.read(cx).is_focused(cx) { + dispatch_context.add("CommitEditor"); + } dispatch_context } + fn is_focused(&self, cx: &ViewContext) -> bool { + cx.focused() + .map_or(false, |focused| self.focus_handle == focused) + } + + fn close_panel(&mut self, _: &Close, cx: &mut ViewContext) { + cx.emit(PanelEvent::Close); + } + fn focus_in(&mut self, cx: &mut ViewContext) { if !self.focus_handle.contains_focused(cx) { cx.emit(Event::Focus); @@ -347,119 +420,195 @@ impl GitPanel { } fn calculate_depth_and_difference( - entry: &StatusEntry, - visible_worktree_entries: &HashSet, + repo_path: &RepoPath, + visible_entries: &HashSet, ) -> (usize, usize) { - let (depth, difference) = entry - .repo_path - .ancestors() - .skip(1) // Skip the entry itself - .find_map(|ancestor| { - if let Some(parent_entry) = visible_worktree_entries.get(ancestor) { - let entry_path_components_count = entry.repo_path.components().count(); - let parent_path_components_count = parent_entry.components().count(); - let difference = entry_path_components_count - parent_path_components_count; - let depth = parent_entry - .ancestors() - .skip(1) - .filter(|ancestor| visible_worktree_entries.contains(*ancestor)) - .count(); - Some((depth + 1, difference)) - } else { - None - } - }) - .unwrap_or((0, 0)); + let ancestors = repo_path.ancestors().skip(1); + for ancestor in ancestors { + if let Some(parent_entry) = visible_entries.get(ancestor) { + let entry_component_count = repo_path.components().count(); + let parent_component_count = parent_entry.components().count(); - (depth, difference) + let difference = entry_component_count - parent_component_count; + + let parent_depth = parent_entry + .ancestors() + .skip(1) // Skip the parent itself + .filter(|ancestor| visible_entries.contains(*ancestor)) + .count(); + + return (parent_depth + 1, difference); + } + } + + (0, 0) } - fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext) { - let item_count = self - .visible_entries - .iter() - .map(|worktree_entries| worktree_entries.visible_entries.len()) - .sum::(); - if item_count == 0 { - return; - } - let selection = match self.selected_item { - Some(i) => { - if i < item_count - 1 { - self.selected_item = Some(i + 1); - i + 1 - } else { - self.selected_item = Some(0); - 0 - } - } - None => { - self.selected_item = Some(0); - 0 - } - }; - self.scroll_handle - .scroll_to_item(selection, ScrollStrategy::Center); - - let mut hunks = None; - self.for_each_visible_entry(selection..selection + 1, cx, |_, entry, _| { - hunks = Some(entry.hunks.clone()); - }); - if let Some(hunks) = hunks { - self.reveal_entry_in_git_editor(hunks, false, Some(UPDATE_DEBOUNCE), cx); + fn scroll_to_selected_entry(&mut self, cx: &mut ViewContext) { + if let Some(selected_entry) = self.selected_entry { + self.scroll_handle + .scroll_to_item(selected_entry, ScrollStrategy::Center); } cx.notify(); } + fn select_first(&mut self, _: &SelectFirst, cx: &mut ViewContext) { + if self.visible_entries.first().is_some() { + self.selected_entry = Some(0); + self.scroll_to_selected_entry(cx); + } + } + fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext) { - let item_count = self - .visible_entries - .iter() - .map(|worktree_entries| worktree_entries.visible_entries.len()) - .sum::(); + let item_count = self.visible_entries.len(); if item_count == 0 { return; } - let selection = match self.selected_item { - Some(i) => { - if i > 0 { - self.selected_item = Some(i - 1); - i - 1 - } else { - self.selected_item = Some(item_count - 1); - item_count - 1 - } - } - None => { - self.selected_item = Some(0); - 0 - } - }; - self.scroll_handle - .scroll_to_item(selection, ScrollStrategy::Center); - let mut hunks = None; - self.for_each_visible_entry(selection..selection + 1, cx, |_, entry, _| { - hunks = Some(entry.hunks.clone()); - }); - if let Some(hunks) = hunks { - self.reveal_entry_in_git_editor(hunks, false, Some(UPDATE_DEBOUNCE), cx); + if let Some(selected_entry) = self.selected_entry { + let new_selected_entry = if selected_entry > 0 { + selected_entry - 1 + } else { + self.selected_entry = Some(item_count - 1); + item_count - 1 + }; + + self.selected_entry = Some(new_selected_entry); + + self.scroll_to_selected_entry(cx); } cx.notify(); } -} -impl GitPanel { - fn stage_all(&mut self, _: &StageAll, _cx: &mut ViewContext) { - // TODO: Implement stage all - println!("Stage all triggered"); + fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext) { + let item_count = self.visible_entries.len(); + if item_count == 0 { + return; + } + + if let Some(selected_entry) = self.selected_entry { + let new_selected_entry = if selected_entry < item_count - 1 { + selected_entry + 1 + } else { + selected_entry + }; + + self.selected_entry = Some(new_selected_entry); + + self.scroll_to_selected_entry(cx); + } + + cx.notify(); } - fn unstage_all(&mut self, _: &UnstageAll, _cx: &mut ViewContext) { - // TODO: Implement unstage all - println!("Unstage all triggered"); + fn select_last(&mut self, _: &SelectLast, cx: &mut ViewContext) { + if self.visible_entries.last().is_some() { + self.selected_entry = Some(self.visible_entries.len() - 1); + self.scroll_to_selected_entry(cx); + } + } + + fn focus_editor(&mut self, _: &FocusEditor, cx: &mut ViewContext) { + self.commit_editor.update(cx, |editor, cx| { + editor.focus(cx); + }); + cx.notify(); + } + + fn select_first_entry(&mut self, cx: &mut ViewContext) { + if !self.no_entries() && self.selected_entry.is_none() { + self.selected_entry = Some(0); + self.scroll_to_selected_entry(cx); + cx.notify(); + } + } + + fn focus_changes_list(&mut self, _: &FocusChanges, cx: &mut ViewContext) { + self.select_first_entry(cx); + + cx.focus_self(); + cx.notify(); + } + + fn get_selected_entry(&self) -> Option<&GitListEntry> { + self.selected_entry + .and_then(|i| self.visible_entries.get(i)) + } + + fn toggle_staged_for_entry(&self, entry: &GitListEntry, cx: &mut ViewContext) { + self.git_state + .clone() + .update(cx, |state, _| match entry.status.is_staged() { + Some(true) | None => state.unstage_entry(entry.repo_path.clone()), + Some(false) => state.stage_entry(entry.repo_path.clone()), + }); + cx.notify(); + } + + fn toggle_staged_for_selected(&mut self, _: &ToggleStaged, cx: &mut ViewContext) { + if let Some(selected_entry) = self.get_selected_entry() { + self.toggle_staged_for_entry(&selected_entry, cx); + } + } + + fn open_selected(&mut self, _: &menu::Confirm, cx: &mut ViewContext) { + println!("Open Selected triggered!"); + let selected_entry = self.selected_entry; + + if let Some(entry) = selected_entry.and_then(|i| self.visible_entries.get(i)) { + self.open_entry(entry); + + cx.notify(); + } + } + + fn open_entry(&self, entry: &GitListEntry) { + // TODO: Open entry or entry's changes. + println!("Open {} triggered!", entry.repo_path); + + // cx.emit(project_panel::Event::OpenedEntry { + // entry_id, + // focus_opened_item, + // allow_preview, + // }); + // + // workspace + // .open_path_preview( + // ProjectPath { + // worktree_id, + // path: file_path.clone(), + // }, + // None, + // focus_opened_item, + // allow_preview, + // cx, + // ) + // .detach_and_prompt_err("Failed to open file", cx, move |e, _| { + // match e.error_code() { + // ErrorCode::Disconnected => if is_via_ssh { + // Some("Disconnected from SSH host".to_string()) + // } else { + // Some("Disconnected from remote project".to_string()) + // }, + // ErrorCode::UnsharedItem => Some(format!( + // "{} is not shared by the host. This could be because it has been marked as `private`", + // file_path.display() + // )), + // _ => None, + // } + // }); + } + + fn stage_all(&mut self, _: &StageAll, cx: &mut ViewContext) { + self.git_state.update(cx, |state, _| state.stage_all()); + } + + fn unstage_all(&mut self, _: &UnstageAll, cx: &mut ViewContext) { + self.git_state.update(cx, |state, _| { + state.unstage_all(); + }); } fn discard_all(&mut self, _: &RevertAll, _cx: &mut ViewContext) { @@ -468,14 +617,14 @@ impl GitPanel { } fn clear_message(&mut self, cx: &mut ViewContext) { - let git_state = self.git_state.clone(); - git_state.update(cx, |state, _cx| state.clear_message()); + self.git_state + .update(cx, |state, _cx| state.clear_commit_message()); self.commit_editor .update(cx, |editor, cx| editor.set_text("", cx)); } /// Commit all staged changes - fn commit_staged_changes(&mut self, _: &CommitStagedChanges, cx: &mut ViewContext) { + fn commit_changes(&mut self, _: &CommitChanges, cx: &mut ViewContext) { self.clear_message(cx); // TODO: Implement commit all staged @@ -500,345 +649,100 @@ impl GitPanel { } fn entry_count(&self) -> usize { - self.visible_entries - .iter() - .map(|worktree_entries| worktree_entries.visible_entries.len()) - .sum() + self.visible_entries.len() } fn for_each_visible_entry( &self, range: Range, cx: &mut ViewContext, - mut callback: impl FnMut(usize, EntryDetails, &mut ViewContext), + mut callback: impl FnMut(usize, GitListEntry, &mut ViewContext), ) { - let mut ix = 0; - for worktree_entries in &self.visible_entries { - if ix >= range.end { - return; - } + let visible_entries = &self.visible_entries; - if ix + worktree_entries.visible_entries.len() <= range.start { - ix += worktree_entries.visible_entries.len(); - continue; - } + for (ix, entry) in visible_entries + .iter() + .enumerate() + .skip(range.start) + .take(range.end - range.start) + { + let status = entry.status.clone(); + let filename = entry + .repo_path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| entry.repo_path.to_string_lossy().into_owned()); - let end_ix = range.end.min(ix + worktree_entries.visible_entries.len()); - // let entry_range = range.start.saturating_sub(ix)..end_ix - ix; - if let Some(worktree) = self - .project - .read(cx) - .worktree_for_id(worktree_entries.worktree_id, cx) - { - let snapshot = worktree.read(cx).snapshot(); - let root_name = OsStr::new(snapshot.root_name()); - // let expanded_entry_ids = self - // .expanded_dir_ids - // .get(&snapshot.id()) - // .map(Vec::as_slice) - // .unwrap_or(&[]); + let details = GitListEntry { + repo_path: entry.repo_path.clone(), + status, + depth: 0, + display_name: filename, + toggle_state: entry.toggle_state, + }; - let entry_range = range.start.saturating_sub(ix)..end_ix - ix; - let entries = worktree_entries.paths(); - - let index_start = entry_range.start; - for (i, entry) in worktree_entries.visible_entries[entry_range] - .iter() - .enumerate() - { - let index = index_start + i; - let status = entry.status; - let is_expanded = true; //expanded_entry_ids.binary_search(&entry.id).is_ok(); - - let (depth, difference) = Self::calculate_depth_and_difference(entry, entries); - - let filename = match difference { - diff if diff > 1 => entry - .repo_path - .iter() - .skip(entry.repo_path.components().count() - diff) - .collect::() - .to_str() - .unwrap_or_default() - .to_string(), - _ => entry - .repo_path - .file_name() - .map(|name| name.to_string_lossy().into_owned()) - .unwrap_or_else(|| root_name.to_string_lossy().to_string()), - }; - - let details = EntryDetails { - filename, - display_name: entry.repo_path.to_string_lossy().into_owned(), - // TODO get it from StatusEntry? - kind: EntryKind::File, - is_expanded, - path: entry.repo_path.clone(), - status: Some(status), - hunks: entry.hunks.clone(), - depth, - index, - }; - callback(ix, details, cx); - } - } - ix = end_ix; + callback(ix, details, cx); } } - // TODO: Update expanded directory state - // TODO: Updates happen in the main loop, could be long for large workspaces + fn schedule_update(&mut self) { + self.rebuild_requested.store(true, Ordering::Relaxed); + } + #[track_caller] - fn update_visible_entries( - &mut self, - for_worktree: Option, - _new_selected_entry: Option<(WorktreeId, ProjectEntryId)>, - cx: &mut ViewContext, - ) { - let project = self.project.read(cx); - let mut old_entries_removed = false; - let mut after_update = Vec::new(); - self.visible_entries - .retain(|worktree_entries| match for_worktree { - Some(for_worktree) => { - if worktree_entries.worktree_id == for_worktree { - old_entries_removed = true; - false - } else if old_entries_removed { - after_update.push(worktree_entries.clone()); - false - } else { - true - } - } - None => false, - }); - for worktree in project.visible_worktrees(cx) { - let snapshot = worktree.read(cx).snapshot(); - let worktree_id = snapshot.id(); + fn update_visible_entries(&mut self, cx: &mut ViewContext) { + let git_state = self.git_state.read(cx); - if for_worktree.is_some() && for_worktree != Some(worktree_id) { - continue; - } + self.visible_entries.clear(); - let mut visible_worktree_entries = Vec::new(); - // Only use the first repository for now - let repositories = snapshot.repositories().take(1); - // let mut work_directory = None; - for repository in repositories { - visible_worktree_entries.extend(repository.status()); - // work_directory = Some(worktree::WorkDirectory::clone(repository)); - } + let Some((_, repo, _)) = git_state.active_repository().as_ref() else { + // Just clear entries if no repository is active. + cx.notify(); + return; + }; - // TODO use the GitTraversal - // let mut visible_worktree_entries = snapshot - // .entries(false, 0) - // .filter(|entry| !entry.is_external) - // .filter(|entry| entry.git_status.is_some()) - // .cloned() - // .collect::>(); - // snapshot.propagate_git_statuses(&mut visible_worktree_entries); - // project::sort_worktree_entries(&mut visible_worktree_entries); + // First pass - collect all paths + let path_set = HashSet::from_iter(repo.status().map(|entry| entry.repo_path)); - if !visible_worktree_entries.is_empty() { - self.visible_entries.push(WorktreeEntries { - worktree_id, - // work_directory: work_directory.unwrap(), - visible_entries: visible_worktree_entries - .into_iter() - .map(|entry| GitPanelEntry { - entry, - hunks: Rc::default(), - }) - .collect(), - paths: Rc::default(), - }); - } + // Second pass - create entries with proper depth calculation + for entry in repo.status() { + let (depth, difference) = + Self::calculate_depth_and_difference(&entry.repo_path, &path_set); + let toggle_state = status_to_toggle_state(&entry.status); + + let display_name = if difference > 1 { + // Show partial path for deeply nested files + entry + .repo_path + .as_ref() + .iter() + .skip(entry.repo_path.components().count() - difference) + .collect::() + .to_string_lossy() + .into_owned() + } else { + // Just show filename + entry + .repo_path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_default() + }; + + let entry = GitListEntry { + depth, + display_name, + repo_path: entry.repo_path, + status: entry.status, + toggle_state, + }; + + self.visible_entries.push(entry); } - self.visible_entries.extend(after_update); - - // TODO re-implement this - // if let Some((worktree_id, entry_id)) = new_selected_entry { - // self.selected_item = self.visible_entries.iter().enumerate().find_map( - // |(worktree_index, worktree_entries)| { - // if worktree_entries.worktree_id == worktree_id { - // worktree_entries - // .visible_entries - // .iter() - // .position(|entry| entry.id == entry_id) - // .map(|entry_index| { - // worktree_index * worktree_entries.visible_entries.len() - // + entry_index - // }) - // } else { - // None - // } - // }, - // ); - // } - - // let project = self.project.downgrade(); - // self.git_diff_editor_updates = cx.spawn(|git_panel, mut cx| async move { - // cx.background_executor() - // .timer(UPDATE_DEBOUNCE) - // .await; - // let Some(project_buffers) = git_panel - // .update(&mut cx, |git_panel, cx| { - // futures::future::join_all(git_panel.visible_entries.iter_mut().flat_map( - // |worktree_entries| { - // worktree_entries - // .visible_entries - // .iter() - // .filter_map(|entry| { - // let git_status = entry.status; - // let entry_hunks = entry.hunks.clone(); - // let (entry_path, unstaged_changes_task) = - // project.update(cx, |project, cx| { - // let entry_path = ProjectPath { - // worktree_id: worktree_entries.worktree_id, - // path: worktree_entries.work_directory.unrelativize(&entry.repo_path)?, - // }; - // let open_task = - // project.open_path(entry_path.clone(), cx); - // let unstaged_changes_task = - // cx.spawn(|project, mut cx| async move { - // let (_, opened_model) = open_task - // .await - // .context("opening buffer")?; - // let buffer = opened_model - // .downcast::() - // .map_err(|_| { - // anyhow::anyhow!( - // "accessing buffer for entry" - // ) - // })?; - // // TODO added files have noop changes and those are not expanded properly in the multi buffer - // let unstaged_changes = project - // .update(&mut cx, |project, cx| { - // project.open_unstaged_changes( - // buffer.clone(), - // cx, - // ) - // })? - // .await - // .context("opening unstaged changes")?; - - // let hunks = cx.update(|cx| { - // entry_hunks - // .get_or_init(|| { - // match git_status { - // GitFileStatus::Added => { - // let buffer_snapshot = buffer.read(cx).snapshot(); - // let entire_buffer_range = - // buffer_snapshot.anchor_after(0) - // ..buffer_snapshot - // .anchor_before( - // buffer_snapshot.len(), - // ); - // let entire_buffer_point_range = - // entire_buffer_range - // .clone() - // .to_point(&buffer_snapshot); - - // vec![DiffHunk { - // row_range: entire_buffer_point_range - // .start - // .row - // ..entire_buffer_point_range - // .end - // .row, - // buffer_range: entire_buffer_range, - // diff_base_byte_range: 0..0, - // }] - // } - // GitFileStatus::Modified => { - // let buffer_snapshot = - // buffer.read(cx).snapshot(); - // unstaged_changes.read(cx) - // .diff_to_buffer - // .hunks_in_row_range( - // 0..BufferRow::MAX, - // &buffer_snapshot, - // ) - // .collect() - // } - // // TODO support these - // GitFileStatus::Conflict | GitFileStatus::Deleted | GitFileStatus::Untracked => Vec::new(), - // } - // }).clone() - // })?; - - // anyhow::Ok((buffer, unstaged_changes, hunks)) - // }); - // Some((entry_path, unstaged_changes_task)) - // }).ok()??; - // Some((entry_path, unstaged_changes_task)) - // }) - // .map(|(entry_path, open_task)| async move { - // (entry_path, open_task.await) - // }) - // .collect::>() - // }, - // )) - // }) - // .ok() - // else { - // return; - // }; - - // let project_buffers = project_buffers.await; - // if project_buffers.is_empty() { - // return; - // } - // let mut change_sets = Vec::with_capacity(project_buffers.len()); - // if let Some(buffer_update_task) = git_panel - // .update(&mut cx, |git_panel, cx| { - // let editor = git_panel.git_diff_editor.clone()?; - // let multi_buffer = editor.read(cx).buffer().clone(); - // let mut buffers_with_ranges = Vec::with_capacity(project_buffers.len()); - // for (buffer_path, open_result) in project_buffers { - // if let Some((buffer, unstaged_changes, diff_hunks)) = open_result - // .with_context(|| format!("opening buffer {buffer_path:?}")) - // .log_err() - // { - // change_sets.push(unstaged_changes); - // buffers_with_ranges.push(( - // buffer, - // diff_hunks - // .into_iter() - // .map(|hunk| hunk.buffer_range) - // .collect(), - // )); - // } - // } - - // Some(multi_buffer.update(cx, |multi_buffer, cx| { - // multi_buffer.clear(cx); - // multi_buffer.push_multiple_excerpts_with_context_lines( - // buffers_with_ranges, - // DEFAULT_MULTIBUFFER_CONTEXT, - // cx, - // ) - // })) - // }) - // .ok().flatten() - // { - // buffer_update_task.await; - // git_panel - // .update(&mut cx, |git_panel, cx| { - // if let Some(diff_editor) = git_panel.git_diff_editor.as_ref() { - // diff_editor.update(cx, |editor, cx| { - // for change_set in change_sets { - // editor.add_change_set(change_set, cx); - // } - // }); - // } - // }) - // .ok(); - // } - // }); + // Sort entries by path to maintain consistent order + self.visible_entries + .sort_by(|a, b| a.repo_path.cmp(&b.repo_path)); cx.notify(); } @@ -860,6 +764,7 @@ impl GitPanel { } } +// GitPanel –– Render impl GitPanel { pub fn panel_button( &self, @@ -947,14 +852,14 @@ impl GitPanel { let focus_handle = focus_handle_1.clone(); Tooltip::for_action_in( "Commit all staged changes", - &CommitStagedChanges, + &CommitChanges, &focus_handle, cx, ) }) - .on_click(cx.listener(|this, _: &ClickEvent, cx| { - this.commit_staged_changes(&CommitStagedChanges, cx) - })); + .on_click( + cx.listener(|this, _: &ClickEvent, cx| this.commit_changes(&CommitChanges, cx)), + ); let commit_all_button = self .panel_button("commit-all-changes", "Commit All") @@ -1063,26 +968,16 @@ impl GitPanel { } fn render_entries(&self, cx: &mut ViewContext) -> impl IntoElement { - let item_count = self - .visible_entries - .iter() - .map(|worktree_entries| worktree_entries.visible_entries.len()) - .sum(); - let selected_entry = self.selected_item; + let entry_count = self.entry_count(); h_flex() .size_full() .overflow_hidden() .child( - uniform_list(cx.view().clone(), "entries", item_count, { + uniform_list(cx.view().clone(), "entries", entry_count, { move |git_panel, range, cx| { let mut items = Vec::with_capacity(range.end - range.start); - git_panel.for_each_visible_entry(range, cx, |id, details, cx| { - items.push(git_panel.render_entry( - id, - Some(details.index) == selected_entry, - details, - cx, - )); + git_panel.for_each_visible_entry(range, cx, |ix, details, cx| { + items.push(git_panel.render_entry(ix, details, cx)); }); items } @@ -1099,18 +994,20 @@ impl GitPanel { fn render_entry( &self, ix: usize, - selected: bool, - details: EntryDetails, + entry_details: GitListEntry, cx: &ViewContext, ) -> impl IntoElement { - let view_mode = self.view_mode.clone(); - let checkbox_id = ElementId::Name(format!("checkbox_{}", ix).into()); - let is_staged = ToggleState::Selected; - let handle = cx.view().downgrade(); + let state = self.git_state.clone(); + let repo_path = entry_details.repo_path.clone(); + let selected = self.selected_entry == Some(ix); - // TODO: At this point, an entry should really have a status. - // Is this fixed with the new git status stuff? - let status = details.status.unwrap_or(GitFileStatus::Untracked); + // TODO revisit, maybe use a different status here? + let status = entry_details.status.combined(); + let entry_id = ElementId::Name(format!("entry_{}", entry_details.display_name).into()); + let checkbox_id = + ElementId::Name(format!("checkbox_{}", entry_details.display_name).into()); + let view_mode = state.read(cx).list_view_mode.clone(); + let handle = cx.view().downgrade(); let end_slot = h_flex() .invisible() @@ -1127,7 +1024,7 @@ impl GitPanel { ); let mut entry = h_flex() - .id(("git-panel-entry", ix)) + .id(entry_id) .group("git-panel-entry") .h(px(28.)) .w_full() @@ -1140,8 +1037,8 @@ impl GitPanel { this.hover(|this| this.bg(cx.theme().colors().ghost_element_hover)) }); - if view_mode == ViewMode::Tree { - entry = entry.pl(px(12. + 12. * details.depth as f32)) + if view_mode == GitViewMode::Tree { + entry = entry.pl(px(12. + 12. * entry_details.depth as f32)) } else { entry = entry.pl(px(12.)) } @@ -1151,129 +1048,70 @@ impl GitPanel { } entry = entry - .child(Checkbox::new(checkbox_id, is_staged)) + .child( + Checkbox::new(checkbox_id, entry_details.toggle_state) + .fill() + .elevation(ElevationIndex::Surface) + .on_click({ + let handle = handle.clone(); + let repo_path = repo_path.clone(); + move |toggle, cx| { + let Some(this) = handle.upgrade() else { + return; + }; + this.update(cx, |this, _| { + this.visible_entries[ix].toggle_state = *toggle; + }); + state.update(cx, { + let repo_path = repo_path.clone(); + move |state, _| match toggle { + ToggleState::Selected | ToggleState::Indeterminate => { + state.stage_entry(repo_path); + } + ToggleState::Unselected => state.unstage_entry(repo_path), + } + }); + } + }), + ) .child(git_status_icon(status)) .child( h_flex() - .gap_1p5() .when(status == GitFileStatus::Deleted, |this| { this.text_color(cx.theme().colors().text_disabled) .line_through() }) - .child(details.display_name.clone()), + .when_some(repo_path.parent(), |this, parent| { + let parent_str = parent.to_string_lossy(); + if !parent_str.is_empty() { + this.child( + div() + .when(status != GitFileStatus::Deleted, |this| { + this.text_color(cx.theme().colors().text_muted) + }) + .child(format!("{}/", parent_str)), + ) + } else { + this + } + }) + .child(div().child(entry_details.display_name.clone())), ) .child(div().flex_1()) .child(end_slot) - // TODO: Only fire this if the entry is not currently revealed, otherwise the ui flashes - .on_click(move |e, cx| { + .on_click(move |_, cx| { + // TODO: add `select_entry` method then do after that + cx.dispatch_action(Box::new(OpenSelected)); + handle - .update(cx, |git_panel, cx| { - git_panel.selected_item = Some(details.index); - let change_focus = e.down.click_count > 1; - git_panel.reveal_entry_in_git_editor( - details.hunks.clone(), - change_focus, - None, - cx, - ); + .update(cx, |git_panel, _| { + git_panel.selected_entry = Some(ix); }) .ok(); }); entry } - - fn reveal_entry_in_git_editor( - &mut self, - _hunks: Rc>>, - _change_focus: bool, - _debounce: Option, - _cx: &mut ViewContext, - ) { - // let workspace = self.workspace.clone(); - // let Some(diff_editor) = self.git_diff_editor.clone() else { - // return; - // }; - // self.reveal_in_editor = cx.spawn(|_, mut cx| async move { - // if let Some(debounce) = debounce { - // cx.background_executor().timer(debounce).await; - // } - - // let Some(editor) = workspace - // .update(&mut cx, |workspace, cx| { - // let git_diff_editor = workspace - // .items_of_type::(cx) - // .find(|editor| &diff_editor == editor); - // match git_diff_editor { - // Some(existing_editor) => { - // workspace.activate_item(&existing_editor, true, change_focus, cx); - // existing_editor - // } - // None => { - // workspace.active_pane().update(cx, |pane, cx| { - // pane.add_item( - // ` diff_editor.boxed_clone(), - // true, - // change_focus, - // None, - // cx, - // ) - // }); - // diff_editor.clone() - // } - // } - // }) - // .ok() - // else { - // return; - // }; - - // if let Some(first_hunk) = hunks.get().and_then(|hunks| hunks.first()) { - // let hunk_buffer_range = &first_hunk.buffer_range; - // if let Some(buffer_id) = hunk_buffer_range - // .start - // .buffer_id - // .or_else(|| first_hunk.buffer_range.end.buffer_id) - // { - // editor - // .update(&mut cx, |editor, cx| { - // let multi_buffer = editor.buffer().read(cx); - // let buffer = multi_buffer.buffer(buffer_id)?; - // let buffer_snapshot = buffer.read(cx).snapshot(); - // let (excerpt_id, _) = multi_buffer - // .excerpts_for_buffer(&buffer, cx) - // .into_iter() - // .find(|(_, excerpt)| { - // hunk_buffer_range - // .start - // .cmp(&excerpt.context.start, &buffer_snapshot) - // .is_ge() - // && hunk_buffer_range - // .end - // .cmp(&excerpt.context.end, &buffer_snapshot) - // .is_le() - // })?; - // let multi_buffer_hunk_start = multi_buffer - // .snapshot(cx) - // .anchor_in_excerpt(excerpt_id, hunk_buffer_range.start)?; - // editor.change_selections( - // Some(Autoscroll::Strategy(AutoscrollStrategy::Center)), - // cx, - // |s| { - // s.select_ranges(Some( - // multi_buffer_hunk_start..multi_buffer_hunk_start, - // )) - // }, - // ); - // cx.notify(); - // Some(()) - // }) - // .ok() - // .flatten(); - // } - // } - // }); - } } impl Render for GitPanel { @@ -1282,24 +1120,35 @@ impl Render for GitPanel { v_flex() .id("git_panel") - .key_context(self.dispatch_context()) + .key_context(self.dispatch_context(cx)) .track_focus(&self.focus_handle) .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed)) .when(!project.is_read_only(cx), |this| { - this.on_action(cx.listener(|this, &StageAll, cx| this.stage_all(&StageAll, cx))) - .on_action( - cx.listener(|this, &UnstageAll, cx| this.unstage_all(&UnstageAll, cx)), - ) - .on_action(cx.listener(|this, &RevertAll, cx| this.discard_all(&RevertAll, cx))) - .on_action(cx.listener(|this, &CommitStagedChanges, cx| { - this.commit_staged_changes(&CommitStagedChanges, cx) - })) - .on_action(cx.listener(|this, &CommitAllChanges, cx| { - this.commit_all_changes(&CommitAllChanges, cx) - })) + this.on_action(cx.listener(|this, &ToggleStaged, cx| { + this.toggle_staged_for_selected(&ToggleStaged, cx) + })) + .on_action(cx.listener(|this, &StageAll, cx| this.stage_all(&StageAll, cx))) + .on_action(cx.listener(|this, &UnstageAll, cx| this.unstage_all(&UnstageAll, cx))) + .on_action(cx.listener(|this, &RevertAll, cx| this.discard_all(&RevertAll, cx))) + .on_action( + cx.listener(|this, &CommitChanges, cx| this.commit_changes(&CommitChanges, cx)), + ) + .on_action(cx.listener(|this, &CommitAllChanges, cx| { + this.commit_all_changes(&CommitAllChanges, cx) + })) }) - .on_action(cx.listener(Self::select_next)) - .on_action(cx.listener(Self::select_prev)) + .when(self.is_focused(cx), |this| { + this.on_action(cx.listener(Self::select_first)) + .on_action(cx.listener(Self::select_next)) + .on_action(cx.listener(Self::select_prev)) + .on_action(cx.listener(Self::select_last)) + .on_action(cx.listener(Self::close_panel)) + }) + .on_action(cx.listener(Self::open_selected)) + .on_action(cx.listener(Self::focus_changes_list)) + .on_action(cx.listener(Self::focus_editor)) + .on_action(cx.listener(Self::toggle_staged_for_selected)) + // .on_action(cx.listener(|this, &OpenSelected, cx| this.open_selected(&OpenSelected, cx))) .on_hover(cx.listener(|this, hovered, cx| { if *hovered { this.show_scrollbar = true; @@ -1384,14 +1233,3 @@ impl Panel for GitPanel { 2 } } - -// fn diff_display_editor(cx: &mut WindowContext) -> View { -// cx.new_view(|cx| { -// let multi_buffer = cx.new_model(|_| { -// MultiBuffer::new(language::Capability::ReadWrite).with_title("Project diff".to_string()) -// }); -// let mut editor = Editor::for_multibuffer(multi_buffer, None, true, cx); -// editor.set_expand_all_diff_hunks(); -// editor -// }) -// } diff --git a/crates/git_ui/src/git_ui.rs b/crates/git_ui/src/git_ui.rs index 89a47d884c..cf9effc111 100644 --- a/crates/git_ui/src/git_ui.rs +++ b/crates/git_ui/src/git_ui.rs @@ -1,56 +1,282 @@ use ::settings::Settings; -use git::repository::GitFileStatus; -use gpui::{actions, AppContext, Context, Global, Hsla, Model}; +use collections::HashMap; +use futures::{future::FusedFuture, select, FutureExt}; +use git::repository::{GitFileStatus, GitRepository, RepoPath}; +use gpui::{actions, AppContext, Context, Global, Hsla, Model, ModelContext}; +use project::{Project, WorktreeId}; use settings::GitPanelSettings; +use std::sync::mpsc; +use std::{ + pin::{pin, Pin}, + sync::Arc, + time::Duration, +}; +use sum_tree::SumTree; use ui::{Color, Icon, IconName, IntoElement, SharedString}; +use worktree::RepositoryEntry; pub mod git_panel; mod settings; +const GIT_TASK_DEBOUNCE: Duration = Duration::from_millis(50); + actions!( - git_ui, + git, [ + StageFile, + UnstageFile, + ToggleStaged, + // Revert actions are currently in the editor crate: + // editor::RevertFile, + // editor::RevertSelectedHunks StageAll, UnstageAll, RevertAll, - CommitStagedChanges, + CommitChanges, CommitAllChanges, - ClearMessage + ClearCommitMessage ] ); pub fn init(cx: &mut AppContext) { GitPanelSettings::register(cx); - let git_state = cx.new_model(|_cx| GitState::new()); + let git_state = cx.new_model(GitState::new); cx.set_global(GlobalGitState(git_state)); } +#[derive(Default, Debug, PartialEq, Eq, Clone)] +pub enum GitViewMode { + #[default] + List, + Tree, +} + struct GlobalGitState(Model); impl Global for GlobalGitState {} +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum StatusAction { + Stage, + Unstage, +} + pub struct GitState { + /// The current commit message being composed. commit_message: Option, + + /// When a git repository is selected, this is used to track which repository's changes + /// are currently being viewed or modified in the UI. + active_repository: Option<(WorktreeId, RepositoryEntry, Arc)>, + + updater_tx: mpsc::Sender<(Arc, Vec, StatusAction)>, + + all_repositories: HashMap>, + + list_view_mode: GitViewMode, } impl GitState { - pub fn new() -> Self { + pub fn new(cx: &mut ModelContext<'_, Self>) -> Self { + let (updater_tx, updater_rx) = mpsc::channel(); + cx.spawn(|_, cx| async move { + // Long-running task to periodically update git indices based on messages from the panel. + + // We read messages from the channel in batches that refer to the same repository. + // When we read a message whose repository is different from the current batch's repository, + // the batch is finished, and since we can't un-receive this last message, we save it + // to begin the next batch. + let mut leftover_message: Option<( + Arc, + Vec, + StatusAction, + )> = None; + let mut git_task = None; + loop { + let mut timer = cx.background_executor().timer(GIT_TASK_DEBOUNCE).fuse(); + let _result = { + let mut task: Pin<&mut dyn FusedFuture>> = + match git_task.as_mut() { + Some(task) => pin!(task), + // If no git task is running, just wait for the timeout. + None => pin!(std::future::pending().fuse()), + }; + select! { + result = task => { + // Task finished. + git_task = None; + Some(result) + } + _ = timer => None, + } + }; + + // TODO handle failure of the git command + + if git_task.is_none() { + // No git task running now; let's see if we should launch a new one. + let mut to_stage = Vec::new(); + let mut to_unstage = Vec::new(); + let mut current_repo = leftover_message.as_ref().map(|msg| msg.0.clone()); + for (git_repo, paths, action) in leftover_message + .take() + .into_iter() + .chain(updater_rx.try_iter()) + { + if current_repo + .as_ref() + .map_or(false, |repo| !Arc::ptr_eq(repo, &git_repo)) + { + // End of a batch, save this for the next one. + leftover_message = Some((git_repo.clone(), paths, action)); + break; + } else if current_repo.is_none() { + // Start of a batch. + current_repo = Some(git_repo); + } + + if action == StatusAction::Stage { + to_stage.extend(paths); + } else { + to_unstage.extend(paths); + } + } + + // TODO handle the same path being staged and unstaged + + if to_stage.is_empty() && to_unstage.is_empty() { + continue; + } + + if let Some(git_repo) = current_repo { + git_task = Some( + cx.background_executor() + .spawn(async move { git_repo.update_index(&to_stage, &to_unstage) }) + .fuse(), + ); + } + } + } + }) + .detach(); GitState { commit_message: None, + active_repository: None, + updater_tx, + list_view_mode: GitViewMode::default(), + all_repositories: HashMap::default(), } } - pub fn set_message(&mut self, message: Option) { - self.commit_message = message; - } - - pub fn clear_message(&mut self) { - self.commit_message = None; - } - pub fn get_global(cx: &mut AppContext) -> Model { cx.global::().0.clone() } + + pub fn activate_repository( + &mut self, + worktree_id: WorktreeId, + active_repository: RepositoryEntry, + git_repo: Arc, + ) { + self.active_repository = Some((worktree_id, active_repository, git_repo)); + } + + pub fn active_repository( + &self, + ) -> Option<&(WorktreeId, RepositoryEntry, Arc)> { + self.active_repository.as_ref() + } + + pub fn commit_message(&mut self, message: Option) { + self.commit_message = message; + } + + pub fn clear_commit_message(&mut self) { + self.commit_message = None; + } + + pub fn stage_entry(&mut self, repo_path: RepoPath) { + if let Some((_, _, git_repo)) = self.active_repository.as_ref() { + let _ = self + .updater_tx + .send((git_repo.clone(), vec![repo_path], StatusAction::Stage)); + } + } + + pub fn unstage_entry(&mut self, repo_path: RepoPath) { + if let Some((_, _, git_repo)) = self.active_repository.as_ref() { + let _ = + self.updater_tx + .send((git_repo.clone(), vec![repo_path], StatusAction::Unstage)); + } + } + + pub fn stage_entries(&mut self, entries: Vec) { + if let Some((_, _, git_repo)) = self.active_repository.as_ref() { + let _ = self + .updater_tx + .send((git_repo.clone(), entries, StatusAction::Stage)); + } + } + + fn act_on_all(&mut self, action: StatusAction) { + if let Some((_, active_repository, git_repo)) = self.active_repository.as_ref() { + let _ = self.updater_tx.send(( + git_repo.clone(), + active_repository + .status() + .map(|entry| entry.repo_path) + .collect(), + action, + )); + } + } + + pub fn stage_all(&mut self) { + self.act_on_all(StatusAction::Stage); + } + + pub fn unstage_all(&mut self) { + self.act_on_all(StatusAction::Unstage); + } +} + +pub fn first_worktree_repository( + project: &Model, + worktree_id: WorktreeId, + cx: &mut AppContext, +) -> Option<(RepositoryEntry, Arc)> { + project + .read(cx) + .worktree_for_id(worktree_id, cx) + .and_then(|worktree| { + let snapshot = worktree.read(cx).snapshot(); + let repo = snapshot.repositories().iter().next()?.clone(); + let git_repo = worktree + .read(cx) + .as_local()? + .get_local_repo(&repo)? + .repo() + .clone(); + Some((repo, git_repo)) + }) +} + +pub fn first_repository_in_project( + project: &Model, + cx: &mut AppContext, +) -> Option<(WorktreeId, RepositoryEntry, Arc)> { + project.read(cx).worktrees(cx).next().and_then(|worktree| { + let snapshot = worktree.read(cx).snapshot(); + let repo = snapshot.repositories().iter().next()?.clone(); + let git_repo = worktree + .read(cx) + .as_local()? + .get_local_repo(&repo)? + .repo() + .clone(); + Some((snapshot.id(), repo, git_repo)) + }) } const ADDED_COLOR: Hsla = Hsla { diff --git a/crates/ui/src/components/button/button_like.rs b/crates/ui/src/components/button/button_like.rs index 26ef2fc19f..f0c69c79c4 100644 --- a/crates/ui/src/components/button/button_like.rs +++ b/crates/ui/src/components/button/button_like.rs @@ -487,6 +487,7 @@ impl RenderOnce for ButtonLike { self.base .h_flex() .id(self.id.clone()) + .font_ui(cx) .group("") .flex_none() .h(self.height.unwrap_or(self.size.rems().into())) diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index b2ced35e75..3f8c113db6 100644 --- a/crates/worktree/src/worktree.rs +++ b/crates/worktree/src/worktree.rs @@ -21,6 +21,7 @@ use fuzzy::CharBag; use git::GitHostingProviderRegistry; use git::{ repository::{GitFileStatus, GitRepository, RepoPath}, + status::GitStatusPair, COOKIES, DOT_GIT, FSMONITOR_DAEMON, GITIGNORE, }; use gpui::{ @@ -193,8 +194,8 @@ pub struct RepositoryEntry { /// - my_sub_folder_1/project_root/changed_file_1 /// - my_sub_folder_2/changed_file_2 pub(crate) statuses_by_path: SumTree, - pub(crate) work_directory_id: ProjectEntryId, - pub(crate) work_directory: WorkDirectory, + pub work_directory_id: ProjectEntryId, + pub work_directory: WorkDirectory, pub(crate) branch: Option>, } @@ -225,6 +226,12 @@ impl RepositoryEntry { self.statuses_by_path.iter().cloned() } + pub fn status_for_path(&self, path: &RepoPath) -> Option { + self.statuses_by_path + .get(&PathKey(path.0.clone()), &()) + .cloned() + } + pub fn initial_update(&self) -> proto::RepositoryEntry { proto::RepositoryEntry { work_directory_id: self.work_directory_id.to_proto(), @@ -234,7 +241,7 @@ impl RepositoryEntry { .iter() .map(|entry| proto::StatusEntry { repo_path: entry.repo_path.to_string_lossy().to_string(), - status: git_status_to_proto(entry.status), + status: status_pair_to_proto(entry.status.clone()), }) .collect(), removed_statuses: Default::default(), @@ -259,7 +266,7 @@ impl RepositoryEntry { current_new_entry = new_statuses.next(); } Ordering::Equal => { - if new_entry.status != old_entry.status { + if new_entry.combined_status() != old_entry.combined_status() { updated_statuses.push(new_entry.to_proto()); } current_old_entry = old_statuses.next(); @@ -2360,7 +2367,7 @@ impl Snapshot { let repo_path = repo.relativize(path).unwrap(); repo.statuses_by_path .get(&PathKey(repo_path.0), &()) - .map(|entry| entry.status) + .map(|entry| entry.combined_status()) }) } @@ -2574,8 +2581,8 @@ impl Snapshot { .map(|repo| repo.status().collect()) } - pub fn repositories(&self) -> impl Iterator { - self.repositories.iter() + pub fn repositories(&self) -> &SumTree { + &self.repositories } /// Get the repository whose work directory corresponds to the given path. @@ -2609,7 +2616,7 @@ impl Snapshot { entries: impl 'a + Iterator, ) -> impl 'a + Iterator)> { let mut containing_repos = Vec::<&RepositoryEntry>::new(); - let mut repositories = self.repositories().peekable(); + let mut repositories = self.repositories().iter().peekable(); entries.map(move |entry| { while let Some(repository) = containing_repos.last() { if repository.directory_contains(&entry.path) { @@ -3626,14 +3633,31 @@ pub type UpdatedGitRepositoriesSet = Arc<[(Arc, GitRepositoryChange)]>; #[derive(Clone, Debug, PartialEq, Eq)] pub struct StatusEntry { pub repo_path: RepoPath, - pub status: GitFileStatus, + pub status: GitStatusPair, } impl StatusEntry { + // TODO revisit uses of this + pub fn combined_status(&self) -> GitFileStatus { + self.status.combined() + } + + pub fn index_status(&self) -> Option { + self.status.index_status + } + + pub fn worktree_status(&self) -> Option { + self.status.worktree_status + } + + pub fn is_staged(&self) -> Option { + self.status.is_staged() + } + fn to_proto(&self) -> proto::StatusEntry { proto::StatusEntry { repo_path: self.repo_path.to_proto(), - status: git_status_to_proto(self.status), + status: status_pair_to_proto(self.status.clone()), } } } @@ -3641,11 +3665,10 @@ impl StatusEntry { impl TryFrom for StatusEntry { type Error = anyhow::Error; fn try_from(value: proto::StatusEntry) -> Result { - Ok(Self { - repo_path: RepoPath(Path::new(&value.repo_path).into()), - status: git_status_from_proto(Some(value.status)) - .ok_or_else(|| anyhow!("Unable to parse status value {}", value.status))?, - }) + let repo_path = RepoPath(Path::new(&value.repo_path).into()); + let status = status_pair_from_proto(value.status) + .ok_or_else(|| anyhow!("Unable to parse status value {}", value.status))?; + Ok(Self { repo_path, status }) } } @@ -3729,7 +3752,7 @@ impl sum_tree::Item for StatusEntry { fn summary(&self, _: &::Context) -> Self::Summary { PathSummary { max_path: self.repo_path.0.clone(), - item_summary: match self.status { + item_summary: match self.combined_status() { GitFileStatus::Added => GitStatuses { added: 1, ..Default::default() @@ -4820,15 +4843,15 @@ impl BackgroundScanner { for (repo_path, status) in &*status.entries { paths.remove_repo_path(repo_path); - if cursor.seek_forward(&PathTarget::Path(&repo_path), Bias::Left, &()) { - if cursor.item().unwrap().status == *status { + if cursor.seek_forward(&PathTarget::Path(repo_path), Bias::Left, &()) { + if &cursor.item().unwrap().status == status { continue; } } changed_path_statuses.push(Edit::Insert(StatusEntry { repo_path: repo_path.clone(), - status: *status, + status: status.clone(), })); } @@ -5257,7 +5280,7 @@ impl BackgroundScanner { new_entries_by_path.insert_or_replace( StatusEntry { repo_path: repo_path.clone(), - status: *status, + status: status.clone(), }, &(), ); @@ -5771,7 +5794,7 @@ impl<'a> GitTraversal<'a> { } else if entry.is_file() { // For a file entry, park the cursor on the corresponding status if statuses.seek_forward(&PathTarget::Path(repo_path.as_ref()), Bias::Left, &()) { - self.current_entry_status = Some(statuses.item().unwrap().status); + self.current_entry_status = Some(statuses.item().unwrap().combined_status()); } } } @@ -6136,19 +6159,23 @@ impl<'a> TryFrom<(&'a CharBag, &PathMatcher, proto::Entry)> for Entry { } } -fn git_status_from_proto(git_status: Option) -> Option { - git_status.and_then(|status| { - proto::GitStatus::from_i32(status).map(|status| match status { - proto::GitStatus::Added => GitFileStatus::Added, - proto::GitStatus::Modified => GitFileStatus::Modified, - proto::GitStatus::Conflict => GitFileStatus::Conflict, - proto::GitStatus::Deleted => GitFileStatus::Deleted, - }) +// TODO pass the status pair all the way through +fn status_pair_from_proto(proto: i32) -> Option { + let proto = proto::GitStatus::from_i32(proto)?; + let worktree_status = match proto { + proto::GitStatus::Added => GitFileStatus::Added, + proto::GitStatus::Modified => GitFileStatus::Modified, + proto::GitStatus::Conflict => GitFileStatus::Conflict, + proto::GitStatus::Deleted => GitFileStatus::Deleted, + }; + Some(GitStatusPair { + index_status: None, + worktree_status: Some(worktree_status), }) } -fn git_status_to_proto(status: GitFileStatus) -> i32 { - match status { +fn status_pair_to_proto(status: GitStatusPair) -> i32 { + match status.combined() { GitFileStatus::Added => proto::GitStatus::Added as i32, GitFileStatus::Modified => proto::GitStatus::Modified as i32, GitFileStatus::Conflict => proto::GitStatus::Conflict as i32, diff --git a/crates/worktree/src/worktree_tests.rs b/crates/worktree/src/worktree_tests.rs index eebb5f9360..5f8144347d 100644 --- a/crates/worktree/src/worktree_tests.rs +++ b/crates/worktree/src/worktree_tests.rs @@ -2179,7 +2179,7 @@ async fn test_rename_work_directory(cx: &mut TestAppContext) { cx.read(|cx| { let tree = tree.read(cx); - let repo = tree.repositories().next().unwrap(); + let repo = tree.repositories().iter().next().unwrap(); assert_eq!(repo.path.as_ref(), Path::new("projects/project1")); assert_eq!( tree.status_for_file(Path::new("projects/project1/a")), @@ -2200,7 +2200,7 @@ async fn test_rename_work_directory(cx: &mut TestAppContext) { cx.read(|cx| { let tree = tree.read(cx); - let repo = tree.repositories().next().unwrap(); + let repo = tree.repositories().iter().next().unwrap(); assert_eq!(repo.path.as_ref(), Path::new("projects/project2")); assert_eq!( tree.status_for_file(Path::new("projects/project2/a")), @@ -2380,8 +2380,8 @@ async fn test_file_status(cx: &mut TestAppContext) { // Check that the right git state is observed on startup tree.read_with(cx, |tree, _cx| { let snapshot = tree.snapshot(); - assert_eq!(snapshot.repositories().count(), 1); - let repo_entry = snapshot.repositories().next().unwrap(); + assert_eq!(snapshot.repositories().iter().count(), 1); + let repo_entry = snapshot.repositories().iter().next().unwrap(); assert_eq!(repo_entry.path.as_ref(), Path::new("project")); assert!(repo_entry.location_in_repo.is_none()); @@ -2554,16 +2554,16 @@ async fn test_git_repository_status(cx: &mut TestAppContext) { // Check that the right git state is observed on startup tree.read_with(cx, |tree, _cx| { let snapshot = tree.snapshot(); - let repo = snapshot.repositories().next().unwrap(); + let repo = snapshot.repositories().iter().next().unwrap(); let entries = repo.status().collect::>(); assert_eq!(entries.len(), 3); assert_eq!(entries[0].repo_path.as_ref(), Path::new("a.txt")); - assert_eq!(entries[0].status, GitFileStatus::Modified); + assert_eq!(entries[0].worktree_status(), Some(GitFileStatus::Modified)); assert_eq!(entries[1].repo_path.as_ref(), Path::new("b.txt")); - assert_eq!(entries[1].status, GitFileStatus::Untracked); + assert_eq!(entries[1].worktree_status(), Some(GitFileStatus::Untracked)); assert_eq!(entries[2].repo_path.as_ref(), Path::new("d.txt")); - assert_eq!(entries[2].status, GitFileStatus::Deleted); + assert_eq!(entries[2].worktree_status(), Some(GitFileStatus::Deleted)); }); std::fs::write(work_dir.join("c.txt"), "some changes").unwrap(); @@ -2576,19 +2576,19 @@ async fn test_git_repository_status(cx: &mut TestAppContext) { tree.read_with(cx, |tree, _cx| { let snapshot = tree.snapshot(); - let repository = snapshot.repositories().next().unwrap(); + let repository = snapshot.repositories().iter().next().unwrap(); let entries = repository.status().collect::>(); std::assert_eq!(entries.len(), 4, "entries: {entries:?}"); assert_eq!(entries[0].repo_path.as_ref(), Path::new("a.txt")); - assert_eq!(entries[0].status, GitFileStatus::Modified); + assert_eq!(entries[0].worktree_status(), Some(GitFileStatus::Modified)); assert_eq!(entries[1].repo_path.as_ref(), Path::new("b.txt")); - assert_eq!(entries[1].status, GitFileStatus::Untracked); + assert_eq!(entries[1].worktree_status(), Some(GitFileStatus::Untracked)); // Status updated assert_eq!(entries[2].repo_path.as_ref(), Path::new("c.txt")); - assert_eq!(entries[2].status, GitFileStatus::Modified); + assert_eq!(entries[2].worktree_status(), Some(GitFileStatus::Modified)); assert_eq!(entries[3].repo_path.as_ref(), Path::new("d.txt")); - assert_eq!(entries[3].status, GitFileStatus::Deleted); + assert_eq!(entries[3].worktree_status(), Some(GitFileStatus::Deleted)); }); git_add("a.txt", &repo); @@ -2609,7 +2609,7 @@ async fn test_git_repository_status(cx: &mut TestAppContext) { tree.read_with(cx, |tree, _cx| { let snapshot = tree.snapshot(); - let repo = snapshot.repositories().next().unwrap(); + let repo = snapshot.repositories().iter().next().unwrap(); let entries = repo.status().collect::>(); // Deleting an untracked entry, b.txt, should leave no status @@ -2621,7 +2621,7 @@ async fn test_git_repository_status(cx: &mut TestAppContext) { &entries ); assert_eq!(entries[0].repo_path.as_ref(), Path::new("a.txt")); - assert_eq!(entries[0].status, GitFileStatus::Deleted); + assert_eq!(entries[0].worktree_status(), Some(GitFileStatus::Deleted)); }); } @@ -2676,8 +2676,8 @@ async fn test_repository_subfolder_git_status(cx: &mut TestAppContext) { // Ensure that the git status is loaded correctly tree.read_with(cx, |tree, _cx| { let snapshot = tree.snapshot(); - assert_eq!(snapshot.repositories().count(), 1); - let repo = snapshot.repositories().next().unwrap(); + assert_eq!(snapshot.repositories().iter().count(), 1); + let repo = snapshot.repositories().iter().next().unwrap(); // Path is blank because the working directory of // the git repository is located at the root of the project assert_eq!(repo.path.as_ref(), Path::new("")); @@ -2707,7 +2707,7 @@ async fn test_repository_subfolder_git_status(cx: &mut TestAppContext) { tree.read_with(cx, |tree, _cx| { let snapshot = tree.snapshot(); - assert!(snapshot.repositories().next().is_some()); + assert!(snapshot.repositories().iter().next().is_some()); assert_eq!(snapshot.status_for_file("c.txt"), None); assert_eq!(snapshot.status_for_file("d/e.txt"), None);