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