Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T02:31:01.223Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

solo_diff_view.rs

911 lines · 31.7 KB · rust
1use crate::{git_panel::GitStatusEntry, git_panel_settings::GitPanelSettings, git_status_icon};
2use anyhow::{Context as _, Result};
3use buffer_diff::DiffHunkSecondaryStatus;
4use editor::{
5    DiffStyleControls, Direction, Editor, EditorEvent, EditorSettings, SplittableEditor,
6    ToggleSplitDiff,
7    actions::{GoToHunk, GoToPreviousHunk},
8    file_status_label_color,
9};
10use git::{
11    Commit, Restore, StageAndNext, StageFile, ToggleStaged, UnstageAndNext, UnstageFile,
12    repository::RepoPath, status::StageStatus,
13};
14use gpui::{
15    Action, AnyElement, App, AppContext as _, Context, Empty, Entity, EventEmitter, FocusHandle,
16    Focusable, HighlightStyle, IntoElement, Render, Subscription, Task, WeakEntity, Window,
17};
18use language::{Anchor, Buffer, HighlightedText, OffsetRangeExt as _, Point};
19use multi_buffer::{MultiBuffer, PathKey, excerpt_context_lines};
20use project::{
21    Project,
22    git_store::{Repository, RepositoryId},
23};
24use settings::{Settings, SettingsStore, StatusStyle};
25use std::{
26    any::{Any, TypeId},
27    ops::Range,
28    sync::Arc,
29};
30use ui::{DiffStat, Divider, Tooltip, prelude::*};
31use util::paths::{PathExt as _, PathStyle};
32use workspace::{
33    Item, ItemHandle, ItemNavHistory, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView,
34    Workspace,
35    item::{ItemEvent, SaveOptions, TabContentParams},
36    notifications::NotifyTaskExt,
37    searchable::SearchableItemHandle,
38};
39
40pub struct SoloDiffView {
41    repository: Entity<Repository>,
42    repository_id: RepositoryId,
43    repo_path: RepoPath,
44    buffer: Entity<Buffer>,
45    diff: Entity<buffer_diff::BufferDiff>,
46    editor: Entity<SplittableEditor>,
47    workspace: WeakEntity<Workspace>,
48    showing_full_file: bool,
49    _settings_subscription: Subscription,
50}
51
52impl SoloDiffView {
53    pub fn open_or_focus(
54        entry: GitStatusEntry,
55        repository: Entity<Repository>,
56        workspace: WeakEntity<Workspace>,
57        window: &mut Window,
58        cx: &mut App,
59    ) -> Task<Result<Entity<Self>>> {
60        let Some(workspace_entity) = workspace.upgrade() else {
61            return Task::ready(Err(anyhow::anyhow!("workspace was dropped")));
62        };
63
64        let existing = workspace_entity
65            .read(cx)
66            .items_of_type::<SoloDiffView>(cx)
67            .find(|item| item.read(cx).matches(&repository, &entry.repo_path, cx));
68        if let Some(existing) = existing {
69            workspace_entity.update(cx, |workspace, cx| {
70                workspace.activate_item(&existing, true, true, window, cx);
71            });
72            existing.focus_handle(cx).focus(window, cx);
73            return Task::ready(Ok(existing));
74        }
75
76        let Some(project_path) = repository
77            .read(cx)
78            .repo_path_to_project_path(&entry.repo_path, cx)
79        else {
80            return Task::ready(Err(anyhow::anyhow!(
81                "could not resolve repository path {:?}",
82                entry.repo_path
83            )));
84        };
85
86        let project = workspace_entity.read(cx).project().clone();
87        let repo_path = entry.repo_path;
88        window.spawn(cx, async move |cx| {
89            let buffer = project
90                .update(cx, |project, cx| {
91                    project.open_buffer(project_path.clone(), cx)
92                })
93                .await?;
94            let diff = project
95                .update(cx, |project, cx| {
96                    project.open_uncommitted_diff(buffer.clone(), cx)
97                })
98                .await?;
99
100            workspace_entity.update_in(cx, |workspace, window, cx| {
101                let workspace_handle = cx.entity();
102                let view = cx.new(|cx| {
103                    Self::new(
104                        project,
105                        repository,
106                        repo_path,
107                        buffer,
108                        diff,
109                        workspace_handle,
110                        window,
111                        cx,
112                    )
113                });
114
115                workspace.add_item_to_active_pane(Box::new(view.clone()), None, true, window, cx);
116                view
117            })
118        })
119    }
120
121    fn new(
122        project: Entity<Project>,
123        repository: Entity<Repository>,
124        repo_path: RepoPath,
125        buffer: Entity<Buffer>,
126        diff: Entity<buffer_diff::BufferDiff>,
127        workspace: Entity<Workspace>,
128        window: &mut Window,
129        cx: &mut Context<Self>,
130    ) -> Self {
131        let repository_id = repository.read(cx).id;
132        let showing_full_file = EditorSettings::get_global(cx).file_diff.show_full_file;
133        let multibuffer = cx
134            .new(|cx| Self::build_multibuffer(buffer.clone(), diff.clone(), showing_full_file, cx));
135        let editor = cx.new(|cx| {
136            let editor = SplittableEditor::new(
137                EditorSettings::get_global(cx).diff_view_style,
138                multibuffer,
139                project.clone(),
140                workspace.clone(),
141                window,
142                cx,
143            );
144            editor.rhs_editor().update(cx, |editor, cx| {
145                editor.set_should_serialize(false, cx);
146                editor.set_allow_git_diff_scrollbar_markers(showing_full_file, cx);
147                let snapshot = editor.snapshot(window, cx);
148                editor.go_to_hunk_before_or_after_position(
149                    &snapshot,
150                    language::Point::new(0, 0),
151                    Direction::Next,
152                    true,
153                    window,
154                    cx,
155                );
156            });
157            editor
158        });
159
160        let mut previous_diff_view_style = EditorSettings::get_global(cx).diff_view_style;
161        let settings_subscription =
162            cx.observe_global_in::<SettingsStore>(window, move |this, window, cx| {
163                let diff_view_style = EditorSettings::get_global(cx).diff_view_style;
164                if diff_view_style != previous_diff_view_style {
165                    this.editor.update(cx, |editor, cx| {
166                        if editor.diff_view_style() != diff_view_style {
167                            editor.toggle_split(&ToggleSplitDiff, window, cx);
168                        }
169                    });
170                    previous_diff_view_style = diff_view_style;
171                    cx.notify();
172                }
173            });
174
175        Self {
176            repository,
177            repository_id,
178            repo_path,
179            buffer,
180            diff,
181            editor,
182            workspace: workspace.downgrade(),
183            showing_full_file,
184            _settings_subscription: settings_subscription,
185        }
186    }
187
188    fn build_multibuffer(
189        buffer: Entity<Buffer>,
190        diff: Entity<buffer_diff::BufferDiff>,
191        showing_full_file: bool,
192        cx: &mut Context<MultiBuffer>,
193    ) -> MultiBuffer {
194        let (ranges, context_line_count) =
195            Self::excerpt_ranges(&buffer, &diff, showing_full_file, cx);
196
197        let mut multibuffer = MultiBuffer::without_headers(buffer.read(cx).capability());
198        multibuffer.set_excerpts_for_buffer(buffer, ranges, context_line_count, cx);
199        multibuffer.add_diff(diff, cx);
200        multibuffer
201    }
202
203    fn excerpt_ranges(
204        buffer: &Entity<Buffer>,
205        diff: &Entity<buffer_diff::BufferDiff>,
206        showing_full_file: bool,
207        cx: &App,
208    ) -> (Vec<Range<Point>>, u32) {
209        if showing_full_file {
210            (vec![Point::zero()..buffer.read(cx).max_point()], 0)
211        } else {
212            (
213                Self::hunk_ranges(buffer, diff, cx),
214                excerpt_context_lines(cx),
215            )
216        }
217    }
218
219    fn hunk_ranges(
220        buffer: &Entity<Buffer>,
221        diff: &Entity<buffer_diff::BufferDiff>,
222        cx: &App,
223    ) -> Vec<Range<Point>> {
224        let buffer = buffer.read(cx);
225        diff.read(cx)
226            .snapshot(cx)
227            .hunks_intersecting_range(
228                Anchor::min_for_buffer(buffer.remote_id())
229                    ..Anchor::max_for_buffer(buffer.remote_id()),
230                buffer,
231            )
232            .map(|diff_hunk| diff_hunk.buffer_range.to_point(buffer))
233            .collect()
234    }
235
236    fn set_showing_full_file(&mut self, showing_full_file: bool, cx: &mut Context<Self>) {
237        if self.showing_full_file == showing_full_file {
238            return;
239        }
240
241        let (ranges, context_line_count) =
242            Self::excerpt_ranges(&self.buffer, &self.diff, showing_full_file, cx);
243
244        self.editor.update(cx, |editor, cx| {
245            let path = PathKey::for_buffer(&self.buffer, cx);
246            editor.remove_excerpts_for_path(path.clone(), cx);
247            editor.update_excerpts_for_path(
248                path,
249                self.buffer.clone(),
250                ranges,
251                context_line_count,
252                self.diff.clone(),
253                cx,
254            );
255            editor.rhs_editor().update(cx, |editor, cx| {
256                editor.set_allow_git_diff_scrollbar_markers(showing_full_file, cx);
257            });
258        });
259
260        self.showing_full_file = showing_full_file;
261        cx.notify();
262    }
263
264    fn matches(&self, repository: &Entity<Repository>, repo_path: &RepoPath, cx: &App) -> bool {
265        self.repository_id == repository.read(cx).id && &self.repo_path == repo_path
266    }
267
268    fn button_states(&self, cx: &App) -> SoloDiffButtonStates {
269        let editor = self.editor.read(cx).rhs_editor().read(cx);
270        let multibuffer = editor.buffer().read(cx);
271        let snapshot = multibuffer.snapshot(cx);
272        let prev_next = snapshot.diff_hunks().nth(1).is_some();
273        let mut selection = true;
274
275        let mut ranges = editor
276            .selections
277            .disjoint_anchor_ranges()
278            .collect::<Vec<_>>();
279        if !ranges.iter().any(|range| range.start != range.end) {
280            selection = false;
281            let anchor = editor.selections.newest_anchor().head();
282            if let Some((_, excerpt_range)) = snapshot.excerpt_containing(anchor..anchor)
283                && let Some(range) = snapshot
284                    .anchor_in_buffer(excerpt_range.context.start)
285                    .zip(snapshot.anchor_in_buffer(excerpt_range.context.end))
286                    .map(|(start, end)| start..end)
287            {
288                ranges = vec![range];
289            } else {
290                ranges = Vec::new();
291            }
292        }
293
294        let mut stage = false;
295        let mut unstage = false;
296        for hunk in editor.diff_hunks_in_ranges(&ranges, &snapshot) {
297            match hunk.status.secondary {
298                DiffHunkSecondaryStatus::HasSecondaryHunk
299                | DiffHunkSecondaryStatus::SecondaryHunkAdditionPending => {
300                    stage = true;
301                }
302                DiffHunkSecondaryStatus::OverlapsWithSecondaryHunk => {
303                    stage = true;
304                    unstage = true;
305                }
306                DiffHunkSecondaryStatus::NoSecondaryHunk
307                | DiffHunkSecondaryStatus::SecondaryHunkRemovalPending => {
308                    unstage = true;
309                }
310            }
311        }
312
313        let stage_status = self
314            .repository
315            .read(cx)
316            .status_for_path(&self.repo_path)
317            .map(|entry| entry.status.staging())
318            .unwrap_or(StageStatus::Unstaged);
319
320        SoloDiffButtonStates {
321            stage,
322            unstage,
323            restore: stage || unstage,
324            prev_next,
325            selection,
326            stage_file: stage_status.has_unstaged(),
327            unstage_file: stage_status.has_staged(),
328        }
329    }
330
331    fn dispatch_action(&self, action: &dyn Action, window: &mut Window, cx: &mut App) {
332        self.focus_handle(cx).focus(window, cx);
333        let action = action.boxed_clone();
334        cx.defer(move |cx| {
335            cx.dispatch_action(action.as_ref());
336        });
337    }
338
339    fn change_file_stage(&self, stage: bool, window: &mut Window, cx: &mut Context<Self>) {
340        let repository = self.repository.clone();
341        let repo_path = self.repo_path.clone();
342        let workspace = self.workspace.clone();
343        let task = cx.spawn(async move |_, cx| {
344            repository
345                .update(cx, |repository, cx| {
346                    if stage {
347                        repository.stage_entries(vec![repo_path], cx)
348                    } else {
349                        repository.unstage_entries(vec![repo_path], cx)
350                    }
351                })
352                .await
353                .with_context(|| {
354                    if stage {
355                        "failed to stage file"
356                    } else {
357                        "failed to unstage file"
358                    }
359                })
360        });
361        task.detach_and_notify_err(workspace, window, cx);
362    }
363}
364
365impl EventEmitter<EditorEvent> for SoloDiffView {}
366
367impl Focusable for SoloDiffView {
368    fn focus_handle(&self, cx: &App) -> FocusHandle {
369        self.editor.focus_handle(cx)
370    }
371}
372
373impl Item for SoloDiffView {
374    type Event = EditorEvent;
375
376    fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
377        Some(Icon::new(IconName::Diff).color(Color::Muted))
378    }
379
380    fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement {
381        Label::new(self.tab_content_text(params.detail.unwrap_or_default(), cx))
382            .color(if params.selected {
383                Color::Default
384            } else {
385                Color::Muted
386            })
387            .into_any_element()
388    }
389
390    fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString {
391        self.buffer
392            .read(cx)
393            .file()
394            .and_then(|file| {
395                Some(
396                    file.full_path(cx)
397                        .file_name()?
398                        .to_string_lossy()
399                        .to_string(),
400                )
401            })
402            .unwrap_or_else(|| {
403                self.repo_path
404                    .as_ref()
405                    .display(PathStyle::local())
406                    .into_owned()
407            })
408            .into()
409    }
410
411    fn tab_tooltip_text(&self, cx: &App) -> Option<SharedString> {
412        Some(
413            self.buffer
414                .read(cx)
415                .file()
416                .map(|file| file.full_path(cx).compact().to_string_lossy().into_owned())
417                .unwrap_or_else(|| {
418                    self.repo_path
419                        .as_ref()
420                        .display(PathStyle::local())
421                        .into_owned()
422                })
423                .into(),
424        )
425    }
426
427    fn to_item_events(event: &EditorEvent, f: &mut dyn FnMut(ItemEvent)) {
428        Editor::to_item_events(event, f)
429    }
430
431    fn telemetry_event_text(&self) -> Option<&'static str> {
432        Some("Solo Diff View Opened")
433    }
434
435    fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
436        self.editor.deactivated(window, cx);
437    }
438
439    fn act_as_type<'a>(
440        &'a self,
441        type_id: TypeId,
442        self_handle: &'a Entity<Self>,
443        cx: &'a App,
444    ) -> Option<gpui::AnyEntity> {
445        if type_id == TypeId::of::<Self>() {
446            Some(self_handle.clone().into())
447        } else if type_id == TypeId::of::<SplittableEditor>() {
448            None
449        } else {
450            self.editor.act_as_type(type_id, cx)
451        }
452    }
453
454    fn as_searchable(&self, _: &Entity<Self>, _: &App) -> Option<Box<dyn SearchableItemHandle>> {
455        Some(Box::new(self.editor.clone()))
456    }
457
458    fn for_each_project_item(
459        &self,
460        cx: &App,
461        f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
462    ) {
463        self.editor.for_each_project_item(cx, f)
464    }
465
466    fn set_nav_history(
467        &mut self,
468        nav_history: ItemNavHistory,
469        _: &mut Window,
470        cx: &mut Context<Self>,
471    ) {
472        self.editor.update(cx, |editor, cx| {
473            editor.rhs_editor().update(cx, |editor, _| {
474                editor.set_nav_history(Some(nav_history));
475            })
476        });
477    }
478
479    fn navigate(
480        &mut self,
481        data: Arc<dyn Any + Send>,
482        window: &mut Window,
483        cx: &mut Context<Self>,
484    ) -> bool {
485        self.editor.update(cx, |editor, cx| {
486            editor
487                .rhs_editor()
488                .update(cx, |editor, cx| editor.navigate(data, window, cx))
489        })
490    }
491
492    fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation {
493        ToolbarItemLocation::PrimaryLeft
494    }
495
496    fn breadcrumbs(&self, cx: &App) -> Option<(Vec<HighlightedText>, Option<gpui::Font>)> {
497        let text: SharedString = self
498            .repo_path
499            .as_ref()
500            .display(PathStyle::local())
501            .into_owned()
502            .into();
503
504        // When the git panel is set to convey status via label color rather
505        // than an icon, tint the whole path like multibuffer headers do.
506        let mut highlights = Vec::new();
507        if GitPanelSettings::get_global(cx).status_style == StatusStyle::LabelColor
508            && let Some(status) = self
509                .repository
510                .read(cx)
511                .status_for_path(&self.repo_path)
512                .map(|entry| entry.status)
513        {
514            highlights.push((
515                0..text.len(),
516                HighlightStyle::color(file_status_label_color(Some(status)).color(cx)),
517            ));
518        }
519
520        Some((
521            vec![HighlightedText { text, highlights }],
522            Some(
523                theme_settings::ThemeSettings::get_global(cx)
524                    .buffer_font
525                    .clone(),
526            ),
527        ))
528    }
529
530    fn added_to_workspace(
531        &mut self,
532        workspace: &mut Workspace,
533        window: &mut Window,
534        cx: &mut Context<Self>,
535    ) {
536        self.editor.update(cx, |editor, cx| {
537            editor.rhs_editor().update(cx, |editor, cx| {
538                editor.added_to_workspace(workspace, window, cx)
539            })
540        });
541    }
542
543    fn can_save(&self, cx: &App) -> bool {
544        self.editor.read(cx).rhs_editor().read(cx).can_save(cx)
545    }
546
547    fn save(
548        &mut self,
549        options: SaveOptions,
550        project: Entity<Project>,
551        window: &mut Window,
552        cx: &mut Context<Self>,
553    ) -> Task<Result<()>> {
554        self.editor.save(options, project, window, cx)
555    }
556}
557
558impl Render for SoloDiffView {
559    fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
560        self.editor.clone()
561    }
562}
563
564pub struct SoloDiffStyleToolbar {
565    solo_diff: Option<WeakEntity<SoloDiffView>>,
566}
567
568pub struct SoloDiffGitToolbar {
569    solo_diff: Option<WeakEntity<SoloDiffView>>,
570}
571
572impl SoloDiffStyleToolbar {
573    pub fn new(_: &mut Context<Self>) -> Self {
574        Self { solo_diff: None }
575    }
576
577    fn solo_diff(&self) -> Option<Entity<SoloDiffView>> {
578        self.solo_diff.as_ref()?.upgrade()
579    }
580
581    fn toggle_showing_full_file(&mut self, cx: &mut Context<Self>) {
582        if let Some(solo_diff) = self.solo_diff() {
583            solo_diff.update(cx, |solo_diff, cx| {
584                solo_diff.set_showing_full_file(!solo_diff.showing_full_file, cx);
585            });
586        }
587    }
588}
589
590impl EventEmitter<ToolbarItemEvent> for SoloDiffStyleToolbar {}
591
592impl ToolbarItemView for SoloDiffStyleToolbar {
593    fn set_active_pane_item(
594        &mut self,
595        active_pane_item: Option<&dyn ItemHandle>,
596        _: &mut Window,
597        cx: &mut Context<Self>,
598    ) -> ToolbarItemLocation {
599        self.solo_diff = active_pane_item
600            .and_then(|item| item.act_as::<SoloDiffView>(cx))
601            .map(|entity| entity.downgrade());
602        if self.solo_diff.is_some() {
603            ToolbarItemLocation::PrimaryLeft
604        } else {
605            ToolbarItemLocation::Hidden
606        }
607    }
608}
609
610impl Render for SoloDiffStyleToolbar {
611    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
612        let Some(solo_diff) = self.solo_diff() else {
613            return Empty.into_any_element();
614        };
615
616        let (editor_entity, showing_full_file, status) = {
617            let solo_diff = solo_diff.read(cx);
618            (
619                solo_diff.editor.clone(),
620                solo_diff.showing_full_file,
621                solo_diff
622                    .repository
623                    .read(cx)
624                    .status_for_path(&solo_diff.repo_path)
625                    .map(|entry| entry.status),
626            )
627        };
628
629        let show_status_icon =
630            GitPanelSettings::get_global(cx).status_style != StatusStyle::LabelColor;
631
632        let (expand_icon, expand_tooltip) = if showing_full_file {
633            (IconName::ChevronDownUp, "Show Changes Only")
634        } else {
635            (IconName::ChevronUpDown, "Show Full File")
636        };
637
638        h_flex()
639            .pl_0p5()
640            .gap_1()
641            .child(
642                IconButton::new("solo-diff-toggle-excerpts", expand_icon)
643                    .icon_size(IconSize::Small)
644                    .tooltip(Tooltip::text(expand_tooltip))
645                    .on_click(cx.listener(|this, _, _, cx| {
646                        this.toggle_showing_full_file(cx);
647                    })),
648            )
649            .child(DiffStyleControls::new(editor_entity))
650            .child(Divider::vertical().mr_1())
651            .when_some(
652                show_status_icon.then_some(status).flatten(),
653                |this, status| this.child(git_status_icon(status)),
654            )
655            .into_any_element()
656    }
657}
658
659impl SoloDiffGitToolbar {
660    pub fn new(_: &mut Context<Self>) -> Self {
661        Self { solo_diff: None }
662    }
663
664    fn solo_diff(&self) -> Option<Entity<SoloDiffView>> {
665        self.solo_diff.as_ref()?.upgrade()
666    }
667
668    fn dispatch_action(&self, action: &dyn Action, window: &mut Window, cx: &mut Context<Self>) {
669        if let Some(solo_diff) = self.solo_diff() {
670            solo_diff.update(cx, |solo_diff, cx| {
671                solo_diff.dispatch_action(action, window, cx);
672            });
673        }
674    }
675
676    fn stage_file(&mut self, window: &mut Window, cx: &mut Context<Self>) {
677        if let Some(solo_diff) = self.solo_diff() {
678            solo_diff.update(cx, |solo_diff, cx| {
679                solo_diff.change_file_stage(true, window, cx);
680            });
681        }
682    }
683
684    fn unstage_file(&mut self, window: &mut Window, cx: &mut Context<Self>) {
685        if let Some(solo_diff) = self.solo_diff() {
686            solo_diff.update(cx, |solo_diff, cx| {
687                solo_diff.change_file_stage(false, window, cx);
688            });
689        }
690    }
691}
692
693impl EventEmitter<ToolbarItemEvent> for SoloDiffGitToolbar {}
694
695impl ToolbarItemView for SoloDiffGitToolbar {
696    fn set_active_pane_item(
697        &mut self,
698        active_pane_item: Option<&dyn ItemHandle>,
699        _: &mut Window,
700        cx: &mut Context<Self>,
701    ) -> ToolbarItemLocation {
702        self.solo_diff = active_pane_item
703            .and_then(|item| item.act_as::<SoloDiffView>(cx))
704            .map(|entity| entity.downgrade());
705        if self.solo_diff.is_some() {
706            ToolbarItemLocation::PrimaryRight
707        } else {
708            ToolbarItemLocation::Hidden
709        }
710    }
711}
712
713struct SoloDiffButtonStates {
714    stage: bool,
715    unstage: bool,
716    restore: bool,
717    prev_next: bool,
718    selection: bool,
719    stage_file: bool,
720    unstage_file: bool,
721}
722
723#[cfg(test)]
724mod tests {
725    use super::*;
726    use gpui::TestAppContext;
727    use multi_buffer::MultiBufferRow;
728
729    #[gpui::test]
730    fn test_changes_only_multibuffer_has_one_buffer_and_expand_controls(cx: &mut TestAppContext) {
731        let base_text = (0..20)
732            .map(|line| format!("line {line}"))
733            .collect::<Vec<_>>()
734            .join("\n");
735        let current_text = base_text.replace("line 10", "changed line 10");
736        let buffer = cx.new(|cx| Buffer::local(current_text, cx));
737        let diff = cx.new(|cx| {
738            buffer_diff::BufferDiff::new_with_base_text(
739                &base_text,
740                &buffer.read(cx).text_snapshot(),
741                cx,
742            )
743        });
744        let multibuffer = cx.new(|cx| SoloDiffView::build_multibuffer(buffer, diff, false, cx));
745
746        let (is_singleton, buffer_count, has_expand_controls) =
747            multibuffer.update(cx, |multibuffer, cx| {
748                (
749                    multibuffer.is_singleton(),
750                    multibuffer.snapshot(cx).all_buffer_ids().count(),
751                    multibuffer
752                        .snapshot(cx)
753                        .row_infos(MultiBufferRow(0))
754                        .any(|row| row.expand_info.is_some()),
755                )
756            });
757
758        assert!(!is_singleton);
759        assert_eq!(buffer_count, 1);
760        assert!(has_expand_controls);
761    }
762}
763
764impl Render for SoloDiffGitToolbar {
765    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
766        let Some(solo_diff) = self.solo_diff() else {
767            return gpui::Empty.into_any_element();
768        };
769
770        let focus_handle = solo_diff.focus_handle(cx);
771        let solo_diff = solo_diff.read(cx);
772        let button_states = solo_diff.button_states(cx);
773        let status_entry = solo_diff
774            .repository
775            .read(cx)
776            .status_for_path(&solo_diff.repo_path);
777        let diff_stat = status_entry.and_then(|entry| entry.diff_stat);
778
779        h_flex()
780            .my_neg_1()
781            .py_1()
782            .gap_1p5()
783            .flex_wrap()
784            .justify_between()
785            .children(diff_stat.map(|stat| {
786                DiffStat::new("solo-diff-stat", stat.added as usize, stat.deleted as usize)
787            }))
788            .child(Divider::vertical().ml_1())
789            .child(
790                h_group_sm()
791                    .child(
792                        IconButton::new("up", IconName::ArrowUp)
793                            .icon_size(IconSize::Small)
794                            .disabled(!button_states.prev_next)
795                            .tooltip(Tooltip::for_action_title_in(
796                                "Go to Previous Hunk",
797                                &GoToPreviousHunk,
798                                &focus_handle,
799                            ))
800                            .on_click(cx.listener(|this, _, window, cx| {
801                                this.dispatch_action(&GoToPreviousHunk, window, cx)
802                            })),
803                    )
804                    .child(
805                        IconButton::new("down", IconName::ArrowDown)
806                            .icon_size(IconSize::Small)
807                            .disabled(!button_states.prev_next)
808                            .tooltip(Tooltip::for_action_title_in(
809                                "Go to Next Hunk",
810                                &GoToHunk,
811                                &focus_handle,
812                            ))
813                            .on_click(cx.listener(|this, _, window, cx| {
814                                this.dispatch_action(&GoToHunk, window, cx)
815                            })),
816                    ),
817            )
818            .child(Divider::vertical())
819            .child(
820                h_group_sm()
821                    .when(button_states.selection, |el| {
822                        el.child(
823                            Button::new("stage", "Toggle Staged")
824                                .disabled(!button_states.stage && !button_states.unstage)
825                                .tooltip(Tooltip::for_action_title_in(
826                                    "Toggle Staged",
827                                    &ToggleStaged,
828                                    &focus_handle,
829                                ))
830                                .on_click(cx.listener(|this, _, window, cx| {
831                                    this.dispatch_action(&ToggleStaged, window, cx)
832                                })),
833                        )
834                    })
835                    .when(!button_states.selection, |el| {
836                        el.child(
837                            Button::new("stage", "Stage")
838                                .disabled(!button_states.stage)
839                                .tooltip(Tooltip::for_action_title_in(
840                                    "Stage and Go to Next Hunk",
841                                    &StageAndNext,
842                                    &focus_handle,
843                                ))
844                                .on_click(cx.listener(|this, _, window, cx| {
845                                    this.dispatch_action(&StageAndNext, window, cx)
846                                })),
847                        )
848                        .child(
849                            Button::new("unstage", "Unstage")
850                                .disabled(!button_states.unstage)
851                                .tooltip(Tooltip::for_action_title_in(
852                                    "Unstage and Go to Next Hunk",
853                                    &UnstageAndNext,
854                                    &focus_handle,
855                                ))
856                                .on_click(cx.listener(|this, _, window, cx| {
857                                    this.dispatch_action(&UnstageAndNext, window, cx)
858                                })),
859                        )
860                    })
861                    .child(
862                        Button::new("restore", "Restore")
863                            .tooltip(Tooltip::for_action_title_in(
864                                "Restore selected hunk",
865                                &Restore,
866                                &focus_handle,
867                            ))
868                            .disabled(!button_states.restore)
869                            .on_click(cx.listener(|this, _, window, cx| {
870                                this.dispatch_action(&Restore, window, cx)
871                            })),
872                    ),
873            )
874            .child(Divider::vertical())
875            .child(h_group_sm().child(if button_states.stage_file {
876                Button::new("stage-file", "Stage All")
877                    .width(rems_from_px(80.))
878                    .disabled(!button_states.stage_file)
879                    .tooltip(Tooltip::for_action_title_in(
880                        "Stage All",
881                        &StageFile,
882                        &focus_handle,
883                    ))
884                    .on_click(cx.listener(|this, _, window, cx| this.stage_file(window, cx)))
885            } else {
886                Button::new("unstage-file", "Unstage All")
887                    .width(rems_from_px(80.))
888                    .disabled(!button_states.unstage_file)
889                    .tooltip(Tooltip::for_action_title_in(
890                        "Unstage All",
891                        &UnstageFile,
892                        &focus_handle,
893                    ))
894                    .on_click(cx.listener(|this, _, window, cx| this.unstage_file(window, cx)))
895            }))
896            .child(Divider::vertical())
897            .child(
898                Button::new("commit", "Commit")
899                    .tooltip(Tooltip::for_action_title_in(
900                        "Commit",
901                        &Commit,
902                        &focus_handle,
903                    ))
904                    .on_click(cx.listener(|this, _, window, cx| {
905                        this.dispatch_action(&Commit, window, cx);
906                    })),
907            )
908            .into_any_element()
909    }
910}
911
Served at tenant.openagents/omega Member data and write actions are omitted.