Skip to repository content911 lines · 31.7 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T03:28:46.854Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
solo_diff_view.rs
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