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                            .min_w_0()
271                            .w_full()
272                            .justify_between()
273                            .child(
274                                h_flex()
275                                    .min_w_0()
276                                    .w_full()
277                                    .gap_1()
278                                    .child(
279                                        Label::new(entry.author_name.clone())
280                                            .size(LabelSize::Small)
281                                            .color(Color::Default)
282                                            .truncate(),
283                                    )
284                                    .child(
285                                        Label::new(&entry.subject)
286                                            .size(LabelSize::Small)
287                                            .color(Color::Muted)
288                                            .truncate(),
289                                    ),
290                            )
291                            .child(
292                                h_flex().flex_none().child(
293                                    Label::new(relative_timestamp)
294                                        .size(LabelSize::Small)
295                                        .color(Color::Muted),
296                                ),
297                            ),
298                    ),
299            )
300            .on_click(cx.listener(move |this, _, window, cx| {
301                this.selected_entry = Some(ix);
302                cx.notify();
303
304                if let Some(repo) = repo.upgrade() {
305                    let sha_str = sha.to_string();
306                    CommitView::open(
307                        sha_str,
308                        repo.downgrade(),
309                        workspace.clone(),
310                        None,
311                        Some(file_path.clone()),
312                        window,
313                        cx,
314                    );
315                }
316            }))
317            .into_any_element()
318    }
319}
320
321#[derive(Clone, Debug)]
322struct CommitAvatarAsset {
323    sha: SharedString,
324    remote: GitRemote,
325}
326
327impl std::hash::Hash for CommitAvatarAsset {
328    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
329        self.sha.hash(state);
330        self.remote.host.name().hash(state);
331    }
332}
333
334impl CommitAvatarAsset {
335    fn new(remote: GitRemote, sha: SharedString) -> Self {
336        Self { remote, sha }
337    }
338}
339
340impl Asset for CommitAvatarAsset {
341    type Source = Self;
342    type Output = Option<SharedString>;
343
344    fn load(
345        source: Self::Source,
346        cx: &mut App,
347    ) -> impl Future<Output = Self::Output> + Send + 'static {
348        let client = cx.http_client();
349        async move {
350            match source
351                .remote
352                .host
353                .commit_author_avatar_url(
354                    &source.remote.owner,
355                    &source.remote.repo,
356                    source.sha.clone(),
357                    client,
358                )
359                .await
360            {
361                Ok(Some(url)) => Some(SharedString::from(url.to_string())),
362                Ok(None) => None,
363                Err(_) => None,
364            }
365        }
366    }
367}
368
369impl EventEmitter<ItemEvent> for FileHistoryView {}
370
371impl Focusable for FileHistoryView {
372    fn focus_handle(&self, _cx: &App) -> FocusHandle {
373        self.focus_handle.clone()
374    }
375}
376
377impl Render for FileHistoryView {
378    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
379        let _file_name = self.history.path.file_name().unwrap_or("File");
380        let entry_count = self.history.entries.len();
381
382        v_flex()
383            .size_full()
384            .bg(cx.theme().colors().editor_background)
385            .child(
386                h_flex()
387                    .h(rems_from_px(41.))
388                    .pl_3()
389                    .pr_2()
390                    .justify_between()
391                    .border_b_1()
392                    .border_color(cx.theme().colors().border_variant)
393                    .child(
394                        Label::new(self.history.path.as_unix_str().to_string())
395                            .color(Color::Muted)
396                            .buffer_font(cx),
397                    )
398                    .child(
399                        h_flex()
400                            .gap_1p5()
401                            .child(
402                                Label::new(format!("{} commits", entry_count))
403                                    .size(LabelSize::Small)
404                                    .color(Color::Muted)
405                                    .when(self.has_more, |this| this.mr_1()),
406                            )
407                            .when(self.has_more, |this| {
408                                this.child(Divider::vertical()).child(
409                                    Button::new("load-more", "Load More")
410                                        .disabled(self.loading_more)
411                                        .label_size(LabelSize::Small)
412                                        .icon(IconName::ArrowCircle)
413                                        .icon_size(IconSize::Small)
414                                        .icon_color(Color::Muted)
415                                        .icon_position(IconPosition::Start)
416                                        .on_click(cx.listener(|this, _, window, cx| {
417                                            this.load_more(window, cx);
418                                        })),
419                                )
420                            }),
421                    ),
422            )
423            .child(
424                v_flex()
425                    .flex_1()
426                    .size_full()
427                    .child({
428                        let view = cx.weak_entity();
429                        uniform_list(
430                            "file-history-list",
431                            entry_count,
432                            move |range, window, cx| {
433                                let Some(view) = view.upgrade() else {
434                                    return Vec::new();
435                                };
436                                view.update(cx, |this, cx| {
437                                    let mut items = Vec::with_capacity(range.end - range.start);
438                                    for ix in range {
439                                        if let Some(entry) = this.history.entries.get(ix) {
440                                            items.push(
441                                                this.render_commit_entry(ix, entry, window, cx),
442                                            );
443                                        }
444                                    }
445                                    items
446                                })
447                            },
448                        )
449                        .flex_1()
450                        .size_full()
451                        .track_scroll(&self.scroll_handle)
452                    })
453                    .vertical_scrollbar_for(&self.scroll_handle, window, cx),
454            )
455    }
456}
457
458impl Item for FileHistoryView {
459    type Event = ItemEvent;
460
461    fn to_item_events(event: &Self::Event, mut f: impl FnMut(ItemEvent)) {
462        f(*event)
463    }
464
465    fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
466        let file_name = self
467            .history
468            .path
469            .file_name()
470            .map(|name| name.to_string())
471            .unwrap_or_else(|| "File".to_string());
472        format!("History: {}", file_name).into()
473    }
474
475    fn tab_tooltip_text(&self, _cx: &App) -> Option<SharedString> {
476        Some(format!("Git history for {}", self.history.path.as_unix_str()).into())
477    }
478
479    fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
480        Some(Icon::new(IconName::GitBranch))
481    }
482
483    fn telemetry_event_text(&self) -> Option<&'static str> {
484        Some("file history")
485    }
486
487    fn clone_on_split(
488        &self,
489        _workspace_id: Option<workspace::WorkspaceId>,
490        _window: &mut Window,
491        _cx: &mut Context<Self>,
492    ) -> Task<Option<Entity<Self>>> {
493        Task::ready(None)
494    }
495
496    fn navigate(&mut self, _: Box<dyn Any>, _window: &mut Window, _: &mut Context<Self>) -> bool {
497        false
498    }
499
500    fn deactivated(&mut self, _window: &mut Window, _: &mut Context<Self>) {}
501
502    fn can_save(&self, _: &App) -> bool {
503        false
504    }
505
506    fn save(
507        &mut self,
508        _options: SaveOptions,
509        _project: Entity<Project>,
510        _window: &mut Window,
511        _: &mut Context<Self>,
512    ) -> Task<Result<()>> {
513        Task::ready(Ok(()))
514    }
515
516    fn save_as(
517        &mut self,
518        _project: Entity<Project>,
519        _path: ProjectPath,
520        _window: &mut Window,
521        _: &mut Context<Self>,
522    ) -> Task<Result<()>> {
523        Task::ready(Ok(()))
524    }
525
526    fn reload(
527        &mut self,
528        _project: Entity<Project>,
529        _window: &mut Window,
530        _: &mut Context<Self>,
531    ) -> Task<Result<()>> {
532        Task::ready(Ok(()))
533    }
534
535    fn is_dirty(&self, _: &App) -> bool {
536        false
537    }
538
539    fn has_conflict(&self, _: &App) -> bool {
540        false
541    }
542
543    fn breadcrumbs(
544        &self,
545        _theme: &theme::Theme,
546        _cx: &App,
547    ) -> Option<Vec<workspace::item::BreadcrumbText>> {
548        None
549    }
550
551    fn added_to_workspace(
552        &mut self,
553        _workspace: &mut Workspace,
554        window: &mut Window,
555        _cx: &mut Context<Self>,
556    ) {
557        window.focus(&self.focus_handle);
558    }
559
560    fn show_toolbar(&self) -> bool {
561        true
562    }
563
564    fn pixel_position_of_cursor(&self, _: &App) -> Option<gpui::Point<gpui::Pixels>> {
565        None
566    }
567
568    fn set_nav_history(
569        &mut self,
570        _: workspace::ItemNavHistory,
571        _window: &mut Window,
572        _: &mut Context<Self>,
573    ) {
574    }
575
576    fn act_as_type<'a>(
577        &'a self,
578        type_id: TypeId,
579        self_handle: &'a Entity<Self>,
580        _: &'a App,
581    ) -> Option<AnyEntity> {
582        if type_id == TypeId::of::<Self>() {
583            Some(self_handle.clone().into())
584        } else {
585            None
586        }
587    }
588}