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