project_diff.rs

  1use std::any::{Any, TypeId};
  2
  3use ::git::UnstageAndNext;
  4use anyhow::Result;
  5use buffer_diff::{BufferDiff, DiffHunkSecondaryStatus};
  6use collections::HashSet;
  7use editor::{
  8    actions::{GoToHunk, GoToPrevHunk},
  9    scroll::Autoscroll,
 10    Editor, EditorEvent, ToPoint,
 11};
 12use feature_flags::FeatureFlagViewExt;
 13use futures::StreamExt;
 14use git::{Commit, StageAll, StageAndNext, ToggleStaged, UnstageAll};
 15use gpui::{
 16    actions, Action, AnyElement, AnyView, App, AppContext as _, AsyncWindowContext, Entity,
 17    EventEmitter, FocusHandle, Focusable, Render, Subscription, Task, WeakEntity,
 18};
 19use language::{Anchor, Buffer, Capability, OffsetRangeExt, Point};
 20use multi_buffer::{MultiBuffer, PathKey};
 21use project::{git::GitStore, Project, ProjectPath};
 22use theme::ActiveTheme;
 23use ui::{prelude::*, vertical_divider, Tooltip};
 24use util::ResultExt as _;
 25use workspace::{
 26    item::{BreadcrumbText, Item, ItemEvent, ItemHandle, TabContentParams},
 27    searchable::SearchableItemHandle,
 28    ItemNavHistory, SerializableItem, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView,
 29    Workspace,
 30};
 31
 32use crate::git_panel::{GitPanel, GitPanelAddon, GitStatusEntry};
 33
 34actions!(git, [Diff]);
 35
 36pub(crate) struct ProjectDiff {
 37    multibuffer: Entity<MultiBuffer>,
 38    editor: Entity<Editor>,
 39    project: Entity<Project>,
 40    git_store: Entity<GitStore>,
 41    workspace: WeakEntity<Workspace>,
 42    focus_handle: FocusHandle,
 43    update_needed: postage::watch::Sender<()>,
 44    pending_scroll: Option<PathKey>,
 45
 46    _task: Task<Result<()>>,
 47    _subscription: Subscription,
 48}
 49
 50struct DiffBuffer {
 51    path_key: PathKey,
 52    buffer: Entity<Buffer>,
 53    diff: Entity<BufferDiff>,
 54}
 55
 56const CONFLICT_NAMESPACE: &'static str = "0";
 57const TRACKED_NAMESPACE: &'static str = "1";
 58const NEW_NAMESPACE: &'static str = "2";
 59
 60impl ProjectDiff {
 61    pub(crate) fn register(
 62        _: &mut Workspace,
 63        window: Option<&mut Window>,
 64        cx: &mut Context<Workspace>,
 65    ) {
 66        let Some(window) = window else { return };
 67        cx.when_flag_enabled::<feature_flags::GitUiFeatureFlag>(window, |workspace, _, _cx| {
 68            workspace.register_action(Self::deploy);
 69        });
 70
 71        workspace::register_serializable_item::<ProjectDiff>(cx);
 72    }
 73
 74    fn deploy(
 75        workspace: &mut Workspace,
 76        _: &Diff,
 77        window: &mut Window,
 78        cx: &mut Context<Workspace>,
 79    ) {
 80        workspace.open_panel::<GitPanel>(window, cx);
 81        Self::deploy_at(workspace, None, window, cx)
 82    }
 83
 84    pub fn deploy_at(
 85        workspace: &mut Workspace,
 86        entry: Option<GitStatusEntry>,
 87        window: &mut Window,
 88        cx: &mut Context<Workspace>,
 89    ) {
 90        let project_diff = if let Some(existing) = workspace.item_of_type::<Self>(cx) {
 91            workspace.activate_item(&existing, true, true, window, cx);
 92            existing
 93        } else {
 94            let workspace_handle = cx.entity();
 95            let project_diff =
 96                cx.new(|cx| Self::new(workspace.project().clone(), workspace_handle, window, cx));
 97            workspace.add_item_to_active_pane(
 98                Box::new(project_diff.clone()),
 99                None,
100                true,
101                window,
102                cx,
103            );
104            project_diff
105        };
106        if let Some(entry) = entry {
107            project_diff.update(cx, |project_diff, cx| {
108                project_diff.scroll_to(entry, window, cx);
109            })
110        }
111    }
112
113    fn new(
114        project: Entity<Project>,
115        workspace: Entity<Workspace>,
116        window: &mut Window,
117        cx: &mut Context<Self>,
118    ) -> Self {
119        let focus_handle = cx.focus_handle();
120        let multibuffer = cx.new(|_| MultiBuffer::new(Capability::ReadWrite));
121
122        let editor = cx.new(|cx| {
123            let mut diff_display_editor = Editor::for_multibuffer(
124                multibuffer.clone(),
125                Some(project.clone()),
126                true,
127                window,
128                cx,
129            );
130            diff_display_editor.set_distinguish_unstaged_diff_hunks();
131            diff_display_editor.set_expand_all_diff_hunks(cx);
132            diff_display_editor.register_addon(GitPanelAddon {
133                workspace: workspace.downgrade(),
134            });
135            diff_display_editor
136        });
137        cx.subscribe_in(&editor, window, Self::handle_editor_event)
138            .detach();
139
140        let git_store = project.read(cx).git_store().clone();
141        let git_store_subscription = cx.subscribe_in(
142            &git_store,
143            window,
144            move |this, _git_store, _event, _window, _cx| {
145                *this.update_needed.borrow_mut() = ();
146            },
147        );
148
149        let (mut send, recv) = postage::watch::channel::<()>();
150        let worker = window.spawn(cx, {
151            let this = cx.weak_entity();
152            |cx| Self::handle_status_updates(this, recv, cx)
153        });
154        // Kick of a refresh immediately
155        *send.borrow_mut() = ();
156
157        Self {
158            project,
159            git_store: git_store.clone(),
160            workspace: workspace.downgrade(),
161            focus_handle,
162            editor,
163            multibuffer,
164            pending_scroll: None,
165            update_needed: send,
166            _task: worker,
167            _subscription: git_store_subscription,
168        }
169    }
170
171    pub fn scroll_to(
172        &mut self,
173        entry: GitStatusEntry,
174        window: &mut Window,
175        cx: &mut Context<Self>,
176    ) {
177        let Some(git_repo) = self.git_store.read(cx).active_repository() else {
178            return;
179        };
180        let repo = git_repo.read(cx);
181
182        let namespace = if repo.has_conflict(&entry.repo_path) {
183            CONFLICT_NAMESPACE
184        } else if entry.status.is_created() {
185            NEW_NAMESPACE
186        } else {
187            TRACKED_NAMESPACE
188        };
189
190        let path_key = PathKey::namespaced(namespace, entry.repo_path.0.clone());
191
192        self.scroll_to_path(path_key, window, cx)
193    }
194
195    fn scroll_to_path(&mut self, path_key: PathKey, window: &mut Window, cx: &mut Context<Self>) {
196        if let Some(position) = self.multibuffer.read(cx).location_for_path(&path_key, cx) {
197            self.editor.update(cx, |editor, cx| {
198                editor.change_selections(Some(Autoscroll::focused()), window, cx, |s| {
199                    s.select_ranges([position..position]);
200                })
201            })
202        } else {
203            self.pending_scroll = Some(path_key);
204        }
205    }
206
207    fn button_states(&self, cx: &App) -> ButtonStates {
208        let editor = self.editor.read(cx);
209        let snapshot = self.multibuffer.read(cx).snapshot(cx);
210        let prev_next = snapshot.diff_hunks().skip(1).next().is_some();
211        let mut selection = true;
212
213        let mut ranges = editor
214            .selections
215            .disjoint_anchor_ranges()
216            .collect::<Vec<_>>();
217        if !ranges.iter().any(|range| range.start != range.end) {
218            selection = false;
219            if let Some((excerpt_id, buffer, range)) = self.editor.read(cx).active_excerpt(cx) {
220                ranges = vec![multi_buffer::Anchor::range_in_buffer(
221                    excerpt_id,
222                    buffer.read(cx).remote_id(),
223                    range,
224                )];
225            } else {
226                ranges = Vec::default();
227            }
228        }
229        let mut has_staged_hunks = false;
230        let mut has_unstaged_hunks = false;
231        for hunk in editor.diff_hunks_in_ranges(&ranges, &snapshot) {
232            match hunk.secondary_status {
233                DiffHunkSecondaryStatus::HasSecondaryHunk => {
234                    has_unstaged_hunks = true;
235                }
236                DiffHunkSecondaryStatus::OverlapsWithSecondaryHunk => {
237                    has_staged_hunks = true;
238                    has_unstaged_hunks = true;
239                }
240                DiffHunkSecondaryStatus::None => {
241                    has_staged_hunks = true;
242                }
243            }
244        }
245        let mut commit = false;
246        let mut stage_all = false;
247        let mut unstage_all = false;
248        self.workspace
249            .read_with(cx, |workspace, cx| {
250                if let Some(git_panel) = workspace.panel::<GitPanel>(cx) {
251                    let git_panel = git_panel.read(cx);
252                    commit = git_panel.can_commit();
253                    stage_all = git_panel.can_stage_all();
254                    unstage_all = git_panel.can_unstage_all();
255                }
256            })
257            .ok();
258
259        return ButtonStates {
260            stage: has_unstaged_hunks,
261            unstage: has_staged_hunks,
262            prev_next,
263            selection,
264            commit,
265            stage_all,
266            unstage_all,
267        };
268    }
269
270    fn handle_editor_event(
271        &mut self,
272        editor: &Entity<Editor>,
273        event: &EditorEvent,
274        window: &mut Window,
275        cx: &mut Context<Self>,
276    ) {
277        match event {
278            EditorEvent::ScrollPositionChanged { .. } => editor.update(cx, |editor, cx| {
279                let anchor = editor.scroll_manager.anchor().anchor;
280                let multibuffer = self.multibuffer.read(cx);
281                let snapshot = multibuffer.snapshot(cx);
282                let mut point = anchor.to_point(&snapshot);
283                point.row = (point.row + 1).min(snapshot.max_row().0);
284
285                let Some((_, buffer, _)) = self.multibuffer.read(cx).excerpt_containing(point, cx)
286                else {
287                    return;
288                };
289                let Some(project_path) = buffer
290                    .read(cx)
291                    .file()
292                    .map(|file| (file.worktree_id(cx), file.path().clone()))
293                else {
294                    return;
295                };
296                self.workspace
297                    .update(cx, |workspace, cx| {
298                        if let Some(git_panel) = workspace.panel::<GitPanel>(cx) {
299                            git_panel.update(cx, |git_panel, cx| {
300                                git_panel.select_entry_by_path(project_path.into(), window, cx)
301                            })
302                        }
303                    })
304                    .ok();
305            }),
306            _ => {}
307        }
308    }
309
310    fn load_buffers(&mut self, cx: &mut Context<Self>) -> Vec<Task<Result<DiffBuffer>>> {
311        let Some(repo) = self.git_store.read(cx).active_repository() else {
312            self.multibuffer.update(cx, |multibuffer, cx| {
313                multibuffer.clear(cx);
314            });
315            return vec![];
316        };
317
318        let mut previous_paths = self.multibuffer.read(cx).paths().collect::<HashSet<_>>();
319
320        let mut result = vec![];
321        repo.update(cx, |repo, cx| {
322            for entry in repo.status() {
323                if !entry.status.has_changes() {
324                    continue;
325                }
326                let Some(project_path) = repo.repo_path_to_project_path(&entry.repo_path) else {
327                    continue;
328                };
329                let namespace = if repo.has_conflict(&entry.repo_path) {
330                    CONFLICT_NAMESPACE
331                } else if entry.status.is_created() {
332                    NEW_NAMESPACE
333                } else {
334                    TRACKED_NAMESPACE
335                };
336                let path_key = PathKey::namespaced(namespace, entry.repo_path.0.clone());
337
338                previous_paths.remove(&path_key);
339                let load_buffer = self
340                    .project
341                    .update(cx, |project, cx| project.open_buffer(project_path, cx));
342
343                let project = self.project.clone();
344                result.push(cx.spawn(|_, mut cx| async move {
345                    let buffer = load_buffer.await?;
346                    let changes = project
347                        .update(&mut cx, |project, cx| {
348                            project.open_uncommitted_diff(buffer.clone(), cx)
349                        })?
350                        .await?;
351                    Ok(DiffBuffer {
352                        path_key,
353                        buffer,
354                        diff: changes,
355                    })
356                }));
357            }
358        });
359        self.multibuffer.update(cx, |multibuffer, cx| {
360            for path in previous_paths {
361                multibuffer.remove_excerpts_for_path(path, cx);
362            }
363        });
364        result
365    }
366
367    fn register_buffer(
368        &mut self,
369        diff_buffer: DiffBuffer,
370        window: &mut Window,
371        cx: &mut Context<Self>,
372    ) {
373        let path_key = diff_buffer.path_key;
374        let buffer = diff_buffer.buffer;
375        let diff = diff_buffer.diff;
376
377        let snapshot = buffer.read(cx).snapshot();
378        let diff = diff.read(cx);
379        let diff_hunk_ranges = if diff.base_text().is_none() {
380            vec![Point::zero()..snapshot.max_point()]
381        } else {
382            diff.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &snapshot, cx)
383                .map(|diff_hunk| diff_hunk.buffer_range.to_point(&snapshot))
384                .collect::<Vec<_>>()
385        };
386
387        self.multibuffer.update(cx, |multibuffer, cx| {
388            multibuffer.set_excerpts_for_path(
389                path_key.clone(),
390                buffer,
391                diff_hunk_ranges,
392                editor::DEFAULT_MULTIBUFFER_CONTEXT,
393                cx,
394            );
395        });
396        if self.multibuffer.read(cx).is_empty()
397            && self
398                .editor
399                .read(cx)
400                .focus_handle(cx)
401                .contains_focused(window, cx)
402        {
403            self.focus_handle.focus(window);
404        } else if self.focus_handle.is_focused(window) && !self.multibuffer.read(cx).is_empty() {
405            self.editor.update(cx, |editor, cx| {
406                editor.focus_handle(cx).focus(window);
407            });
408        }
409        if self.pending_scroll.as_ref() == Some(&path_key) {
410            self.scroll_to_path(path_key, window, cx);
411        }
412    }
413
414    pub async fn handle_status_updates(
415        this: WeakEntity<Self>,
416        mut recv: postage::watch::Receiver<()>,
417        mut cx: AsyncWindowContext,
418    ) -> Result<()> {
419        while let Some(_) = recv.next().await {
420            let buffers_to_load = this.update(&mut cx, |this, cx| this.load_buffers(cx))?;
421            for buffer_to_load in buffers_to_load {
422                if let Some(buffer) = buffer_to_load.await.log_err() {
423                    cx.update(|window, cx| {
424                        this.update(cx, |this, cx| this.register_buffer(buffer, window, cx))
425                            .ok();
426                    })?;
427                }
428            }
429            this.update(&mut cx, |this, _| this.pending_scroll.take())?;
430        }
431
432        Ok(())
433    }
434}
435
436impl EventEmitter<EditorEvent> for ProjectDiff {}
437
438impl Focusable for ProjectDiff {
439    fn focus_handle(&self, cx: &App) -> FocusHandle {
440        if self.multibuffer.read(cx).is_empty() {
441            self.focus_handle.clone()
442        } else {
443            self.editor.focus_handle(cx)
444        }
445    }
446}
447
448impl Item for ProjectDiff {
449    type Event = EditorEvent;
450
451    fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
452        Some(Icon::new(IconName::GitBranch).color(Color::Muted))
453    }
454
455    fn to_item_events(event: &EditorEvent, f: impl FnMut(ItemEvent)) {
456        Editor::to_item_events(event, f)
457    }
458
459    fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
460        self.editor
461            .update(cx, |editor, cx| editor.deactivated(window, cx));
462    }
463
464    fn navigate(
465        &mut self,
466        data: Box<dyn Any>,
467        window: &mut Window,
468        cx: &mut Context<Self>,
469    ) -> bool {
470        self.editor
471            .update(cx, |editor, cx| editor.navigate(data, window, cx))
472    }
473
474    fn tab_tooltip_text(&self, _: &App) -> Option<SharedString> {
475        Some("Project Diff".into())
476    }
477
478    fn tab_content(&self, params: TabContentParams, _window: &Window, _: &App) -> AnyElement {
479        Label::new("Uncommitted Changes")
480            .color(if params.selected {
481                Color::Default
482            } else {
483                Color::Muted
484            })
485            .into_any_element()
486    }
487
488    fn telemetry_event_text(&self) -> Option<&'static str> {
489        Some("Project Diff Opened")
490    }
491
492    fn as_searchable(&self, _: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
493        Some(Box::new(self.editor.clone()))
494    }
495
496    fn for_each_project_item(
497        &self,
498        cx: &App,
499        f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
500    ) {
501        self.editor.for_each_project_item(cx, f)
502    }
503
504    fn is_singleton(&self, _: &App) -> bool {
505        false
506    }
507
508    fn set_nav_history(
509        &mut self,
510        nav_history: ItemNavHistory,
511        _: &mut Window,
512        cx: &mut Context<Self>,
513    ) {
514        self.editor.update(cx, |editor, _| {
515            editor.set_nav_history(Some(nav_history));
516        });
517    }
518
519    fn clone_on_split(
520        &self,
521        _workspace_id: Option<workspace::WorkspaceId>,
522        window: &mut Window,
523        cx: &mut Context<Self>,
524    ) -> Option<Entity<Self>>
525    where
526        Self: Sized,
527    {
528        let workspace = self.workspace.upgrade()?;
529        Some(cx.new(|cx| ProjectDiff::new(self.project.clone(), workspace, window, cx)))
530    }
531
532    fn is_dirty(&self, cx: &App) -> bool {
533        self.multibuffer.read(cx).is_dirty(cx)
534    }
535
536    fn has_conflict(&self, cx: &App) -> bool {
537        self.multibuffer.read(cx).has_conflict(cx)
538    }
539
540    fn can_save(&self, _: &App) -> bool {
541        true
542    }
543
544    fn save(
545        &mut self,
546        format: bool,
547        project: Entity<Project>,
548        window: &mut Window,
549        cx: &mut Context<Self>,
550    ) -> Task<Result<()>> {
551        self.editor.save(format, project, window, cx)
552    }
553
554    fn save_as(
555        &mut self,
556        _: Entity<Project>,
557        _: ProjectPath,
558        _window: &mut Window,
559        _: &mut Context<Self>,
560    ) -> Task<Result<()>> {
561        unreachable!()
562    }
563
564    fn reload(
565        &mut self,
566        project: Entity<Project>,
567        window: &mut Window,
568        cx: &mut Context<Self>,
569    ) -> Task<Result<()>> {
570        self.editor.reload(project, window, cx)
571    }
572
573    fn act_as_type<'a>(
574        &'a self,
575        type_id: TypeId,
576        self_handle: &'a Entity<Self>,
577        _: &'a App,
578    ) -> Option<AnyView> {
579        if type_id == TypeId::of::<Self>() {
580            Some(self_handle.to_any())
581        } else if type_id == TypeId::of::<Editor>() {
582            Some(self.editor.to_any())
583        } else {
584            None
585        }
586    }
587
588    fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation {
589        ToolbarItemLocation::PrimaryLeft
590    }
591
592    fn breadcrumbs(&self, theme: &theme::Theme, cx: &App) -> Option<Vec<BreadcrumbText>> {
593        self.editor.breadcrumbs(theme, cx)
594    }
595
596    fn added_to_workspace(
597        &mut self,
598        workspace: &mut Workspace,
599        window: &mut Window,
600        cx: &mut Context<Self>,
601    ) {
602        self.editor.update(cx, |editor, cx| {
603            editor.added_to_workspace(workspace, window, cx)
604        });
605    }
606}
607
608impl Render for ProjectDiff {
609    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
610        let is_empty = self.multibuffer.read(cx).is_empty();
611
612        div()
613            .track_focus(&self.focus_handle)
614            .bg(cx.theme().colors().editor_background)
615            .flex()
616            .items_center()
617            .justify_center()
618            .size_full()
619            .when(is_empty, |el| {
620                el.child(Label::new("No uncommitted changes"))
621            })
622            .when(!is_empty, |el| el.child(self.editor.clone()))
623    }
624}
625
626impl SerializableItem for ProjectDiff {
627    fn serialized_item_kind() -> &'static str {
628        "ProjectDiff"
629    }
630
631    fn cleanup(
632        _: workspace::WorkspaceId,
633        _: Vec<workspace::ItemId>,
634        _: &mut Window,
635        _: &mut App,
636    ) -> Task<Result<()>> {
637        Task::ready(Ok(()))
638    }
639
640    fn deserialize(
641        _project: Entity<Project>,
642        workspace: WeakEntity<Workspace>,
643        _workspace_id: workspace::WorkspaceId,
644        _item_id: workspace::ItemId,
645        window: &mut Window,
646        cx: &mut App,
647    ) -> Task<Result<Entity<Self>>> {
648        window.spawn(cx, |mut cx| async move {
649            workspace.update_in(&mut cx, |workspace, window, cx| {
650                let workspace_handle = cx.entity();
651                cx.new(|cx| Self::new(workspace.project().clone(), workspace_handle, window, cx))
652            })
653        })
654    }
655
656    fn serialize(
657        &mut self,
658        _workspace: &mut Workspace,
659        _item_id: workspace::ItemId,
660        _closing: bool,
661        _window: &mut Window,
662        _cx: &mut Context<Self>,
663    ) -> Option<Task<Result<()>>> {
664        None
665    }
666
667    fn should_serialize(&self, _: &Self::Event) -> bool {
668        false
669    }
670}
671
672pub struct ProjectDiffToolbar {
673    project_diff: Option<WeakEntity<ProjectDiff>>,
674    workspace: WeakEntity<Workspace>,
675}
676
677impl ProjectDiffToolbar {
678    pub fn new(workspace: &Workspace, _: &mut Context<Self>) -> Self {
679        Self {
680            project_diff: None,
681            workspace: workspace.weak_handle(),
682        }
683    }
684
685    fn project_diff(&self, _: &App) -> Option<Entity<ProjectDiff>> {
686        self.project_diff.as_ref()?.upgrade()
687    }
688    fn dispatch_action(&self, action: &dyn Action, window: &mut Window, cx: &mut Context<Self>) {
689        if let Some(project_diff) = self.project_diff(cx) {
690            project_diff.focus_handle(cx).focus(window);
691        }
692        let action = action.boxed_clone();
693        cx.defer(move |cx| {
694            cx.dispatch_action(action.as_ref());
695        })
696    }
697    fn dispatch_panel_action(
698        &self,
699        action: &dyn Action,
700        window: &mut Window,
701        cx: &mut Context<Self>,
702    ) {
703        self.workspace
704            .read_with(cx, |workspace, cx| {
705                if let Some(panel) = workspace.panel::<GitPanel>(cx) {
706                    panel.focus_handle(cx).focus(window)
707                }
708            })
709            .ok();
710        let action = action.boxed_clone();
711        cx.defer(move |cx| {
712            cx.dispatch_action(action.as_ref());
713        })
714    }
715}
716
717impl EventEmitter<ToolbarItemEvent> for ProjectDiffToolbar {}
718
719impl ToolbarItemView for ProjectDiffToolbar {
720    fn set_active_pane_item(
721        &mut self,
722        active_pane_item: Option<&dyn ItemHandle>,
723        _: &mut Window,
724        cx: &mut Context<Self>,
725    ) -> ToolbarItemLocation {
726        self.project_diff = active_pane_item
727            .and_then(|item| item.act_as::<ProjectDiff>(cx))
728            .map(|entity| entity.downgrade());
729        if self.project_diff.is_some() {
730            ToolbarItemLocation::PrimaryRight
731        } else {
732            ToolbarItemLocation::Hidden
733        }
734    }
735
736    fn pane_focus_update(
737        &mut self,
738        _pane_focused: bool,
739        _window: &mut Window,
740        _cx: &mut Context<Self>,
741    ) {
742    }
743}
744
745struct ButtonStates {
746    stage: bool,
747    unstage: bool,
748    prev_next: bool,
749    selection: bool,
750    stage_all: bool,
751    unstage_all: bool,
752    commit: bool,
753}
754
755impl Render for ProjectDiffToolbar {
756    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
757        let Some(project_diff) = self.project_diff(cx) else {
758            return div();
759        };
760        let focus_handle = project_diff.focus_handle(cx);
761        let button_states = project_diff.read(cx).button_states(cx);
762
763        h_group_xl()
764            .my_neg_1()
765            .items_center()
766            .py_1()
767            .pl_2()
768            .pr_1()
769            .flex_wrap()
770            .justify_between()
771            .child(
772                h_group_sm()
773                    .when(button_states.selection, |el| {
774                        el.child(
775                            Button::new("stage", "Toggle Staged")
776                                .tooltip(Tooltip::for_action_title_in(
777                                    "Toggle Staged",
778                                    &ToggleStaged,
779                                    &focus_handle,
780                                ))
781                                .disabled(!button_states.stage && !button_states.unstage)
782                                .on_click(cx.listener(|this, _, window, cx| {
783                                    this.dispatch_action(&ToggleStaged, window, cx)
784                                })),
785                        )
786                    })
787                    .when(!button_states.selection, |el| {
788                        el.child(
789                            Button::new("stage", "Stage")
790                                .tooltip(Tooltip::for_action_title_in(
791                                    "Stage",
792                                    &StageAndNext,
793                                    &focus_handle,
794                                ))
795                                // don't actually disable the button so it's mashable
796                                .color(if button_states.stage {
797                                    Color::Default
798                                } else {
799                                    Color::Disabled
800                                })
801                                .on_click(cx.listener(|this, _, window, cx| {
802                                    this.dispatch_action(&StageAndNext, window, cx)
803                                })),
804                        )
805                        .child(
806                            Button::new("unstage", "Unstage")
807                                .tooltip(Tooltip::for_action_title_in(
808                                    "Unstage",
809                                    &UnstageAndNext,
810                                    &focus_handle,
811                                ))
812                                .color(if button_states.unstage {
813                                    Color::Default
814                                } else {
815                                    Color::Disabled
816                                })
817                                .on_click(cx.listener(|this, _, window, cx| {
818                                    this.dispatch_action(&UnstageAndNext, window, cx)
819                                })),
820                        )
821                    }),
822            )
823            // n.b. the only reason these arrows are here is because we don't
824            // support "undo" for staging so we need a way to go back.
825            .child(
826                h_group_sm()
827                    .child(
828                        IconButton::new("up", IconName::ArrowUp)
829                            .shape(ui::IconButtonShape::Square)
830                            .tooltip(Tooltip::for_action_title_in(
831                                "Go to previous hunk",
832                                &GoToPrevHunk,
833                                &focus_handle,
834                            ))
835                            .disabled(!button_states.prev_next)
836                            .on_click(cx.listener(|this, _, window, cx| {
837                                this.dispatch_action(&GoToPrevHunk, window, cx)
838                            })),
839                    )
840                    .child(
841                        IconButton::new("down", IconName::ArrowDown)
842                            .shape(ui::IconButtonShape::Square)
843                            .tooltip(Tooltip::for_action_title_in(
844                                "Go to next hunk",
845                                &GoToHunk,
846                                &focus_handle,
847                            ))
848                            .disabled(!button_states.prev_next)
849                            .on_click(cx.listener(|this, _, window, cx| {
850                                this.dispatch_action(&GoToHunk, window, cx)
851                            })),
852                    ),
853            )
854            .child(vertical_divider())
855            .child(
856                h_group_sm()
857                    .when(
858                        button_states.unstage_all && !button_states.stage_all,
859                        |el| {
860                            el.child(Button::new("unstage-all", "Unstage All").on_click(
861                                cx.listener(|this, _, window, cx| {
862                                    this.dispatch_panel_action(&UnstageAll, window, cx)
863                                }),
864                            ))
865                        },
866                    )
867                    .when(
868                        !button_states.unstage_all || button_states.stage_all,
869                        |el| {
870                            el.child(
871                                // todo make it so that changing to say "Unstaged"
872                                // doesn't change the position.
873                                div().child(
874                                    Button::new("stage-all", "Stage All")
875                                        .disabled(!button_states.stage_all)
876                                        .on_click(cx.listener(|this, _, window, cx| {
877                                            this.dispatch_panel_action(&StageAll, window, cx)
878                                        })),
879                                ),
880                            )
881                        },
882                    )
883                    .child(
884                        Button::new("commit", "Commit")
885                            .disabled(!button_states.commit)
886                            .on_click(cx.listener(|this, _, window, cx| {
887                                // todo this should open modal, not focus panel.
888                                this.dispatch_action(&Commit, window, cx);
889                            })),
890                    ),
891            )
892    }
893}