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