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