file_history_view.rs

  1use anyhow::Result;
  2use futures::Future;
  3use git::repository::{FileHistory, FileHistoryEntry, RepoPath};
  4use git::{GitHostingProviderRegistry, GitRemote, parse_git_remote_url};
  5use gpui::{
  6    AnyElement, AnyEntity, App, Asset, Context, Entity, EventEmitter, FocusHandle, Focusable,
  7    IntoElement, Render, ScrollStrategy, Task, UniformListScrollHandle, WeakEntity, Window,
  8    actions, uniform_list,
  9};
 10use project::{
 11    Project, ProjectPath,
 12    git_store::{GitStore, Repository},
 13};
 14use std::any::{Any, TypeId};
 15use std::sync::Arc;
 16
 17use time::OffsetDateTime;
 18use ui::{Avatar, Chip, Divider, ListItem, WithScrollbar, prelude::*};
 19use util::ResultExt;
 20use workspace::{
 21    Item, Workspace,
 22    item::{ItemEvent, SaveOptions},
 23};
 24
 25use crate::commit_view::CommitView;
 26
 27actions!(git, [ViewCommitFromHistory, LoadMoreHistory]);
 28
 29pub fn init(cx: &mut App) {
 30    cx.observe_new(|workspace: &mut Workspace, _window, _cx| {
 31        workspace.register_action(|_workspace, _: &ViewCommitFromHistory, _window, _cx| {});
 32        workspace.register_action(|_workspace, _: &LoadMoreHistory, _window, _cx| {});
 33    })
 34    .detach();
 35}
 36
 37const PAGE_SIZE: usize = 50;
 38
 39pub struct FileHistoryView {
 40    history: FileHistory,
 41    repository: WeakEntity<Repository>,
 42    git_store: WeakEntity<GitStore>,
 43    workspace: WeakEntity<Workspace>,
 44    remote: Option<GitRemote>,
 45    selected_entry: Option<usize>,
 46    scroll_handle: UniformListScrollHandle,
 47    focus_handle: FocusHandle,
 48    loading_more: bool,
 49    has_more: bool,
 50}
 51
 52impl FileHistoryView {
 53    pub fn open(
 54        path: RepoPath,
 55        git_store: WeakEntity<GitStore>,
 56        repo: WeakEntity<Repository>,
 57        workspace: WeakEntity<Workspace>,
 58        window: &mut Window,
 59        cx: &mut App,
 60    ) {
 61        let file_history_task = git_store
 62            .update(cx, |git_store, cx| {
 63                repo.upgrade().map(|repo| {
 64                    git_store.file_history_paginated(&repo, path.clone(), 0, Some(PAGE_SIZE), cx)
 65                })
 66            })
 67            .ok()
 68            .flatten();
 69
 70        window
 71            .spawn(cx, async move |cx| {
 72                let file_history = file_history_task?.await.log_err()?;
 73                let repo = repo.upgrade()?;
 74
 75                workspace
 76                    .update_in(cx, |workspace, window, cx| {
 77                        let project = workspace.project();
 78                        let view = cx.new(|cx| {
 79                            FileHistoryView::new(
 80                                file_history,
 81                                git_store.clone(),
 82                                repo.clone(),
 83                                workspace.weak_handle(),
 84                                project.clone(),
 85                                window,
 86                                cx,
 87                            )
 88                        });
 89
 90                        let pane = workspace.active_pane();
 91                        pane.update(cx, |pane, cx| {
 92                            let ix = pane.items().position(|item| {
 93                                let view = item.downcast::<FileHistoryView>();
 94                                view.is_some_and(|v| v.read(cx).history.path == path)
 95                            });
 96                            if let Some(ix) = ix {
 97                                pane.activate_item(ix, true, true, window, cx);
 98                            } else {
 99                                pane.add_item(Box::new(view), true, true, None, window, cx);
100                            }
101                        })
102                    })
103                    .log_err()
104            })
105            .detach();
106    }
107
108    fn new(
109        history: FileHistory,
110        git_store: WeakEntity<GitStore>,
111        repository: Entity<Repository>,
112        workspace: WeakEntity<Workspace>,
113        _project: Entity<Project>,
114        _window: &mut Window,
115        cx: &mut Context<Self>,
116    ) -> Self {
117        let focus_handle = cx.focus_handle();
118        let scroll_handle = UniformListScrollHandle::new();
119        let has_more = history.entries.len() >= PAGE_SIZE;
120
121        let snapshot = repository.read(cx).snapshot();
122        let remote_url = snapshot
123            .remote_upstream_url
124            .as_ref()
125            .or(snapshot.remote_origin_url.as_ref());
126
127        let remote = remote_url.and_then(|url| {
128            let provider_registry = GitHostingProviderRegistry::default_global(cx);
129            parse_git_remote_url(provider_registry, url).map(|(host, parsed)| GitRemote {
130                host,
131                owner: parsed.owner.into(),
132                repo: parsed.repo.into(),
133            })
134        });
135
136        Self {
137            history,
138            git_store,
139            repository: repository.downgrade(),
140            workspace,
141            remote,
142            selected_entry: None,
143            scroll_handle,
144            focus_handle,
145            loading_more: false,
146            has_more,
147        }
148    }
149
150    fn load_more(&mut self, window: &mut Window, cx: &mut Context<Self>) {
151        if self.loading_more || !self.has_more {
152            return;
153        }
154
155        self.loading_more = true;
156        cx.notify();
157
158        let current_count = self.history.entries.len();
159        let path = self.history.path.clone();
160        let git_store = self.git_store.clone();
161        let repo = self.repository.clone();
162
163        let this = cx.weak_entity();
164        let task = window.spawn(cx, async move |cx| {
165            let file_history_task = git_store
166                .update(cx, |git_store, cx| {
167                    repo.upgrade().map(|repo| {
168                        git_store.file_history_paginated(
169                            &repo,
170                            path,
171                            current_count,
172                            Some(PAGE_SIZE),
173                            cx,
174                        )
175                    })
176                })
177                .ok()
178                .flatten();
179
180            if let Some(task) = file_history_task {
181                if let Ok(more_history) = task.await {
182                    this.update(cx, |this, cx| {
183                        this.loading_more = false;
184                        this.has_more = more_history.entries.len() >= PAGE_SIZE;
185                        this.history.entries.extend(more_history.entries);
186                        cx.notify();
187                    })
188                    .ok();
189                }
190            }
191        });
192
193        task.detach();
194    }
195
196    fn select_next(&mut self, _: &menu::SelectNext, _: &mut Window, cx: &mut Context<Self>) {
197        let entry_count = self.history.entries.len();
198        let ix = match self.selected_entry {
199            _ if entry_count == 0 => None,
200            None => Some(0),
201            Some(ix) => {
202                if ix == entry_count - 1 {
203                    Some(0)
204                } else {
205                    Some(ix + 1)
206                }
207            }
208        };
209        self.select_ix(ix, cx);
210    }
211
212    fn select_previous(
213        &mut self,
214        _: &menu::SelectPrevious,
215        _: &mut Window,
216        cx: &mut Context<Self>,
217    ) {
218        let entry_count = self.history.entries.len();
219        let ix = match self.selected_entry {
220            _ if entry_count == 0 => None,
221            None => Some(entry_count - 1),
222            Some(ix) => {
223                if ix == 0 {
224                    Some(entry_count - 1)
225                } else {
226                    Some(ix - 1)
227                }
228            }
229        };
230        self.select_ix(ix, cx);
231    }
232
233    fn select_first(&mut self, _: &menu::SelectFirst, _: &mut Window, cx: &mut Context<Self>) {
234        let entry_count = self.history.entries.len();
235        let ix = if entry_count != 0 { Some(0) } else { None };
236        self.select_ix(ix, cx);
237    }
238
239    fn select_last(&mut self, _: &menu::SelectLast, _: &mut Window, cx: &mut Context<Self>) {
240        let entry_count = self.history.entries.len();
241        let ix = if entry_count != 0 {
242            Some(entry_count - 1)
243        } else {
244            None
245        };
246        self.select_ix(ix, cx);
247    }
248
249    fn select_ix(&mut self, ix: Option<usize>, cx: &mut Context<Self>) {
250        self.selected_entry = ix;
251        if let Some(ix) = ix {
252            self.scroll_handle.scroll_to_item(ix, ScrollStrategy::Top);
253        }
254        cx.notify();
255    }
256
257    fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
258        self.open_commit_view(window, cx);
259    }
260
261    fn open_commit_view(&mut self, window: &mut Window, cx: &mut Context<Self>) {
262        let Some(entry) = self
263            .selected_entry
264            .and_then(|ix| self.history.entries.get(ix))
265        else {
266            return;
267        };
268
269        if let Some(repo) = self.repository.upgrade() {
270            let sha_str = entry.sha.to_string();
271            CommitView::open(
272                sha_str,
273                repo.downgrade(),
274                self.workspace.clone(),
275                None,
276                Some(self.history.path.clone()),
277                window,
278                cx,
279            );
280        }
281    }
282
283    fn render_commit_avatar(
284        &self,
285        sha: &SharedString,
286        author_email: Option<SharedString>,
287        window: &mut Window,
288        cx: &mut App,
289    ) -> impl IntoElement {
290        let remote = self.remote.as_ref().filter(|r| r.host_supports_avatars());
291        let size = rems_from_px(20.);
292
293        if let Some(remote) = remote {
294            let avatar_asset = CommitAvatarAsset::new(remote.clone(), sha.clone(), author_email);
295            if let Some(Some(url)) = window.use_asset::<CommitAvatarAsset>(&avatar_asset, cx) {
296                Avatar::new(url.to_string()).size(size)
297            } else {
298                Avatar::new("").size(size)
299            }
300        } else {
301            Avatar::new("").size(size)
302        }
303    }
304
305    fn render_commit_entry(
306        &self,
307        ix: usize,
308        entry: &FileHistoryEntry,
309        window: &mut Window,
310        cx: &mut Context<Self>,
311    ) -> AnyElement {
312        let pr_number = entry
313            .subject
314            .rfind("(#")
315            .and_then(|start| {
316                let rest = &entry.subject[start + 2..];
317                rest.find(')')
318                    .and_then(|end| rest[..end].parse::<u32>().ok())
319            })
320            .map(|num| format!("#{}", num))
321            .unwrap_or_else(|| {
322                if entry.sha.len() >= 7 {
323                    entry.sha[..7].to_string()
324                } else {
325                    entry.sha.to_string()
326                }
327            });
328
329        let commit_time = OffsetDateTime::from_unix_timestamp(entry.commit_timestamp)
330            .unwrap_or_else(|_| OffsetDateTime::UNIX_EPOCH);
331        let relative_timestamp = time_format::format_localized_timestamp(
332            commit_time,
333            OffsetDateTime::now_utc(),
334            time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC),
335            time_format::TimestampFormat::Relative,
336        );
337
338        ListItem::new(("commit", ix))
339            .toggle_state(Some(ix) == self.selected_entry)
340            .child(
341                h_flex()
342                    .h_8()
343                    .w_full()
344                    .pl_0p5()
345                    .pr_2p5()
346                    .gap_2()
347                    .child(
348                        div()
349                            .w(rems_from_px(52.))
350                            .flex_none()
351                            .child(Chip::new(pr_number)),
352                    )
353                    .child(self.render_commit_avatar(
354                        &entry.sha,
355                        Some(entry.author_email.clone()),
356                        window,
357                        cx,
358                    ))
359                    .child(
360                        h_flex()
361                            .min_w_0()
362                            .w_full()
363                            .justify_between()
364                            .child(
365                                h_flex()
366                                    .min_w_0()
367                                    .w_full()
368                                    .gap_1()
369                                    .child(
370                                        Label::new(entry.author_name.clone())
371                                            .size(LabelSize::Small)
372                                            .color(Color::Default)
373                                            .truncate(),
374                                    )
375                                    .child(
376                                        Label::new(&entry.subject)
377                                            .size(LabelSize::Small)
378                                            .color(Color::Muted)
379                                            .truncate(),
380                                    ),
381                            )
382                            .child(
383                                h_flex().flex_none().child(
384                                    Label::new(relative_timestamp)
385                                        .size(LabelSize::Small)
386                                        .color(Color::Muted),
387                                ),
388                            ),
389                    ),
390            )
391            .on_click(cx.listener(move |this, _, window, cx| {
392                this.selected_entry = Some(ix);
393                cx.notify();
394
395                this.open_commit_view(window, cx);
396            }))
397            .into_any_element()
398    }
399}
400
401#[derive(Clone, Debug)]
402struct CommitAvatarAsset {
403    sha: SharedString,
404    author_email: Option<SharedString>,
405    remote: GitRemote,
406}
407
408impl std::hash::Hash for CommitAvatarAsset {
409    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
410        self.sha.hash(state);
411        self.remote.host.name().hash(state);
412    }
413}
414
415impl CommitAvatarAsset {
416    fn new(remote: GitRemote, sha: SharedString, author_email: Option<SharedString>) -> Self {
417        Self {
418            remote,
419            sha,
420            author_email,
421        }
422    }
423}
424
425impl Asset for CommitAvatarAsset {
426    type Source = Self;
427    type Output = Option<SharedString>;
428
429    fn load(
430        source: Self::Source,
431        cx: &mut App,
432    ) -> impl Future<Output = Self::Output> + Send + 'static {
433        let client = cx.http_client();
434        async move {
435            match source
436                .remote
437                .host
438                .commit_author_avatar_url(
439                    &source.remote.owner,
440                    &source.remote.repo,
441                    source.sha.clone(),
442                    source.author_email.clone(),
443                    client,
444                )
445                .await
446            {
447                Ok(Some(url)) => Some(SharedString::from(url.to_string())),
448                Ok(None) => None,
449                Err(_) => None,
450            }
451        }
452    }
453}
454
455impl EventEmitter<ItemEvent> for FileHistoryView {}
456
457impl Focusable for FileHistoryView {
458    fn focus_handle(&self, _cx: &App) -> FocusHandle {
459        self.focus_handle.clone()
460    }
461}
462
463impl Render for FileHistoryView {
464    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
465        let _file_name = self.history.path.file_name().unwrap_or("File");
466        let entry_count = self.history.entries.len();
467
468        v_flex()
469            .id("file_history_view")
470            .key_context("FileHistoryView")
471            .track_focus(&self.focus_handle)
472            .on_action(cx.listener(Self::select_next))
473            .on_action(cx.listener(Self::select_previous))
474            .on_action(cx.listener(Self::select_first))
475            .on_action(cx.listener(Self::select_last))
476            .on_action(cx.listener(Self::confirm))
477            .size_full()
478            .bg(cx.theme().colors().editor_background)
479            .child(
480                h_flex()
481                    .h(rems_from_px(41.))
482                    .pl_3()
483                    .pr_2()
484                    .justify_between()
485                    .border_b_1()
486                    .border_color(cx.theme().colors().border_variant)
487                    .child(
488                        Label::new(self.history.path.as_unix_str().to_string())
489                            .color(Color::Muted)
490                            .buffer_font(cx),
491                    )
492                    .child(
493                        h_flex()
494                            .gap_1p5()
495                            .child(
496                                Label::new(format!("{} commits", entry_count))
497                                    .size(LabelSize::Small)
498                                    .color(Color::Muted)
499                                    .when(self.has_more, |this| this.mr_1()),
500                            )
501                            .when(self.has_more, |this| {
502                                this.child(Divider::vertical()).child(
503                                    Button::new("load-more", "Load More")
504                                        .disabled(self.loading_more)
505                                        .label_size(LabelSize::Small)
506                                        .icon(IconName::ArrowCircle)
507                                        .icon_size(IconSize::Small)
508                                        .icon_color(Color::Muted)
509                                        .icon_position(IconPosition::Start)
510                                        .on_click(cx.listener(|this, _, window, cx| {
511                                            this.load_more(window, cx);
512                                        })),
513                                )
514                            }),
515                    ),
516            )
517            .child(
518                v_flex()
519                    .flex_1()
520                    .size_full()
521                    .child({
522                        let view = cx.weak_entity();
523                        uniform_list(
524                            "file-history-list",
525                            entry_count,
526                            move |range, window, cx| {
527                                let Some(view) = view.upgrade() else {
528                                    return Vec::new();
529                                };
530                                view.update(cx, |this, cx| {
531                                    let mut items = Vec::with_capacity(range.end - range.start);
532                                    for ix in range {
533                                        if let Some(entry) = this.history.entries.get(ix) {
534                                            items.push(
535                                                this.render_commit_entry(ix, entry, window, cx),
536                                            );
537                                        }
538                                    }
539                                    items
540                                })
541                            },
542                        )
543                        .flex_1()
544                        .size_full()
545                        .track_scroll(&self.scroll_handle)
546                    })
547                    .vertical_scrollbar_for(&self.scroll_handle, window, cx),
548            )
549    }
550}
551
552impl Item for FileHistoryView {
553    type Event = ItemEvent;
554
555    fn to_item_events(event: &Self::Event, f: &mut dyn FnMut(ItemEvent)) {
556        f(*event)
557    }
558
559    fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
560        let file_name = self
561            .history
562            .path
563            .file_name()
564            .map(|name| name.to_string())
565            .unwrap_or_else(|| "File".to_string());
566        format!("History: {}", file_name).into()
567    }
568
569    fn tab_tooltip_text(&self, _cx: &App) -> Option<SharedString> {
570        Some(format!("Git history for {}", self.history.path.as_unix_str()).into())
571    }
572
573    fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
574        Some(Icon::new(IconName::GitBranch))
575    }
576
577    fn telemetry_event_text(&self) -> Option<&'static str> {
578        Some("file history")
579    }
580
581    fn clone_on_split(
582        &self,
583        _workspace_id: Option<workspace::WorkspaceId>,
584        _window: &mut Window,
585        _cx: &mut Context<Self>,
586    ) -> Task<Option<Entity<Self>>> {
587        Task::ready(None)
588    }
589
590    fn navigate(
591        &mut self,
592        _: Arc<dyn Any + Send>,
593        _window: &mut Window,
594        _: &mut Context<Self>,
595    ) -> bool {
596        false
597    }
598
599    fn deactivated(&mut self, _window: &mut Window, _: &mut Context<Self>) {}
600
601    fn can_save(&self, _: &App) -> bool {
602        false
603    }
604
605    fn save(
606        &mut self,
607        _options: SaveOptions,
608        _project: Entity<Project>,
609        _window: &mut Window,
610        _: &mut Context<Self>,
611    ) -> Task<Result<()>> {
612        Task::ready(Ok(()))
613    }
614
615    fn save_as(
616        &mut self,
617        _project: Entity<Project>,
618        _path: ProjectPath,
619        _window: &mut Window,
620        _: &mut Context<Self>,
621    ) -> Task<Result<()>> {
622        Task::ready(Ok(()))
623    }
624
625    fn reload(
626        &mut self,
627        _project: Entity<Project>,
628        _window: &mut Window,
629        _: &mut Context<Self>,
630    ) -> Task<Result<()>> {
631        Task::ready(Ok(()))
632    }
633
634    fn is_dirty(&self, _: &App) -> bool {
635        false
636    }
637
638    fn has_conflict(&self, _: &App) -> bool {
639        false
640    }
641
642    fn breadcrumbs(&self, _cx: &App) -> Option<Vec<workspace::item::BreadcrumbText>> {
643        None
644    }
645
646    fn added_to_workspace(
647        &mut self,
648        _workspace: &mut Workspace,
649        window: &mut Window,
650        cx: &mut Context<Self>,
651    ) {
652        window.focus(&self.focus_handle, cx);
653    }
654
655    fn show_toolbar(&self) -> bool {
656        true
657    }
658
659    fn pixel_position_of_cursor(&self, _: &App) -> Option<gpui::Point<gpui::Pixels>> {
660        None
661    }
662
663    fn set_nav_history(
664        &mut self,
665        _: workspace::ItemNavHistory,
666        _window: &mut Window,
667        _: &mut Context<Self>,
668    ) {
669    }
670
671    fn act_as_type<'a>(
672        &'a self,
673        type_id: TypeId,
674        self_handle: &'a Entity<Self>,
675        _: &'a App,
676    ) -> Option<AnyEntity> {
677        if type_id == TypeId::of::<Self>() {
678            Some(self_handle.clone().into())
679        } else {
680            None
681        }
682    }
683}