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        window: &mut Window,
287        cx: &mut App,
288    ) -> impl IntoElement {
289        let remote = self.remote.as_ref().filter(|r| r.host_supports_avatars());
290        let size = rems_from_px(20.);
291
292        if let Some(remote) = remote {
293            let avatar_asset = CommitAvatarAsset::new(remote.clone(), sha.clone());
294            if let Some(Some(url)) = window.use_asset::<CommitAvatarAsset>(&avatar_asset, cx) {
295                Avatar::new(url.to_string()).size(size)
296            } else {
297                Avatar::new("").size(size)
298            }
299        } else {
300            Avatar::new("").size(size)
301        }
302    }
303
304    fn render_commit_entry(
305        &self,
306        ix: usize,
307        entry: &FileHistoryEntry,
308        window: &mut Window,
309        cx: &mut Context<Self>,
310    ) -> AnyElement {
311        let pr_number = entry
312            .subject
313            .rfind("(#")
314            .and_then(|start| {
315                let rest = &entry.subject[start + 2..];
316                rest.find(')')
317                    .and_then(|end| rest[..end].parse::<u32>().ok())
318            })
319            .map(|num| format!("#{}", num))
320            .unwrap_or_else(|| {
321                if entry.sha.len() >= 7 {
322                    entry.sha[..7].to_string()
323                } else {
324                    entry.sha.to_string()
325                }
326            });
327
328        let commit_time = OffsetDateTime::from_unix_timestamp(entry.commit_timestamp)
329            .unwrap_or_else(|_| OffsetDateTime::UNIX_EPOCH);
330        let relative_timestamp = time_format::format_localized_timestamp(
331            commit_time,
332            OffsetDateTime::now_utc(),
333            time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC),
334            time_format::TimestampFormat::Relative,
335        );
336
337        ListItem::new(("commit", ix))
338            .toggle_state(Some(ix) == self.selected_entry)
339            .child(
340                h_flex()
341                    .h_8()
342                    .w_full()
343                    .pl_0p5()
344                    .pr_2p5()
345                    .gap_2()
346                    .child(
347                        div()
348                            .w(rems_from_px(52.))
349                            .flex_none()
350                            .child(Chip::new(pr_number)),
351                    )
352                    .child(self.render_commit_avatar(&entry.sha, window, cx))
353                    .child(
354                        h_flex()
355                            .min_w_0()
356                            .w_full()
357                            .justify_between()
358                            .child(
359                                h_flex()
360                                    .min_w_0()
361                                    .w_full()
362                                    .gap_1()
363                                    .child(
364                                        Label::new(entry.author_name.clone())
365                                            .size(LabelSize::Small)
366                                            .color(Color::Default)
367                                            .truncate(),
368                                    )
369                                    .child(
370                                        Label::new(&entry.subject)
371                                            .size(LabelSize::Small)
372                                            .color(Color::Muted)
373                                            .truncate(),
374                                    ),
375                            )
376                            .child(
377                                h_flex().flex_none().child(
378                                    Label::new(relative_timestamp)
379                                        .size(LabelSize::Small)
380                                        .color(Color::Muted),
381                                ),
382                            ),
383                    ),
384            )
385            .on_click(cx.listener(move |this, _, window, cx| {
386                this.selected_entry = Some(ix);
387                cx.notify();
388
389                this.open_commit_view(window, cx);
390            }))
391            .into_any_element()
392    }
393}
394
395#[derive(Clone, Debug)]
396struct CommitAvatarAsset {
397    sha: SharedString,
398    remote: GitRemote,
399}
400
401impl std::hash::Hash for CommitAvatarAsset {
402    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
403        self.sha.hash(state);
404        self.remote.host.name().hash(state);
405    }
406}
407
408impl CommitAvatarAsset {
409    fn new(remote: GitRemote, sha: SharedString) -> Self {
410        Self { remote, sha }
411    }
412}
413
414impl Asset for CommitAvatarAsset {
415    type Source = Self;
416    type Output = Option<SharedString>;
417
418    fn load(
419        source: Self::Source,
420        cx: &mut App,
421    ) -> impl Future<Output = Self::Output> + Send + 'static {
422        let client = cx.http_client();
423        async move {
424            match source
425                .remote
426                .host
427                .commit_author_avatar_url(
428                    &source.remote.owner,
429                    &source.remote.repo,
430                    source.sha.clone(),
431                    client,
432                )
433                .await
434            {
435                Ok(Some(url)) => Some(SharedString::from(url.to_string())),
436                Ok(None) => None,
437                Err(_) => None,
438            }
439        }
440    }
441}
442
443impl EventEmitter<ItemEvent> for FileHistoryView {}
444
445impl Focusable for FileHistoryView {
446    fn focus_handle(&self, _cx: &App) -> FocusHandle {
447        self.focus_handle.clone()
448    }
449}
450
451impl Render for FileHistoryView {
452    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
453        let _file_name = self.history.path.file_name().unwrap_or("File");
454        let entry_count = self.history.entries.len();
455
456        v_flex()
457            .id("file_history_view")
458            .key_context("FileHistoryView")
459            .track_focus(&self.focus_handle)
460            .on_action(cx.listener(Self::select_next))
461            .on_action(cx.listener(Self::select_previous))
462            .on_action(cx.listener(Self::select_first))
463            .on_action(cx.listener(Self::select_last))
464            .on_action(cx.listener(Self::confirm))
465            .size_full()
466            .bg(cx.theme().colors().editor_background)
467            .child(
468                h_flex()
469                    .h(rems_from_px(41.))
470                    .pl_3()
471                    .pr_2()
472                    .justify_between()
473                    .border_b_1()
474                    .border_color(cx.theme().colors().border_variant)
475                    .child(
476                        Label::new(self.history.path.as_unix_str().to_string())
477                            .color(Color::Muted)
478                            .buffer_font(cx),
479                    )
480                    .child(
481                        h_flex()
482                            .gap_1p5()
483                            .child(
484                                Label::new(format!("{} commits", entry_count))
485                                    .size(LabelSize::Small)
486                                    .color(Color::Muted)
487                                    .when(self.has_more, |this| this.mr_1()),
488                            )
489                            .when(self.has_more, |this| {
490                                this.child(Divider::vertical()).child(
491                                    Button::new("load-more", "Load More")
492                                        .disabled(self.loading_more)
493                                        .label_size(LabelSize::Small)
494                                        .icon(IconName::ArrowCircle)
495                                        .icon_size(IconSize::Small)
496                                        .icon_color(Color::Muted)
497                                        .icon_position(IconPosition::Start)
498                                        .on_click(cx.listener(|this, _, window, cx| {
499                                            this.load_more(window, cx);
500                                        })),
501                                )
502                            }),
503                    ),
504            )
505            .child(
506                v_flex()
507                    .flex_1()
508                    .size_full()
509                    .child({
510                        let view = cx.weak_entity();
511                        uniform_list(
512                            "file-history-list",
513                            entry_count,
514                            move |range, window, cx| {
515                                let Some(view) = view.upgrade() else {
516                                    return Vec::new();
517                                };
518                                view.update(cx, |this, cx| {
519                                    let mut items = Vec::with_capacity(range.end - range.start);
520                                    for ix in range {
521                                        if let Some(entry) = this.history.entries.get(ix) {
522                                            items.push(
523                                                this.render_commit_entry(ix, entry, window, cx),
524                                            );
525                                        }
526                                    }
527                                    items
528                                })
529                            },
530                        )
531                        .flex_1()
532                        .size_full()
533                        .track_scroll(&self.scroll_handle)
534                    })
535                    .vertical_scrollbar_for(&self.scroll_handle, window, cx),
536            )
537    }
538}
539
540impl Item for FileHistoryView {
541    type Event = ItemEvent;
542
543    fn to_item_events(event: &Self::Event, mut f: impl FnMut(ItemEvent)) {
544        f(*event)
545    }
546
547    fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
548        let file_name = self
549            .history
550            .path
551            .file_name()
552            .map(|name| name.to_string())
553            .unwrap_or_else(|| "File".to_string());
554        format!("History: {}", file_name).into()
555    }
556
557    fn tab_tooltip_text(&self, _cx: &App) -> Option<SharedString> {
558        Some(format!("Git history for {}", self.history.path.as_unix_str()).into())
559    }
560
561    fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
562        Some(Icon::new(IconName::GitBranch))
563    }
564
565    fn telemetry_event_text(&self) -> Option<&'static str> {
566        Some("file history")
567    }
568
569    fn clone_on_split(
570        &self,
571        _workspace_id: Option<workspace::WorkspaceId>,
572        _window: &mut Window,
573        _cx: &mut Context<Self>,
574    ) -> Task<Option<Entity<Self>>> {
575        Task::ready(None)
576    }
577
578    fn navigate(
579        &mut self,
580        _: Arc<dyn Any + Send>,
581        _window: &mut Window,
582        _: &mut Context<Self>,
583    ) -> bool {
584        false
585    }
586
587    fn deactivated(&mut self, _window: &mut Window, _: &mut Context<Self>) {}
588
589    fn can_save(&self, _: &App) -> bool {
590        false
591    }
592
593    fn save(
594        &mut self,
595        _options: SaveOptions,
596        _project: Entity<Project>,
597        _window: &mut Window,
598        _: &mut Context<Self>,
599    ) -> Task<Result<()>> {
600        Task::ready(Ok(()))
601    }
602
603    fn save_as(
604        &mut self,
605        _project: Entity<Project>,
606        _path: ProjectPath,
607        _window: &mut Window,
608        _: &mut Context<Self>,
609    ) -> Task<Result<()>> {
610        Task::ready(Ok(()))
611    }
612
613    fn reload(
614        &mut self,
615        _project: Entity<Project>,
616        _window: &mut Window,
617        _: &mut Context<Self>,
618    ) -> Task<Result<()>> {
619        Task::ready(Ok(()))
620    }
621
622    fn is_dirty(&self, _: &App) -> bool {
623        false
624    }
625
626    fn has_conflict(&self, _: &App) -> bool {
627        false
628    }
629
630    fn breadcrumbs(
631        &self,
632        _theme: &theme::Theme,
633        _cx: &App,
634    ) -> Option<Vec<workspace::item::BreadcrumbText>> {
635        None
636    }
637
638    fn added_to_workspace(
639        &mut self,
640        _workspace: &mut Workspace,
641        window: &mut Window,
642        cx: &mut Context<Self>,
643    ) {
644        window.focus(&self.focus_handle, cx);
645    }
646
647    fn show_toolbar(&self) -> bool {
648        true
649    }
650
651    fn pixel_position_of_cursor(&self, _: &App) -> Option<gpui::Point<gpui::Pixels>> {
652        None
653    }
654
655    fn set_nav_history(
656        &mut self,
657        _: workspace::ItemNavHistory,
658        _window: &mut Window,
659        _: &mut Context<Self>,
660    ) {
661    }
662
663    fn act_as_type<'a>(
664        &'a self,
665        type_id: TypeId,
666        self_handle: &'a Entity<Self>,
667        _: &'a App,
668    ) -> Option<AnyEntity> {
669        if type_id == TypeId::of::<Self>() {
670            Some(self_handle.clone().into())
671        } else {
672            None
673        }
674    }
675}