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 .start_icon(
433 Icon::new(IconName::ArrowCircle)
434 .size(IconSize::Small)
435 .color(Color::Muted),
436 )
437 .on_click(cx.listener(|this, _, window, cx| {
438 this.load_more(window, cx);
439 })),
440 )
441 }),
442 ),
443 )
444 .child(
445 v_flex()
446 .flex_1()
447 .size_full()
448 .child({
449 let view = cx.weak_entity();
450 uniform_list(
451 "file-history-list",
452 entry_count,
453 move |range, window, cx| {
454 let Some(view) = view.upgrade() else {
455 return Vec::new();
456 };
457 view.update(cx, |this, cx| {
458 let mut items = Vec::with_capacity(range.end - range.start);
459 for ix in range {
460 if let Some(entry) = this.history.entries.get(ix) {
461 items.push(
462 this.render_commit_entry(ix, entry, window, cx),
463 );
464 }
465 }
466 items
467 })
468 },
469 )
470 .flex_1()
471 .size_full()
472 .track_scroll(&self.scroll_handle)
473 })
474 .vertical_scrollbar_for(&self.scroll_handle, window, cx),
475 )
476 }
477}
478
479impl Item for FileHistoryView {
480 type Event = ItemEvent;
481
482 fn to_item_events(event: &Self::Event, f: &mut dyn FnMut(ItemEvent)) {
483 f(*event)
484 }
485
486 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
487 let file_name = self
488 .history
489 .path
490 .file_name()
491 .map(|name| name.to_string())
492 .unwrap_or_else(|| "File".to_string());
493 format!("History: {}", file_name).into()
494 }
495
496 fn tab_tooltip_text(&self, _cx: &App) -> Option<SharedString> {
497 Some(format!("Git history for {}", self.history.path.as_unix_str()).into())
498 }
499
500 fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
501 Some(Icon::new(IconName::GitBranch))
502 }
503
504 fn telemetry_event_text(&self) -> Option<&'static str> {
505 Some("file history")
506 }
507
508 fn clone_on_split(
509 &self,
510 _workspace_id: Option<workspace::WorkspaceId>,
511 _window: &mut Window,
512 _cx: &mut Context<Self>,
513 ) -> Task<Option<Entity<Self>>> {
514 Task::ready(None)
515 }
516
517 fn navigate(
518 &mut self,
519 _: Arc<dyn Any + Send>,
520 _window: &mut Window,
521 _: &mut Context<Self>,
522 ) -> bool {
523 false
524 }
525
526 fn deactivated(&mut self, _window: &mut Window, _: &mut Context<Self>) {}
527
528 fn can_save(&self, _: &App) -> bool {
529 false
530 }
531
532 fn save(
533 &mut self,
534 _options: SaveOptions,
535 _project: Entity<Project>,
536 _window: &mut Window,
537 _: &mut Context<Self>,
538 ) -> Task<Result<()>> {
539 Task::ready(Ok(()))
540 }
541
542 fn save_as(
543 &mut self,
544 _project: Entity<Project>,
545 _path: ProjectPath,
546 _window: &mut Window,
547 _: &mut Context<Self>,
548 ) -> Task<Result<()>> {
549 Task::ready(Ok(()))
550 }
551
552 fn reload(
553 &mut self,
554 _project: Entity<Project>,
555 _window: &mut Window,
556 _: &mut Context<Self>,
557 ) -> Task<Result<()>> {
558 Task::ready(Ok(()))
559 }
560
561 fn is_dirty(&self, _: &App) -> bool {
562 false
563 }
564
565 fn has_conflict(&self, _: &App) -> bool {
566 false
567 }
568
569 fn breadcrumbs(
570 &self,
571 _cx: &App,
572 ) -> Option<(Vec<workspace::item::HighlightedText>, Option<gpui::Font>)> {
573 None
574 }
575
576 fn added_to_workspace(
577 &mut self,
578 _workspace: &mut Workspace,
579 window: &mut Window,
580 cx: &mut Context<Self>,
581 ) {
582 window.focus(&self.focus_handle, cx);
583 }
584
585 fn show_toolbar(&self) -> bool {
586 true
587 }
588
589 fn pixel_position_of_cursor(&self, _: &App) -> Option<gpui::Point<gpui::Pixels>> {
590 None
591 }
592
593 fn set_nav_history(
594 &mut self,
595 _: workspace::ItemNavHistory,
596 _window: &mut Window,
597 _: &mut Context<Self>,
598 ) {
599 }
600
601 fn act_as_type<'a>(
602 &'a self,
603 type_id: TypeId,
604 self_handle: &'a Entity<Self>,
605 _: &'a App,
606 ) -> Option<AnyEntity> {
607 if type_id == TypeId::of::<Self>() {
608 Some(self_handle.clone().into())
609 } else {
610 None
611 }
612 }
613}