file_history_view.rs

  1use anyhow::Result;
  2use git::repository::{FileHistory, FileHistoryEntry, RepoPath};
  3use gpui::{
  4    AnyElement, AnyView, App, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement,
  5    ListSizingBehavior, Render, Task, UniformListScrollHandle, WeakEntity, Window, actions, rems,
  6    uniform_list,
  7};
  8use project::{
  9    Project, ProjectPath,
 10    git_store::{GitStore, Repository},
 11};
 12use std::any::{Any, TypeId};
 13use time::OffsetDateTime;
 14use ui::{Icon, IconName, Label, LabelCommon as _, SharedString, prelude::*};
 15use util::{ResultExt, truncate_and_trailoff};
 16use workspace::{
 17    Item, Workspace,
 18    item::{ItemEvent, SaveOptions},
 19    searchable::SearchableItemHandle,
 20};
 21
 22use crate::commit_view::CommitView;
 23
 24actions!(git, [ViewCommitFromHistory]);
 25
 26pub fn init(cx: &mut App) {
 27    cx.observe_new(|workspace: &mut Workspace, _window, _cx| {
 28        workspace.register_action(|_workspace, _: &ViewCommitFromHistory, _window, _cx| {});
 29    })
 30    .detach();
 31}
 32
 33pub struct FileHistoryView {
 34    history: FileHistory,
 35    repository: WeakEntity<Repository>,
 36    workspace: WeakEntity<Workspace>,
 37    selected_entry: Option<usize>,
 38    scroll_handle: UniformListScrollHandle,
 39    focus_handle: FocusHandle,
 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()
 54                    .map(|repo| git_store.file_history(&repo, path.clone(), cx))
 55            })
 56            .ok()
 57            .flatten();
 58
 59        window
 60            .spawn(cx, async move |cx| {
 61                let file_history = file_history_task?.await.log_err()?;
 62                let repo = repo.upgrade()?;
 63
 64                workspace
 65                    .update_in(cx, |workspace, window, cx| {
 66                        let project = workspace.project();
 67                        let view = cx.new(|cx| {
 68                            FileHistoryView::new(
 69                                file_history,
 70                                repo.clone(),
 71                                workspace.weak_handle(),
 72                                project.clone(),
 73                                window,
 74                                cx,
 75                            )
 76                        });
 77
 78                        let pane = workspace.active_pane();
 79                        pane.update(cx, |pane, cx| {
 80                            let ix = pane.items().position(|item| {
 81                                let view = item.downcast::<FileHistoryView>();
 82                                view.is_some_and(|v| v.read(cx).history.path == path)
 83                            });
 84                            if let Some(ix) = ix {
 85                                pane.activate_item(ix, true, true, window, cx);
 86                            } else {
 87                                pane.add_item(Box::new(view), true, true, None, window, cx);
 88                            }
 89                        })
 90                    })
 91                    .log_err()
 92            })
 93            .detach();
 94    }
 95
 96    fn new(
 97        history: FileHistory,
 98        repository: Entity<Repository>,
 99        workspace: WeakEntity<Workspace>,
100        _project: Entity<Project>,
101        _window: &mut Window,
102        cx: &mut Context<Self>,
103    ) -> Self {
104        let focus_handle = cx.focus_handle();
105        let scroll_handle = UniformListScrollHandle::new();
106
107        Self {
108            history,
109            repository: repository.downgrade(),
110            workspace,
111            selected_entry: None,
112            scroll_handle,
113            focus_handle,
114        }
115    }
116
117    fn list_item_height(&self) -> Rems {
118        rems(1.75)
119    }
120
121    fn render_commit_entry(
122        &self,
123        ix: usize,
124        entry: &FileHistoryEntry,
125        _window: &Window,
126        cx: &Context<Self>,
127    ) -> AnyElement {
128        let short_sha = if entry.sha.len() >= 7 {
129            entry.sha[..7].to_string()
130        } else {
131            entry.sha.to_string()
132        };
133
134        let commit_time = OffsetDateTime::from_unix_timestamp(entry.commit_timestamp)
135            .unwrap_or_else(|_| OffsetDateTime::UNIX_EPOCH);
136        let relative_timestamp = time_format::format_localized_timestamp(
137            commit_time,
138            OffsetDateTime::now_utc(),
139            time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC),
140            time_format::TimestampFormat::Relative,
141        );
142
143        let selected = self.selected_entry == Some(ix);
144        let sha = entry.sha.clone();
145        let repo = self.repository.clone();
146        let workspace = self.workspace.clone();
147        let file_path = self.history.path.clone();
148
149        let base_bg = if selected {
150            cx.theme().status().info.alpha(0.15)
151        } else {
152            cx.theme().colors().element_background
153        };
154
155        let hover_bg = if selected {
156            cx.theme().status().info.alpha(0.2)
157        } else {
158            cx.theme().colors().element_hover
159        };
160
161        h_flex()
162            .id(("commit", ix))
163            .h(self.list_item_height())
164            .w_full()
165            .items_center()
166            .px(rems(0.75))
167            .gap_2()
168            .bg(base_bg)
169            .hover(|style| style.bg(hover_bg))
170            .cursor_pointer()
171            .on_click(cx.listener(move |this, _, window, cx| {
172                this.selected_entry = Some(ix);
173                cx.notify();
174
175                // Open the commit view filtered to show only this file's changes
176                if let Some(repo) = repo.upgrade() {
177                    let sha_str = sha.to_string();
178                    CommitView::open(
179                        sha_str,
180                        repo.downgrade(),
181                        workspace.clone(),
182                        None,
183                        Some(file_path.clone()),
184                        window,
185                        cx,
186                    );
187                }
188            }))
189            .child(
190                div()
191                    .flex_none()
192                    .w(rems(4.5))
193                    .text_color(cx.theme().status().info)
194                    .font_family(".SystemUIFontMonospaced-Regular")
195                    .child(short_sha),
196            )
197            .child(
198                Label::new(truncate_and_trailoff(&entry.subject, 60))
199                    .single_line()
200                    .color(ui::Color::Default),
201            )
202            .child(div().flex_1())
203            .child(
204                Label::new(truncate_and_trailoff(&entry.author_name, 20))
205                    .size(LabelSize::Small)
206                    .color(ui::Color::Muted)
207                    .single_line(),
208            )
209            .child(
210                div().flex_none().w(rems(6.5)).child(
211                    Label::new(relative_timestamp)
212                        .size(LabelSize::Small)
213                        .color(ui::Color::Muted)
214                        .single_line(),
215                ),
216            )
217            .into_any_element()
218    }
219}
220
221impl EventEmitter<ItemEvent> for FileHistoryView {}
222
223impl Focusable for FileHistoryView {
224    fn focus_handle(&self, _cx: &App) -> FocusHandle {
225        self.focus_handle.clone()
226    }
227}
228
229impl Render for FileHistoryView {
230    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
231        let file_name = self.history.path.file_name().unwrap_or("File");
232        let entry_count = self.history.entries.len();
233
234        v_flex()
235            .size_full()
236            .child(
237                h_flex()
238                    .px(rems(0.75))
239                    .py(rems(0.5))
240                    .border_b_1()
241                    .border_color(cx.theme().colors().border)
242                    .bg(cx.theme().colors().title_bar_background)
243                    .items_center()
244                    .justify_between()
245                    .child(
246                        h_flex()
247                            .gap_2()
248                            .items_center()
249                            .child(
250                                Icon::new(IconName::FileGit)
251                                    .size(IconSize::Small)
252                                    .color(ui::Color::Muted),
253                            )
254                            .child(
255                                Label::new(format!("History: {}", file_name))
256                                    .size(LabelSize::Default),
257                            ),
258                    )
259                    .child(
260                        Label::new(format!("{} commits", entry_count))
261                            .size(LabelSize::Small)
262                            .color(ui::Color::Muted),
263                    ),
264            )
265            .child({
266                let view = cx.weak_entity();
267                uniform_list(
268                    "file-history-list",
269                    entry_count,
270                    move |range, window, cx| {
271                        let Some(view) = view.upgrade() else {
272                            return Vec::new();
273                        };
274                        view.update(cx, |this, cx| {
275                            let mut items = Vec::with_capacity(range.end - range.start);
276                            for ix in range {
277                                if let Some(entry) = this.history.entries.get(ix) {
278                                    items.push(this.render_commit_entry(ix, entry, window, cx));
279                                }
280                            }
281                            items
282                        })
283                    },
284                )
285                .flex_1()
286                .size_full()
287                .with_sizing_behavior(ListSizingBehavior::Auto)
288                .track_scroll(self.scroll_handle.clone())
289            })
290    }
291}
292
293impl Item for FileHistoryView {
294    type Event = ItemEvent;
295
296    fn to_item_events(event: &Self::Event, mut f: impl FnMut(ItemEvent)) {
297        f(*event)
298    }
299
300    fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
301        let file_name = self
302            .history
303            .path
304            .file_name()
305            .map(|name| name.to_string())
306            .unwrap_or_else(|| "File".to_string());
307        format!("History: {}", file_name).into()
308    }
309
310    fn tab_tooltip_text(&self, _cx: &App) -> Option<SharedString> {
311        Some(format!("Git history for {}", self.history.path.as_unix_str()).into())
312    }
313
314    fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
315        Some(Icon::new(IconName::FileGit))
316    }
317
318    fn telemetry_event_text(&self) -> Option<&'static str> {
319        Some("file history")
320    }
321
322    fn clone_on_split(
323        &self,
324        _workspace_id: Option<workspace::WorkspaceId>,
325        _window: &mut Window,
326        _cx: &mut Context<Self>,
327    ) -> Task<Option<Entity<Self>>> {
328        Task::ready(None)
329    }
330
331    fn navigate(&mut self, _: Box<dyn Any>, _window: &mut Window, _: &mut Context<Self>) -> bool {
332        false
333    }
334
335    fn deactivated(&mut self, _window: &mut Window, _: &mut Context<Self>) {}
336
337    fn can_save(&self, _: &App) -> bool {
338        false
339    }
340
341    fn save(
342        &mut self,
343        _options: SaveOptions,
344        _project: Entity<Project>,
345        _window: &mut Window,
346        _: &mut Context<Self>,
347    ) -> Task<Result<()>> {
348        Task::ready(Ok(()))
349    }
350
351    fn save_as(
352        &mut self,
353        _project: Entity<Project>,
354        _path: ProjectPath,
355        _window: &mut Window,
356        _: &mut Context<Self>,
357    ) -> Task<Result<()>> {
358        Task::ready(Ok(()))
359    }
360
361    fn reload(
362        &mut self,
363        _project: Entity<Project>,
364        _window: &mut Window,
365        _: &mut Context<Self>,
366    ) -> Task<Result<()>> {
367        Task::ready(Ok(()))
368    }
369
370    fn is_dirty(&self, _: &App) -> bool {
371        false
372    }
373
374    fn has_conflict(&self, _: &App) -> bool {
375        false
376    }
377
378    fn breadcrumbs(
379        &self,
380        _theme: &theme::Theme,
381        _cx: &App,
382    ) -> Option<Vec<workspace::item::BreadcrumbText>> {
383        None
384    }
385
386    fn added_to_workspace(
387        &mut self,
388        _workspace: &mut Workspace,
389        window: &mut Window,
390        _cx: &mut Context<Self>,
391    ) {
392        window.focus(&self.focus_handle);
393    }
394
395    fn show_toolbar(&self) -> bool {
396        true
397    }
398
399    fn pixel_position_of_cursor(&self, _: &App) -> Option<gpui::Point<gpui::Pixels>> {
400        None
401    }
402
403    fn as_searchable(&self, _: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
404        None
405    }
406
407    fn set_nav_history(
408        &mut self,
409        _: workspace::ItemNavHistory,
410        _window: &mut Window,
411        _: &mut Context<Self>,
412    ) {
413    }
414
415    fn act_as_type<'a>(
416        &'a self,
417        _type_id: TypeId,
418        _self_handle: &'a Entity<Self>,
419        _: &'a App,
420    ) -> Option<AnyView> {
421        None
422    }
423}