History

commit view: Display message within editor (#44024)

#42441 moved the commit message out of the multi-buffer editor into its own header element which looks nicer, but unfortunately can make the view become unusable when the commit message is too long since it doesn't scroll with the diff. This PR maintains the metadata in its own element, but moves the commit message back to the editor so the user can scroll past it. This does mean that we lose markdown rendering for now, but we think this is a good solution for the moment. https://github.com/user-attachments/assets/d67cf22e-1a79-451a-932a-cdc8a65e43de Release Notes: - N/A ---------

Agus Zubiaga committed 11 months ago

Commit 8a12ecf1 parent 22bf4492 files · +94 −213Browse files

Unified · Split

2 files
  1. crates/editor/src/editor.rs +4 −0
  2. crates/git_ui/src/commit_view.rs +90 −213
crates/editor/src/editor.rs+4−0
@@ -22598,6 +22598,10 @@ impl Editor { · 22597 unmodified lines
22598 }22598 }
22599 }22599 }
22600 22600
22601 pub fn last_gutter_dimensions(&self) -> &GutterDimensions {
22602 &self.gutter_dimensions
22603 }
22604
22601 pub fn wait_for_diff_to_load(&self) -> Option<Shared<Task<()>>> {22605 pub fn wait_for_diff_to_load(&self) -> Option<Shared<Task<()>>> {
22602 self.load_diff_task.clone()22606 self.load_diff_task.clone()
22603 }22607 }
crates/git_ui/src/commit_view.rs+90−213
@@ -1,19 +1,18 @@
1use anyhow::{Context as _, Result};1use anyhow::{Context as _, Result};
2use buffer_diff::{BufferDiff, BufferDiffSnapshot};2use buffer_diff::{BufferDiff, BufferDiffSnapshot};
3use editor::{Addon, Editor, EditorEvent, MultiBuffer};3use editor::display_map::{BlockPlacement, BlockProperties, BlockStyle};
4use editor::{Addon, Editor, EditorEvent, ExcerptId, ExcerptRange, MultiBuffer};
4use git::repository::{CommitDetails, CommitDiff, RepoPath};5use git::repository::{CommitDetails, CommitDiff, RepoPath};
5use git::{GitHostingProviderRegistry, GitRemote, parse_git_remote_url};6use git::{GitHostingProviderRegistry, GitRemote, parse_git_remote_url};
6use gpui::{7use gpui::{
7 AnyElement, App, AppContext as _, Asset, AsyncApp, AsyncWindowContext, Context, Element,8 AnyElement, App, AppContext as _, Asset, AsyncApp, AsyncWindowContext, Context, Element,
8 Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement,9 Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement,
9 PromptLevel, Render, Styled, Task, TextStyleRefinement, UnderlineStyle, WeakEntity, Window,10 PromptLevel, Render, Styled, Task, WeakEntity, Window, actions,
10 actions, px,
11};11};
12use language::{12use language::{
13 Buffer, Capability, DiskState, File, LanguageRegistry, LineEnding, ReplicaId, Rope, TextBuffer,13 Anchor, Buffer, Capability, DiskState, File, LanguageRegistry, LineEnding, ReplicaId, Rope,
14 ToPoint,14 TextBuffer, ToPoint,
15};15};
16use markdown::{Markdown, MarkdownElement, MarkdownStyle};
17use multi_buffer::ExcerptInfo;16use multi_buffer::ExcerptInfo;
18use multi_buffer::PathKey;17use multi_buffer::PathKey;
19use project::{Project, WorktreeId, git_store::Repository};18use project::{Project, WorktreeId, git_store::Repository};
@@ -63,7 +62,6 @@ pub struct CommitView { · 43 unmodified lines
63 multibuffer: Entity<MultiBuffer>,62 multibuffer: Entity<MultiBuffer>,
64 repository: Entity<Repository>,63 repository: Entity<Repository>,
65 remote: Option<GitRemote>,64 remote: Option<GitRemote>,
66 markdown: Entity<Markdown>,
67}65}
68 66
69struct GitBlob {67struct GitBlob {
@@ -167,6 +165,8 @@ impl CommitView { · 97 unmodified lines
167 .map(|worktree| worktree.read(cx).id());165 .map(|worktree| worktree.read(cx).id());
168 166
169 let repository_clone = repository.clone();167 let repository_clone = repository.clone();
168 let commit_message = commit.message.clone();
169
170 cx.spawn(async move |this, cx| {170 cx.spawn(async move |this, cx| {
171 for file in commit_diff.files {171 for file in commit_diff.files {
172 let is_deleted = file.new_text.is_none();172 let is_deleted = file.new_text.is_none();
@@ -227,6 +227,58 @@ impl CommitView { · 54 unmodified lines
227 });227 });
228 })?;228 })?;
229 }229 }
230
231 let message_buffer = cx.new(|cx| {
232 let mut buffer = Buffer::local(commit_message, cx);
233 buffer.set_capability(Capability::ReadOnly, cx);
234 buffer
235 })?;
236
237 this.update(cx, |this, cx| {
238 this.multibuffer.update(cx, |multibuffer, cx| {
239 let range = ExcerptRange {
240 context: Anchor::MIN..Anchor::MAX,
241 primary: Anchor::MIN..Anchor::MAX,
242 };
243 multibuffer.insert_excerpts_after(
244 ExcerptId::min(),
245 message_buffer.clone(),
246 [range],
247 cx,
248 )
249 });
250
251 this.editor.update(cx, |editor, cx| {
252 editor.disable_header_for_buffer(message_buffer.read(cx).remote_id(), cx);
253
254 editor.insert_blocks(
255 [BlockProperties {
256 placement: BlockPlacement::Above(editor::Anchor::min()),
257 height: Some(1),
258 style: BlockStyle::Sticky,
259 render: Arc::new(|_| gpui::Empty.into_any_element()),
260 priority: 0,
261 }]
262 .into_iter()
263 .chain(
264 editor
265 .buffer()
266 .read(cx)
267 .buffer_anchor_to_anchor(&message_buffer, Anchor::MAX, cx)
268 .map(|anchor| BlockProperties {
269 placement: BlockPlacement::Below(anchor),
270 height: Some(1),
271 style: BlockStyle::Sticky,
272 render: Arc::new(|_| gpui::Empty.into_any_element()),
273 priority: 0,
274 }),
275 ),
276 None,
277 cx,
278 )
279 });
280 })?;
281
230 anyhow::Ok(())282 anyhow::Ok(())
231 })283 })
232 .detach();284 .detach();
@@ -246,14 +298,6 @@ impl CommitView { · 13 unmodified lines
246 })298 })
247 });299 });
248 300
249 let processed_message = if let Some(ref remote) = remote {
250 Self::process_github_issues(&commit.message, remote)
251 } else {
252 commit.message.to_string()
253 };
254
255 let markdown = cx.new(|cx| Markdown::new(processed_message.into(), None, None, cx));
256
257 Self {301 Self {
258 commit,302 commit,
259 editor,303 editor,
@@ -261,18 +305,9 @@ impl CommitView { · 1 unmodified line
261 stash,305 stash,
262 repository,306 repository,
263 remote,307 remote,
264 markdown,
265 }308 }
266 }309 }
267 310
268 fn fallback_commit_avatar() -> AnyElement {
269 Icon::new(IconName::Person)
270 .color(Color::Muted)
271 .size(IconSize::Medium)
272 .into_element()
273 .into_any()
274 }
275
276 fn render_commit_avatar(311 fn render_commit_avatar(
277 &self,312 &self,
278 sha: &SharedString,313 sha: &SharedString,
@@ -280,21 +315,34 @@ impl CommitView { · 1 unmodified line
280 window: &mut Window,315 window: &mut Window,
281 cx: &mut App,316 cx: &mut App,
282 ) -> AnyElement {317 ) -> AnyElement {
318 let size = size.into();
283 let remote = self.remote.as_ref().filter(|r| r.host_supports_avatars());319 let remote = self.remote.as_ref().filter(|r| r.host_supports_avatars());
284 320
285 if let Some(remote) = remote {321 if let Some(remote) = remote {
286 let avatar_asset = CommitAvatarAsset::new(remote.clone(), sha.clone());322 let avatar_asset = CommitAvatarAsset::new(remote.clone(), sha.clone());
287 if let Some(Some(url)) = window.use_asset::<CommitAvatarAsset>(&avatar_asset, cx) {323 if let Some(Some(url)) = window.use_asset::<CommitAvatarAsset>(&avatar_asset, cx) {
288 Avatar::new(url.to_string())324 return Avatar::new(url.to_string())
289 .size(size)325 .size(size)
290 .into_element()326 .into_element()
291 .into_any()327 .into_any();
292 } else {
293 Self::fallback_commit_avatar()
294 }328 }
295 } else {
296 Self::fallback_commit_avatar()
297 }329 }
330
331 v_flex()
332 .w(size)
333 .h(size)
334 .border_1()
335 .border_color(cx.theme().colors().border)
336 .rounded_full()
337 .justify_center()
338 .items_center()
339 .child(
340 Icon::new(IconName::Person)
341 .color(Color::Muted)
342 .size(IconSize::Medium)
343 .into_element(),
344 )
345 .into_any()
298 }346 }
299 347
300 fn render_header(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {348 fn render_header(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
@@ -322,14 +370,24 @@ impl CommitView { · 21 unmodified lines
322 370
323 v_flex()371 v_flex()
324 .p_4()372 .p_4()
373 .pl_0()
325 .gap_4()374 .gap_4()
326 .border_b_1()375 .border_b_1()
327 .border_color(cx.theme().colors().border)376 .border_color(cx.theme().colors().border)
328 .child(377 .child(
329 h_flex()378 h_flex()
330 .items_start()379 .items_start()
331 .gap_3()380 .child(
332 .child(self.render_commit_avatar(&commit.sha, gpui::rems(3.0), window, cx))381 h_flex()
382 .w(self.editor.read(cx).last_gutter_dimensions().full_width())
383 .justify_center()
384 .child(self.render_commit_avatar(
385 &commit.sha,
386 gpui::rems(3.0),
387 window,
388 cx,
389 )),
390 )
333 .child(391 .child(
334 v_flex()392 v_flex()
335 .gap_1()393 .gap_1()
@@ -353,66 +411,6 @@ impl CommitView { · 17 unmodified lines
353 .on_click(move |_, _, cx| cx.open_url(&url))411 .on_click(move |_, _, cx| cx.open_url(&url))
354 })),412 })),
355 )413 )
356 .child(self.render_commit_message(window, cx))
357 }
358
359 fn process_github_issues(message: &str, remote: &GitRemote) -> String {
360 let mut result = String::new();
361 let chars: Vec<char> = message.chars().collect();
362 let mut i = 0;
363
364 while i < chars.len() {
365 if chars[i] == '#' && i + 1 < chars.len() && chars[i + 1].is_ascii_digit() {
366 let mut j = i + 1;
367 while j < chars.len() && chars[j].is_ascii_digit() {
368 j += 1;
369 }
370 let issue_number = &message[i + 1..i + (j - i)];
371 let url = format!(
372 "{}/{}/{}/issues/{}",
373 remote.host.base_url().as_str().trim_end_matches('/'),
374 remote.owner,
375 remote.repo,
376 issue_number
377 );
378 result.push_str(&format!("[#{}]({})", issue_number, url));
379 i = j;
380 } else if i + 3 < chars.len()
381 && chars[i] == 'G'
382 && chars[i + 1] == 'H'
383 && chars[i + 2] == '-'
384 && chars[i + 3].is_ascii_digit()
385 {
386 let mut j = i + 3;
387 while j < chars.len() && chars[j].is_ascii_digit() {
388 j += 1;
389 }
390 let issue_number = &message[i + 3..i + (j - i)];
391 let url = format!(
392 "{}/{}/{}/issues/{}",
393 remote.host.base_url().as_str().trim_end_matches('/'),
394 remote.owner,
395 remote.repo,
396 issue_number
397 );
398 result.push_str(&format!("[GH-{}]({})", issue_number, url));
399 i = j;
400 } else {
401 result.push(chars[i]);
402 i += 1;
403 }
404 }
405
406 result
407 }
408
409 fn render_commit_message(
410 &self,
411 window: &mut Window,
412 cx: &mut Context<Self>,
413 ) -> impl IntoElement {
414 let style = hover_markdown_style(window, cx);
415 MarkdownElement::new(self.markdown.clone(), style)
416 }414 }
417 415
418 fn apply_stash(workspace: &mut Workspace, window: &mut Window, cx: &mut App) {416 fn apply_stash(workspace: &mut Workspace, window: &mut Window, cx: &mut App) {
@@ -963,12 +961,6 @@ impl Item for CommitView { · 544 unmodified lines
963 .update(cx, |editor, cx| editor.clone(window, cx))961 .update(cx, |editor, cx| editor.clone(window, cx))
964 });962 });
965 let multibuffer = editor.read(cx).buffer().clone();963 let multibuffer = editor.read(cx).buffer().clone();
966 let processed_message = if let Some(ref remote) = self.remote {
967 Self::process_github_issues(&self.commit.message, remote)
968 } else {
969 self.commit.message.to_string()
970 };
971 let markdown = cx.new(|cx| Markdown::new(processed_message.into(), None, None, cx));
972 Self {964 Self {
973 editor,965 editor,
974 multibuffer,966 multibuffer,
@@ -976,7 +968,6 @@ impl Item for CommitView { · 1 unmodified line
976 stash: self.stash,968 stash: self.stash,
977 repository: self.repository.clone(),969 repository: self.repository.clone(),
978 remote: self.remote.clone(),970 remote: self.remote.clone(),
979 markdown,
980 }971 }
981 })))972 })))
982 }973 }
@@ -1046,117 +1037,3 @@ fn stash_matches_index(sha: &str, stash_index: usize, repo: &Repository) -> bool · 63 unmodified lines
1046 .map(|entry| entry.oid.to_string() == sha)1037 .map(|entry| entry.oid.to_string() == sha)
1047 .unwrap_or(false)1038 .unwrap_or(false)
1048}1039}
1049
1050fn hover_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
1051 let colors = cx.theme().colors();
1052 let mut style = MarkdownStyle::default();
1053 style.base_text_style = window.text_style();
1054 style.syntax = cx.theme().syntax().clone();
1055 style.selection_background_color = colors.element_selection_background;
1056 style.link = TextStyleRefinement {
1057 color: Some(colors.text_accent),
1058 underline: Some(UnderlineStyle {
1059 thickness: px(1.0),
1060 color: Some(colors.text_accent),
1061 wavy: false,
1062 }),
1063 ..Default::default()
1064 };
1065 style
1066}
1067
1068#[cfg(test)]
1069mod tests {
1070 use super::*;
1071 use git_hosting_providers::Github;
1072
1073 fn create_test_remote() -> GitRemote {
1074 GitRemote {
1075 host: Arc::new(Github::public_instance()),
1076 owner: "zed-industries".into(),
1077 repo: "zed".into(),
1078 }
1079 }
1080
1081 #[test]
1082 fn test_process_github_issues_simple_issue_number() {
1083 let remote = create_test_remote();
1084 let message = "Fix bug #123";
1085 let result = CommitView::process_github_issues(message, &remote);
1086 assert_eq!(
1087 result,
1088 "Fix bug [#123](https://github.com/zed-industries/zed/issues/123)"
1089 );
1090 }
1091
1092 #[test]
1093 fn test_process_github_issues_multiple_issue_numbers() {
1094 let remote = create_test_remote();
1095 let message = "Fix #123 and #456";
1096 let result = CommitView::process_github_issues(message, &remote);
1097 assert_eq!(
1098 result,
1099 "Fix [#123](https://github.com/zed-industries/zed/issues/123) and [#456](https://github.com/zed-industries/zed/issues/456)"
1100 );
1101 }
1102
1103 #[test]
1104 fn test_process_github_issues_gh_format() {
1105 let remote = create_test_remote();
1106 let message = "Fix GH-789";
1107 let result = CommitView::process_github_issues(message, &remote);
1108 assert_eq!(
1109 result,
1110 "Fix [GH-789](https://github.com/zed-industries/zed/issues/789)"
1111 );
1112 }
1113
1114 #[test]
1115 fn test_process_github_issues_mixed_formats() {
1116 let remote = create_test_remote();
1117 let message = "Fix #123 and GH-456";
1118 let result = CommitView::process_github_issues(message, &remote);
1119 assert_eq!(
1120 result,
1121 "Fix [#123](https://github.com/zed-industries/zed/issues/123) and [GH-456](https://github.com/zed-industries/zed/issues/456)"
1122 );
1123 }
1124
1125 #[test]
1126 fn test_process_github_issues_no_issues() {
1127 let remote = create_test_remote();
1128 let message = "This is a commit message without any issues";
1129 let result = CommitView::process_github_issues(message, &remote);
1130 assert_eq!(result, message);
1131 }
1132
1133 #[test]
1134 fn test_process_github_issues_hash_without_number() {
1135 let remote = create_test_remote();
1136 let message = "Use # for comments";
1137 let result = CommitView::process_github_issues(message, &remote);
1138 assert_eq!(result, message);
1139 }
1140
1141 #[test]
1142 fn test_process_github_issues_consecutive_issues() {
1143 let remote = create_test_remote();
1144 let message = "#123#456";
1145 let result = CommitView::process_github_issues(message, &remote);
1146 assert_eq!(
1147 result,
1148 "[#123](https://github.com/zed-industries/zed/issues/123)[#456](https://github.com/zed-industries/zed/issues/456)"
1149 );
1150 }
1151
1152 #[test]
1153 fn test_process_github_issues_multiline() {
1154 let remote = create_test_remote();
1155 let message = "Fix #123\n\nThis also fixes #456";
1156 let result = CommitView::process_github_issues(message, &remote);
1157 assert_eq!(
1158 result,
1159 "Fix [#123](https://github.com/zed-industries/zed/issues/123)\n\nThis also fixes [#456](https://github.com/zed-industries/zed/issues/456)"
1160 );
1161 }
1162}