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