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