file_history_view.rs

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