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}