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 .w_full()
271 .justify_between()
272 .child(
273 h_flex()
274 .gap_1()
275 .child(
276 Label::new(entry.author_name.clone())
277 .size(LabelSize::Small)
278 .color(Color::Default),
279 )
280 .child(
281 Label::new(&entry.subject)
282 .size(LabelSize::Small)
283 .color(Color::Muted)
284 .truncate(),
285 ),
286 )
287 .child(
288 Label::new(relative_timestamp)
289 .size(LabelSize::Small)
290 .color(Color::Muted),
291 ),
292 ),
293 )
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 .into_any_element()
312 }
313}
314
315#[derive(Clone, Debug)]
316struct CommitAvatarAsset {
317 sha: SharedString,
318 remote: GitRemote,
319}
320
321impl std::hash::Hash for CommitAvatarAsset {
322 fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
323 self.sha.hash(state);
324 self.remote.host.name().hash(state);
325 }
326}
327
328impl CommitAvatarAsset {
329 fn new(remote: GitRemote, sha: SharedString) -> Self {
330 Self { remote, sha }
331 }
332}
333
334impl Asset for CommitAvatarAsset {
335 type Source = Self;
336 type Output = Option<SharedString>;
337
338 fn load(
339 source: Self::Source,
340 cx: &mut App,
341 ) -> impl Future<Output = Self::Output> + Send + 'static {
342 let client = cx.http_client();
343 async move {
344 match source
345 .remote
346 .host
347 .commit_author_avatar_url(
348 &source.remote.owner,
349 &source.remote.repo,
350 source.sha.clone(),
351 client,
352 )
353 .await
354 {
355 Ok(Some(url)) => Some(SharedString::from(url.to_string())),
356 Ok(None) => None,
357 Err(_) => None,
358 }
359 }
360 }
361}
362
363impl EventEmitter<ItemEvent> for FileHistoryView {}
364
365impl Focusable for FileHistoryView {
366 fn focus_handle(&self, _cx: &App) -> FocusHandle {
367 self.focus_handle.clone()
368 }
369}
370
371impl Render for FileHistoryView {
372 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
373 let _file_name = self.history.path.file_name().unwrap_or("File");
374 let entry_count = self.history.entries.len();
375
376 v_flex()
377 .size_full()
378 .bg(cx.theme().colors().editor_background)
379 .child(
380 h_flex()
381 .h(rems_from_px(41.))
382 .pl_3()
383 .pr_2()
384 .justify_between()
385 .border_b_1()
386 .border_color(cx.theme().colors().border_variant)
387 .child(
388 Label::new(self.history.path.as_unix_str().to_string())
389 .color(Color::Muted)
390 .buffer_font(cx),
391 )
392 .child(
393 h_flex()
394 .gap_1p5()
395 .child(
396 Label::new(format!("{} commits", entry_count))
397 .size(LabelSize::Small)
398 .color(Color::Muted)
399 .when(self.has_more, |this| this.mr_1()),
400 )
401 .when(self.has_more, |this| {
402 this.child(Divider::vertical()).child(
403 Button::new("load-more", "Load More")
404 .disabled(self.loading_more)
405 .label_size(LabelSize::Small)
406 .icon(IconName::ArrowCircle)
407 .icon_size(IconSize::Small)
408 .icon_color(Color::Muted)
409 .icon_position(IconPosition::Start)
410 .on_click(cx.listener(|this, _, window, cx| {
411 this.load_more(window, cx);
412 })),
413 )
414 }),
415 ),
416 )
417 .child(
418 v_flex()
419 .flex_1()
420 .size_full()
421 .child({
422 let view = cx.weak_entity();
423 uniform_list(
424 "file-history-list",
425 entry_count,
426 move |range, window, cx| {
427 let Some(view) = view.upgrade() else {
428 return Vec::new();
429 };
430 view.update(cx, |this, cx| {
431 let mut items = Vec::with_capacity(range.end - range.start);
432 for ix in range {
433 if let Some(entry) = this.history.entries.get(ix) {
434 items.push(
435 this.render_commit_entry(ix, entry, window, cx),
436 );
437 }
438 }
439 items
440 })
441 },
442 )
443 .flex_1()
444 .size_full()
445 .track_scroll(&self.scroll_handle)
446 })
447 .vertical_scrollbar_for(&self.scroll_handle, window, cx),
448 )
449 }
450}
451
452impl Item for FileHistoryView {
453 type Event = ItemEvent;
454
455 fn to_item_events(event: &Self::Event, mut f: impl FnMut(ItemEvent)) {
456 f(*event)
457 }
458
459 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
460 let file_name = self
461 .history
462 .path
463 .file_name()
464 .map(|name| name.to_string())
465 .unwrap_or_else(|| "File".to_string());
466 format!("History: {}", file_name).into()
467 }
468
469 fn tab_tooltip_text(&self, _cx: &App) -> Option<SharedString> {
470 Some(format!("Git history for {}", self.history.path.as_unix_str()).into())
471 }
472
473 fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
474 Some(Icon::new(IconName::GitBranch))
475 }
476
477 fn telemetry_event_text(&self) -> Option<&'static str> {
478 Some("file history")
479 }
480
481 fn clone_on_split(
482 &self,
483 _workspace_id: Option<workspace::WorkspaceId>,
484 _window: &mut Window,
485 _cx: &mut Context<Self>,
486 ) -> Task<Option<Entity<Self>>> {
487 Task::ready(None)
488 }
489
490 fn navigate(&mut self, _: Box<dyn Any>, _window: &mut Window, _: &mut Context<Self>) -> bool {
491 false
492 }
493
494 fn deactivated(&mut self, _window: &mut Window, _: &mut Context<Self>) {}
495
496 fn can_save(&self, _: &App) -> bool {
497 false
498 }
499
500 fn save(
501 &mut self,
502 _options: SaveOptions,
503 _project: Entity<Project>,
504 _window: &mut Window,
505 _: &mut Context<Self>,
506 ) -> Task<Result<()>> {
507 Task::ready(Ok(()))
508 }
509
510 fn save_as(
511 &mut self,
512 _project: Entity<Project>,
513 _path: ProjectPath,
514 _window: &mut Window,
515 _: &mut Context<Self>,
516 ) -> Task<Result<()>> {
517 Task::ready(Ok(()))
518 }
519
520 fn reload(
521 &mut self,
522 _project: Entity<Project>,
523 _window: &mut Window,
524 _: &mut Context<Self>,
525 ) -> Task<Result<()>> {
526 Task::ready(Ok(()))
527 }
528
529 fn is_dirty(&self, _: &App) -> bool {
530 false
531 }
532
533 fn has_conflict(&self, _: &App) -> bool {
534 false
535 }
536
537 fn breadcrumbs(
538 &self,
539 _theme: &theme::Theme,
540 _cx: &App,
541 ) -> Option<Vec<workspace::item::BreadcrumbText>> {
542 None
543 }
544
545 fn added_to_workspace(
546 &mut self,
547 _workspace: &mut Workspace,
548 window: &mut Window,
549 _cx: &mut Context<Self>,
550 ) {
551 window.focus(&self.focus_handle);
552 }
553
554 fn show_toolbar(&self) -> bool {
555 true
556 }
557
558 fn pixel_position_of_cursor(&self, _: &App) -> Option<gpui::Point<gpui::Pixels>> {
559 None
560 }
561
562 fn set_nav_history(
563 &mut self,
564 _: workspace::ItemNavHistory,
565 _window: &mut Window,
566 _: &mut Context<Self>,
567 ) {
568 }
569
570 fn act_as_type<'a>(
571 &'a self,
572 type_id: TypeId,
573 self_handle: &'a Entity<Self>,
574 _: &'a App,
575 ) -> Option<AnyEntity> {
576 if type_id == TypeId::of::<Self>() {
577 Some(self_handle.clone().into())
578 } else {
579 None
580 }
581 }
582}