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