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 None,
268 window,
269 cx,
270 );
271 }
272 }
273
274 fn render_commit_avatar(
275 &self,
276 sha: &SharedString,
277 author_email: Option<SharedString>,
278 window: &mut Window,
279 cx: &mut App,
280 ) -> AnyElement {
281 CommitAvatar::new(sha, author_email, self.remote.as_ref())
282 .size(rems_from_px(20.))
283 .render(window, cx)
284 }
285
286 fn render_commit_entry(
287 &self,
288 ix: usize,
289 entry: &FileHistoryEntry,
290 window: &mut Window,
291 cx: &mut Context<Self>,
292 ) -> AnyElement {
293 let pr_number = entry
294 .subject
295 .rfind("(#")
296 .and_then(|start| {
297 let rest = &entry.subject[start + 2..];
298 rest.find(')')
299 .and_then(|end| rest[..end].parse::<u32>().ok())
300 })
301 .map(|num| format!("#{}", num))
302 .unwrap_or_else(|| {
303 if entry.sha.len() >= 7 {
304 entry.sha[..7].to_string()
305 } else {
306 entry.sha.to_string()
307 }
308 });
309
310 let commit_time = OffsetDateTime::from_unix_timestamp(entry.commit_timestamp)
311 .unwrap_or_else(|_| OffsetDateTime::UNIX_EPOCH);
312 let relative_timestamp = time_format::format_localized_timestamp(
313 commit_time,
314 OffsetDateTime::now_utc(),
315 time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC),
316 time_format::TimestampFormat::Relative,
317 );
318
319 ListItem::new(("commit", ix))
320 .toggle_state(Some(ix) == self.selected_entry)
321 .child(
322 h_flex()
323 .h_8()
324 .w_full()
325 .pl_0p5()
326 .pr_2p5()
327 .gap_2()
328 .child(
329 div()
330 .w(rems_from_px(52.))
331 .flex_none()
332 .child(Chip::new(pr_number)),
333 )
334 .child(self.render_commit_avatar(
335 &entry.sha,
336 Some(entry.author_email.clone()),
337 window,
338 cx,
339 ))
340 .child(
341 h_flex()
342 .min_w_0()
343 .w_full()
344 .justify_between()
345 .child(
346 h_flex()
347 .min_w_0()
348 .w_full()
349 .gap_1()
350 .child(
351 Label::new(entry.author_name.clone())
352 .size(LabelSize::Small)
353 .color(Color::Default)
354 .truncate(),
355 )
356 .child(
357 Label::new(&entry.subject)
358 .size(LabelSize::Small)
359 .color(Color::Muted)
360 .truncate(),
361 ),
362 )
363 .child(
364 h_flex().flex_none().child(
365 Label::new(relative_timestamp)
366 .size(LabelSize::Small)
367 .color(Color::Muted),
368 ),
369 ),
370 ),
371 )
372 .on_click(cx.listener(move |this, _, window, cx| {
373 this.selected_entry = Some(ix);
374 cx.notify();
375
376 this.open_commit_view(window, cx);
377 }))
378 .into_any_element()
379 }
380}
381
382impl EventEmitter<ItemEvent> for FileHistoryView {}
383
384impl Focusable for FileHistoryView {
385 fn focus_handle(&self, _cx: &App) -> FocusHandle {
386 self.focus_handle.clone()
387 }
388}
389
390impl Render for FileHistoryView {
391 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
392 let _file_name = self.history.path.file_name().unwrap_or("File");
393 let entry_count = self.history.entries.len();
394
395 v_flex()
396 .id("file_history_view")
397 .key_context("FileHistoryView")
398 .track_focus(&self.focus_handle)
399 .on_action(cx.listener(Self::select_next))
400 .on_action(cx.listener(Self::select_previous))
401 .on_action(cx.listener(Self::select_first))
402 .on_action(cx.listener(Self::select_last))
403 .on_action(cx.listener(Self::confirm))
404 .size_full()
405 .bg(cx.theme().colors().editor_background)
406 .child(
407 h_flex()
408 .h(rems_from_px(41.))
409 .pl_3()
410 .pr_2()
411 .justify_between()
412 .border_b_1()
413 .border_color(cx.theme().colors().border_variant)
414 .child(
415 Label::new(self.history.path.as_unix_str().to_string())
416 .color(Color::Muted)
417 .buffer_font(cx),
418 )
419 .child(
420 h_flex()
421 .gap_1p5()
422 .child(
423 Label::new(format!("{} commits", entry_count))
424 .size(LabelSize::Small)
425 .color(Color::Muted)
426 .when(self.has_more, |this| this.mr_1()),
427 )
428 .when(self.has_more, |this| {
429 this.child(Divider::vertical()).child(
430 Button::new("load-more", "Load More")
431 .disabled(self.loading_more)
432 .label_size(LabelSize::Small)
433 .start_icon(
434 Icon::new(IconName::ArrowCircle)
435 .size(IconSize::Small)
436 .color(Color::Muted),
437 )
438 .on_click(cx.listener(|this, _, window, cx| {
439 this.load_more(window, cx);
440 })),
441 )
442 }),
443 ),
444 )
445 .child(
446 v_flex()
447 .flex_1()
448 .size_full()
449 .child({
450 let view = cx.weak_entity();
451 uniform_list(
452 "file-history-list",
453 entry_count,
454 move |range, window, cx| {
455 let Some(view) = view.upgrade() else {
456 return Vec::new();
457 };
458 view.update(cx, |this, cx| {
459 let mut items = Vec::with_capacity(range.end - range.start);
460 for ix in range {
461 if let Some(entry) = this.history.entries.get(ix) {
462 items.push(
463 this.render_commit_entry(ix, entry, window, cx),
464 );
465 }
466 }
467 items
468 })
469 },
470 )
471 .flex_1()
472 .size_full()
473 .track_scroll(&self.scroll_handle)
474 })
475 .vertical_scrollbar_for(&self.scroll_handle, window, cx),
476 )
477 }
478}
479
480impl Item for FileHistoryView {
481 type Event = ItemEvent;
482
483 fn to_item_events(event: &Self::Event, f: &mut dyn FnMut(ItemEvent)) {
484 f(*event)
485 }
486
487 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
488 let file_name = self
489 .history
490 .path
491 .file_name()
492 .map(|name| name.to_string())
493 .unwrap_or_else(|| "File".to_string());
494 format!("History: {}", file_name).into()
495 }
496
497 fn tab_tooltip_text(&self, _cx: &App) -> Option<SharedString> {
498 Some(format!("Git history for {}", self.history.path.as_unix_str()).into())
499 }
500
501 fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
502 Some(Icon::new(IconName::GitBranch))
503 }
504
505 fn telemetry_event_text(&self) -> Option<&'static str> {
506 Some("file history")
507 }
508
509 fn clone_on_split(
510 &self,
511 _workspace_id: Option<workspace::WorkspaceId>,
512 _window: &mut Window,
513 _cx: &mut Context<Self>,
514 ) -> Task<Option<Entity<Self>>> {
515 Task::ready(None)
516 }
517
518 fn navigate(
519 &mut self,
520 _: Arc<dyn Any + Send>,
521 _window: &mut Window,
522 _: &mut Context<Self>,
523 ) -> bool {
524 false
525 }
526
527 fn deactivated(&mut self, _window: &mut Window, _: &mut Context<Self>) {}
528
529 fn can_save(&self, _: &App) -> bool {
530 false
531 }
532
533 fn save(
534 &mut self,
535 _options: SaveOptions,
536 _project: Entity<Project>,
537 _window: &mut Window,
538 _: &mut Context<Self>,
539 ) -> Task<Result<()>> {
540 Task::ready(Ok(()))
541 }
542
543 fn save_as(
544 &mut self,
545 _project: Entity<Project>,
546 _path: ProjectPath,
547 _window: &mut Window,
548 _: &mut Context<Self>,
549 ) -> Task<Result<()>> {
550 Task::ready(Ok(()))
551 }
552
553 fn reload(
554 &mut self,
555 _project: Entity<Project>,
556 _window: &mut Window,
557 _: &mut Context<Self>,
558 ) -> Task<Result<()>> {
559 Task::ready(Ok(()))
560 }
561
562 fn is_dirty(&self, _: &App) -> bool {
563 false
564 }
565
566 fn has_conflict(&self, _: &App) -> bool {
567 false
568 }
569
570 fn breadcrumbs(
571 &self,
572 _cx: &App,
573 ) -> Option<(Vec<workspace::item::HighlightedText>, Option<gpui::Font>)> {
574 None
575 }
576
577 fn added_to_workspace(
578 &mut self,
579 _workspace: &mut Workspace,
580 window: &mut Window,
581 cx: &mut Context<Self>,
582 ) {
583 window.focus(&self.focus_handle, cx);
584 }
585
586 fn show_toolbar(&self) -> bool {
587 true
588 }
589
590 fn pixel_position_of_cursor(&self, _: &App) -> Option<gpui::Point<gpui::Pixels>> {
591 None
592 }
593
594 fn set_nav_history(
595 &mut self,
596 _: workspace::ItemNavHistory,
597 _window: &mut Window,
598 _: &mut Context<Self>,
599 ) {
600 }
601
602 fn act_as_type<'a>(
603 &'a self,
604 type_id: TypeId,
605 self_handle: &'a Entity<Self>,
606 _: &'a App,
607 ) -> Option<AnyEntity> {
608 if type_id == TypeId::of::<Self>() {
609 Some(self_handle.clone().into())
610 } else {
611 None
612 }
613 }
614}