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