file_history_view.rs

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