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