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