Detailed changes
@@ -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",
@@ -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,
@@ -1221,7 +1221,7 @@ impl RandomizedTest for ProjectCollaborationTest {
id,
guest_project.remote_id(),
);
- assert_eq!(guest_snapshot.repositories().collect::<Vec<_>>(), host_snapshot.repositories().collect::<Vec<_>>(),
+ assert_eq!(guest_snapshot.repositories().iter().collect::<Vec<_>>(), host_snapshot.repositories().iter().collect::<Vec<_>>(),
"{} has different repositories than the host for worktree {:?} and project {:?}",
client.username,
host_snapshot.abs_path(),
@@ -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)| {
@@ -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::<Vec<_>>();
- 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<Self> {
+ 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<RepoPath> =
@@ -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())
@@ -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<GitFileStatus>,
+ pub worktree_status: Option<GitFileStatus>,
+}
+
+impl GitStatusPair {
+ pub fn is_staged(&self) -> Option<bool> {
+ 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::<Vec<_>>();
- 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<GitFileStatus> {
- 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 {
@@ -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
@@ -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
@@ -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,
+#[derive(Serialize, Deserialize)]
+struct SerializedGitPanel {
+ width: Option<Pixels>,
}
-pub struct GitStatusEntry {}
-
#[derive(Debug, PartialEq, Eq, Clone)]
-struct EntryDetails {
- filename: String,
- display_name: String,
- path: RepoPath,
- kind: EntryKind,
+pub struct GitListEntry {
depth: usize,
- is_expanded: bool,
- status: Option<GitFileStatus>,
- hunks: Rc<OnceCell<Vec<DiffHunk>>>,
- index: usize,
-}
-
-#[derive(Serialize, Deserialize)]
-struct SerializedGitPanel {
- width: Option<Pixels>,
+ display_name: String,
+ repo_path: RepoPath,
+ status: GitStatusPair,
+ toggle_state: ToggleState,
}
pub struct GitPanel {
- // workspace: WeakView<Workspace>,
current_modifiers: Modifiers,
focus_handle: FocusHandle,
fs: Arc<dyn Fs>,
@@ -96,53 +81,25 @@ pub struct GitPanel {
project: Model<Project>,
scroll_handle: UniformListScrollHandle,
scrollbar_state: ScrollbarState,
- selected_item: Option<usize>,
- view_mode: ViewMode,
+ selected_entry: Option<usize>,
show_scrollbar: bool,
- // TODO Reintroduce expanded directories, once we're deriving directories from paths
- // expanded_dir_ids: HashMap<WorktreeId, Vec<ProjectEntryId>>,
+ rebuild_requested: Arc<AtomicBool>,
git_state: Model<GitState>,
commit_editor: View<Editor>,
- // The entries that are currently shown in the panel, aka
- // not hidden by folding or such
- visible_entries: Vec<WorktreeEntries>,
+ /// 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<GitListEntry>,
width: Option<Pixels>,
- // git_diff_editor: Option<View<Editor>>,
- // 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<GitPanelEntry>,
- paths: Rc<OnceCell<HashSet<RepoPath>>>,
-}
-
-#[derive(Debug, Clone)]
-struct GitPanelEntry {
- entry: worktree::StatusEntry,
- hunks: Rc<OnceCell<Vec<DiffHunk>>>,
-}
-
-impl Deref for GitPanelEntry {
- type Target = worktree::StatusEntry;
-
- fn deref(&self) -> &Self::Target {
- &self.entry
- }
-}
-
-impl WorktreeEntries {
- fn paths(&self) -> &HashSet<RepoPath> {
- 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<Workspace>) -> View<Self> {
- 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<Self>| {
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<Self>) {
+ // 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<Self>) -> 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<Self>) -> bool {
+ cx.focused()
+ .map_or(false, |focused| self.focus_handle == focused)
+ }
+
+ fn close_panel(&mut self, _: &Close, cx: &mut ViewContext<Self>) {
+ cx.emit(PanelEvent::Close);
+ }
+
fn focus_in(&mut self, cx: &mut ViewContext<Self>) {
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<RepoPath>,
+ repo_path: &RepoPath,
+ visible_entries: &HashSet<RepoPath>,
) -> (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();
+
+ 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();
- (depth, difference)
+ return (parent_depth + 1, difference);
+ }
+ }
+
+ (0, 0)
}
- fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
- let item_count = self
- .visible_entries
- .iter()
- .map(|worktree_entries| worktree_entries.visible_entries.len())
- .sum::<usize>();
+ fn scroll_to_selected_entry(&mut self, cx: &mut ViewContext<Self>) {
+ 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<Self>) {
+ 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<Self>) {
+ let item_count = self.visible_entries.len();
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);
+ 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();
}
- fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
- let item_count = self
- .visible_entries
- .iter()
- .map(|worktree_entries| worktree_entries.visible_entries.len())
- .sum::<usize>();
+ fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
+ 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(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 select_last(&mut self, _: &SelectLast, cx: &mut ViewContext<Self>) {
+ 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>) {
+ self.commit_editor.update(cx, |editor, cx| {
+ editor.focus(cx);
});
- if let Some(hunks) = hunks {
- self.reveal_entry_in_git_editor(hunks, false, Some(UPDATE_DEBOUNCE), cx);
+ cx.notify();
+ }
+
+ fn select_first_entry(&mut self, cx: &mut ViewContext<Self>) {
+ 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>) {
+ self.select_first_entry(cx);
+ cx.focus_self();
cx.notify();
}
-}
-impl GitPanel {
- fn stage_all(&mut self, _: &StageAll, _cx: &mut ViewContext<Self>) {
- // TODO: Implement stage all
- println!("Stage all triggered");
+ 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>) {
+ 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<Self>) {
+ 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<Self>) {
+ 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 unstage_all(&mut self, _: &UnstageAll, _cx: &mut ViewContext<Self>) {
- // TODO: Implement unstage all
- println!("Unstage all triggered");
+ fn stage_all(&mut self, _: &StageAll, cx: &mut ViewContext<Self>) {
+ self.git_state.update(cx, |state, _| state.stage_all());
+ }
+
+ fn unstage_all(&mut self, _: &UnstageAll, cx: &mut ViewContext<Self>) {
+ self.git_state.update(cx, |state, _| {
+ state.unstage_all();
+ });
}
fn discard_all(&mut self, _: &RevertAll, _cx: &mut ViewContext<Self>) {
@@ -468,14 +617,14 @@ impl GitPanel {
}
fn clear_message(&mut self, cx: &mut ViewContext<Self>) {
- 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<Self>) {
+ fn commit_changes(&mut self, _: &CommitChanges, cx: &mut ViewContext<Self>) {
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<usize>,
cx: &mut ViewContext<Self>,
- mut callback: impl FnMut(usize, EntryDetails, &mut ViewContext<Self>),
+ mut callback: impl FnMut(usize, GitListEntry, &mut ViewContext<Self>),
) {
- 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 details = GitListEntry {
+ repo_path: entry.repo_path.clone(),
+ status,
+ depth: 0,
+ display_name: filename,
+ toggle_state: entry.toggle_state,
+ };
- 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 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::<PathBuf>()
- .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<WorktreeId>,
- _new_selected_entry: Option<(WorktreeId, ProjectEntryId)>,
- cx: &mut ViewContext<Self>,
- ) {
- 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<Self>) {
+ 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::<Vec<_>>();
- // snapshot.propagate_git_statuses(&mut visible_worktree_entries);
- // project::sort_worktree_entries(&mut visible_worktree_entries);
-
- 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(),
- });
- }
+ // First pass - collect all paths
+ let path_set = HashSet::from_iter(repo.status().map(|entry| entry.repo_path));
+
+ // 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::<PathBuf>()
+ .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::<Buffer>()
- // .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::<Vec<_>>()
- // },
- // ))
- // })
- // .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();
}
@@ -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<GitState>);
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<SharedString>,
+
+ /// 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<dyn GitRepository>)>,
+
+ updater_tx: mpsc::Sender<(Arc<dyn GitRepository>, Vec<RepoPath>, StatusAction)>,
+
+ all_repositories: HashMap<WorktreeId, SumTree<RepositoryEntry>>,
+
+ 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<dyn GitRepository>,
+ Vec<RepoPath>,
+ 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<Output = anyhow::Result<()>>> =
+ 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<SharedString>) {
+ pub fn get_global(cx: &mut AppContext) -> Model<GitState> {
+ cx.global::<GlobalGitState>().0.clone()
+ }
+
+ pub fn activate_repository(
+ &mut self,
+ worktree_id: WorktreeId,
+ active_repository: RepositoryEntry,
+ git_repo: Arc<dyn GitRepository>,
+ ) {
+ self.active_repository = Some((worktree_id, active_repository, git_repo));
+ }
+
+ pub fn active_repository(
+ &self,
+ ) -> Option<&(WorktreeId, RepositoryEntry, Arc<dyn GitRepository>)> {
+ self.active_repository.as_ref()
+ }
+
+ pub fn commit_message(&mut self, message: Option<SharedString>) {
self.commit_message = message;
}
- pub fn clear_message(&mut self) {
+ pub fn clear_commit_message(&mut self) {
self.commit_message = None;
}
- pub fn get_global(cx: &mut AppContext) -> Model<GitState> {
- cx.global::<GlobalGitState>().0.clone()
+ 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<RepoPath>) {
+ 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<Project>,
+ worktree_id: WorktreeId,
+ cx: &mut AppContext,
+) -> Option<(RepositoryEntry, Arc<dyn GitRepository>)> {
+ 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<Project>,
+ cx: &mut AppContext,
+) -> Option<(WorktreeId, RepositoryEntry, Arc<dyn GitRepository>)> {
+ 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 {
@@ -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()))
@@ -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<StatusEntry>,
- pub(crate) work_directory_id: ProjectEntryId,
- pub(crate) work_directory: WorkDirectory,
+ pub work_directory_id: ProjectEntryId,
+ pub work_directory: WorkDirectory,
pub(crate) branch: Option<Arc<str>>,
}
@@ -225,6 +226,12 @@ impl RepositoryEntry {
self.statuses_by_path.iter().cloned()
}
+ pub fn status_for_path(&self, path: &RepoPath) -> Option<StatusEntry> {
+ 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<Item = &RepositoryEntry> {
- self.repositories.iter()
+ pub fn repositories(&self) -> &SumTree<RepositoryEntry> {
+ &self.repositories
}
/// Get the repository whose work directory corresponds to the given path.
@@ -2609,7 +2616,7 @@ impl Snapshot {
entries: impl 'a + Iterator<Item = &'a Entry>,
) -> impl 'a + Iterator<Item = (&'a Entry, Option<&'a RepositoryEntry>)> {
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<Path>, 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<GitFileStatus> {
+ self.status.index_status
+ }
+
+ pub fn worktree_status(&self) -> Option<GitFileStatus> {
+ self.status.worktree_status
+ }
+
+ pub fn is_staged(&self) -> Option<bool> {
+ 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<proto::StatusEntry> for StatusEntry {
type Error = anyhow::Error;
fn try_from(value: proto::StatusEntry) -> Result<Self, Self::Error> {
- 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, _: &<Self::Summary as Summary>::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<i32>) -> Option<GitFileStatus> {
- 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<GitStatusPair> {
+ 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,
@@ -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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
// 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);