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