use anyhow::{Context as _, Result}; use buffer_diff::{BufferDiff, BufferDiffSnapshot}; use editor::{Addon, Editor, EditorEvent, MultiBuffer}; use git::repository::{CommitDetails, CommitDiff, RepoPath}; use git::{GitHostingProviderRegistry, GitRemote, parse_git_remote_url}; use gpui::{ AnyElement, App, AppContext as _, Asset, AsyncApp, AsyncWindowContext, Context, Element, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement, PromptLevel, Render, Styled, Task, TextStyleRefinement, UnderlineStyle, WeakEntity, Window, actions, px, }; use language::{ Buffer, Capability, DiskState, File, LanguageRegistry, LineEnding, ReplicaId, Rope, TextBuffer, ToPoint, }; use markdown::{Markdown, MarkdownElement, MarkdownStyle}; use multi_buffer::ExcerptInfo; use multi_buffer::PathKey; use project::{Project, WorktreeId, git_store::Repository}; use std::{ any::{Any, TypeId}, path::PathBuf, sync::Arc, }; use theme::ActiveTheme; use ui::{ Avatar, Button, ButtonCommon, Clickable, Color, Icon, IconName, IconSize, Label, LabelCommon as _, LabelSize, SharedString, div, h_flex, v_flex, }; use util::{ResultExt, paths::PathStyle, rel_path::RelPath, truncate_and_trailoff}; use workspace::{ Item, ItemHandle, ItemNavHistory, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace, item::{BreadcrumbText, ItemEvent, TabContentParams}, notifications::NotifyTaskExt, pane::SaveIntent, searchable::SearchableItemHandle, }; use crate::git_panel::GitPanel; actions!(git, [ApplyCurrentStash, PopCurrentStash, DropCurrentStash,]); pub fn init(cx: &mut App) { cx.observe_new(|workspace: &mut Workspace, _window, _cx| { workspace.register_action(|workspace, _: &ApplyCurrentStash, window, cx| { CommitView::apply_stash(workspace, window, cx); }); workspace.register_action(|workspace, _: &DropCurrentStash, window, cx| { CommitView::remove_stash(workspace, window, cx); }); workspace.register_action(|workspace, _: &PopCurrentStash, window, cx| { CommitView::pop_stash(workspace, window, cx); }); }) .detach(); } pub struct CommitView { commit: CommitDetails, editor: Entity, stash: Option, multibuffer: Entity, repository: Entity, remote: Option, markdown: Entity, } struct GitBlob { path: RepoPath, worktree_id: WorktreeId, is_deleted: bool, } const FILE_NAMESPACE_SORT_PREFIX: u64 = 1; impl CommitView { pub fn open( commit_sha: String, repo: WeakEntity, workspace: WeakEntity, stash: Option, file_filter: Option, window: &mut Window, cx: &mut App, ) { let commit_diff = repo .update(cx, |repo, _| repo.load_commit_diff(commit_sha.clone())) .ok(); let commit_details = repo .update(cx, |repo, _| repo.show(commit_sha.clone())) .ok(); window .spawn(cx, async move |cx| { let (commit_diff, commit_details) = futures::join!(commit_diff?, commit_details?); let mut commit_diff = commit_diff.log_err()?.log_err()?; let commit_details = commit_details.log_err()?.log_err()?; // Filter to specific file if requested if let Some(ref filter_path) = file_filter { commit_diff.files.retain(|f| &f.path == filter_path); } let repo = repo.upgrade()?; workspace .update_in(cx, |workspace, window, cx| { let project = workspace.project(); let commit_view = cx.new(|cx| { CommitView::new( commit_details, commit_diff, repo, project.clone(), stash, window, cx, ) }); let pane = workspace.active_pane(); pane.update(cx, |pane, cx| { let ix = pane.items().position(|item| { let commit_view = item.downcast::(); commit_view .is_some_and(|view| view.read(cx).commit.sha == commit_sha) }); if let Some(ix) = ix { pane.activate_item(ix, true, true, window, cx); } else { pane.add_item(Box::new(commit_view), true, true, None, window, cx); } }) }) .log_err() }) .detach(); } fn new( commit: CommitDetails, commit_diff: CommitDiff, repository: Entity, project: Entity, stash: Option, window: &mut Window, cx: &mut Context, ) -> Self { let language_registry = project.read(cx).languages().clone(); let multibuffer = cx.new(|_| MultiBuffer::new(Capability::ReadOnly)); let editor = cx.new(|cx| { let mut editor = Editor::for_multibuffer(multibuffer.clone(), Some(project.clone()), window, cx); editor.disable_inline_diagnostics(); editor.set_expand_all_diff_hunks(cx); editor.register_addon(CommitViewAddon { multibuffer: multibuffer.downgrade(), }); editor }); let first_worktree_id = project .read(cx) .worktrees(cx) .next() .map(|worktree| worktree.read(cx).id()); let repository_clone = repository.clone(); cx.spawn(async move |this, cx| { for file in commit_diff.files { let is_deleted = file.new_text.is_none(); let new_text = file.new_text.unwrap_or_default(); let old_text = file.old_text; let worktree_id = repository_clone .update(cx, |repository, cx| { repository .repo_path_to_project_path(&file.path, cx) .map(|path| path.worktree_id) .or(first_worktree_id) })? .context("project has no worktrees")?; let file = Arc::new(GitBlob { path: file.path.clone(), is_deleted, worktree_id, }) as Arc; let buffer = build_buffer(new_text, file, &language_registry, cx).await?; let buffer_diff = build_buffer_diff(old_text, &buffer, &language_registry, cx).await?; this.update(cx, |this, cx| { this.multibuffer.update(cx, |multibuffer, cx| { let snapshot = buffer.read(cx).snapshot(); let path = snapshot.file().unwrap().path().clone(); let hunks: Vec<_> = buffer_diff.read(cx).hunks(&snapshot, cx).collect(); let excerpt_ranges = if hunks.is_empty() { vec![language::Point::zero()..snapshot.max_point()] } else { hunks .into_iter() .map(|hunk| { let start = hunk.range.start.max(language::Point::new( hunk.range.start.row.saturating_sub(3), 0, )); let end_row = (hunk.range.end.row + 3).min(snapshot.max_point().row); let end = language::Point::new(end_row, snapshot.line_len(end_row)); start..end }) .collect() }; let _is_newly_added = multibuffer.set_excerpts_for_path( PathKey::with_sort_prefix(FILE_NAMESPACE_SORT_PREFIX, path), buffer, excerpt_ranges, 0, cx, ); multibuffer.add_diff(buffer_diff, cx); }); })?; } anyhow::Ok(()) }) .detach(); let snapshot = repository.read(cx).snapshot(); let remote_url = snapshot .remote_upstream_url .as_ref() .or(snapshot.remote_origin_url.as_ref()); let remote = remote_url.and_then(|url| { let provider_registry = GitHostingProviderRegistry::default_global(cx); parse_git_remote_url(provider_registry, url).map(|(host, parsed)| GitRemote { host, owner: parsed.owner.into(), repo: parsed.repo.into(), }) }); let processed_message = if let Some(ref remote) = remote { Self::process_github_issues(&commit.message, remote) } else { commit.message.to_string() }; let markdown = cx.new(|cx| Markdown::new(processed_message.into(), None, None, cx)); Self { commit, editor, multibuffer, stash, repository, remote, markdown, } } fn fallback_commit_avatar() -> AnyElement { Icon::new(IconName::Person) .color(Color::Muted) .size(IconSize::Medium) .into_element() .into_any() } fn render_commit_avatar( &self, sha: &SharedString, size: impl Into, window: &mut Window, cx: &mut App, ) -> AnyElement { let remote = self.remote.as_ref().filter(|r| r.host_supports_avatars()); if let Some(remote) = remote { let avatar_asset = CommitAvatarAsset::new(remote.clone(), sha.clone()); if let Some(Some(url)) = window.use_asset::(&avatar_asset, cx) { Avatar::new(url.to_string()) .size(size) .into_element() .into_any() } else { Self::fallback_commit_avatar() } } else { Self::fallback_commit_avatar() } } fn render_header(&self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let commit = &self.commit; let author_name = commit.author_name.clone(); let commit_date = time::OffsetDateTime::from_unix_timestamp(commit.commit_timestamp) .unwrap_or_else(|_| time::OffsetDateTime::now_utc()); let local_offset = time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC); let date_string = time_format::format_localized_timestamp( commit_date, time::OffsetDateTime::now_utc(), local_offset, time_format::TimestampFormat::MediumAbsolute, ); let github_url = self.remote.as_ref().map(|remote| { format!( "{}/{}/{}/commit/{}", remote.host.base_url(), remote.owner, remote.repo, commit.sha ) }); v_flex() .p_4() .gap_4() .border_b_1() .border_color(cx.theme().colors().border) .child( h_flex() .items_start() .gap_3() .child(self.render_commit_avatar(&commit.sha, gpui::rems(3.0), window, cx)) .child( v_flex() .gap_1() .child( h_flex() .gap_3() .items_baseline() .child(Label::new(author_name).color(Color::Default)) .child( Label::new(format!("commit {}", commit.sha)) .color(Color::Muted), ), ) .child(Label::new(date_string).color(Color::Muted)), ) .child(div().flex_grow()) .children(github_url.map(|url| { Button::new("view_on_github", "View on GitHub") .icon(IconName::Github) .style(ui::ButtonStyle::Subtle) .on_click(move |_, _, cx| cx.open_url(&url)) })), ) .child(self.render_commit_message(window, cx)) } fn process_github_issues(message: &str, remote: &GitRemote) -> String { let mut result = String::new(); let chars: Vec = message.chars().collect(); let mut i = 0; while i < chars.len() { if chars[i] == '#' && i + 1 < chars.len() && chars[i + 1].is_ascii_digit() { let mut j = i + 1; while j < chars.len() && chars[j].is_ascii_digit() { j += 1; } let issue_number = &message[i + 1..i + (j - i)]; let url = format!( "{}/{}/{}/issues/{}", remote.host.base_url().as_str().trim_end_matches('/'), remote.owner, remote.repo, issue_number ); result.push_str(&format!("[#{}]({})", issue_number, url)); i = j; } else if i + 3 < chars.len() && chars[i] == 'G' && chars[i + 1] == 'H' && chars[i + 2] == '-' && chars[i + 3].is_ascii_digit() { let mut j = i + 3; while j < chars.len() && chars[j].is_ascii_digit() { j += 1; } let issue_number = &message[i + 3..i + (j - i)]; let url = format!( "{}/{}/{}/issues/{}", remote.host.base_url().as_str().trim_end_matches('/'), remote.owner, remote.repo, issue_number ); result.push_str(&format!("[GH-{}]({})", issue_number, url)); i = j; } else { result.push(chars[i]); i += 1; } } result } fn render_commit_message( &self, window: &mut Window, cx: &mut Context, ) -> impl IntoElement { let style = hover_markdown_style(window, cx); MarkdownElement::new(self.markdown.clone(), style) } fn apply_stash(workspace: &mut Workspace, window: &mut Window, cx: &mut App) { Self::stash_action( workspace, "Apply", window, cx, async move |repository, sha, stash, commit_view, workspace, cx| { let result = repository.update(cx, |repo, cx| { if !stash_matches_index(&sha, stash, repo) { return Err(anyhow::anyhow!("Stash has changed, not applying")); } Ok(repo.stash_apply(Some(stash), cx)) })?; match result { Ok(task) => task.await?, Err(err) => { Self::close_commit_view(commit_view, workspace, cx).await?; return Err(err); } }; Self::close_commit_view(commit_view, workspace, cx).await?; anyhow::Ok(()) }, ); } fn pop_stash(workspace: &mut Workspace, window: &mut Window, cx: &mut App) { Self::stash_action( workspace, "Pop", window, cx, async move |repository, sha, stash, commit_view, workspace, cx| { let result = repository.update(cx, |repo, cx| { if !stash_matches_index(&sha, stash, repo) { return Err(anyhow::anyhow!("Stash has changed, pop aborted")); } Ok(repo.stash_pop(Some(stash), cx)) })?; match result { Ok(task) => task.await?, Err(err) => { Self::close_commit_view(commit_view, workspace, cx).await?; return Err(err); } }; Self::close_commit_view(commit_view, workspace, cx).await?; anyhow::Ok(()) }, ); } fn remove_stash(workspace: &mut Workspace, window: &mut Window, cx: &mut App) { Self::stash_action( workspace, "Drop", window, cx, async move |repository, sha, stash, commit_view, workspace, cx| { let result = repository.update(cx, |repo, cx| { if !stash_matches_index(&sha, stash, repo) { return Err(anyhow::anyhow!("Stash has changed, drop aborted")); } Ok(repo.stash_drop(Some(stash), cx)) })?; match result { Ok(task) => task.await??, Err(err) => { Self::close_commit_view(commit_view, workspace, cx).await?; return Err(err); } }; Self::close_commit_view(commit_view, workspace, cx).await?; anyhow::Ok(()) }, ); } fn stash_action( workspace: &mut Workspace, str_action: &str, window: &mut Window, cx: &mut App, callback: AsyncFn, ) where AsyncFn: AsyncFnOnce( Entity, &SharedString, usize, Entity, WeakEntity, &mut AsyncWindowContext, ) -> anyhow::Result<()> + 'static, { let Some(commit_view) = workspace.active_item_as::(cx) else { return; }; let Some(stash) = commit_view.read(cx).stash else { return; }; let sha = commit_view.read(cx).commit.sha.clone(); let answer = window.prompt( PromptLevel::Info, &format!("{} stash@{{{}}}?", str_action, stash), None, &[str_action, "Cancel"], cx, ); let workspace_weak = workspace.weak_handle(); let commit_view_entity = commit_view; window .spawn(cx, async move |cx| { if answer.await != Ok(0) { return anyhow::Ok(()); } let Some(workspace) = workspace_weak.upgrade() else { return Ok(()); }; let repo = workspace.update(cx, |workspace, cx| { workspace .panel::(cx) .and_then(|p| p.read(cx).active_repository.clone()) })?; let Some(repo) = repo else { return Ok(()); }; callback(repo, &sha, stash, commit_view_entity, workspace_weak, cx).await?; anyhow::Ok(()) }) .detach_and_notify_err(window, cx); } async fn close_commit_view( commit_view: Entity, workspace: WeakEntity, cx: &mut AsyncWindowContext, ) -> anyhow::Result<()> { workspace .update_in(cx, |workspace, window, cx| { let active_pane = workspace.active_pane(); let commit_view_id = commit_view.entity_id(); active_pane.update(cx, |pane, cx| { pane.close_item_by_id(commit_view_id, SaveIntent::Skip, window, cx) }) })? .await?; anyhow::Ok(()) } } #[derive(Clone, Debug)] struct CommitAvatarAsset { sha: SharedString, remote: GitRemote, } impl std::hash::Hash for CommitAvatarAsset { fn hash(&self, state: &mut H) { self.sha.hash(state); self.remote.host.name().hash(state); } } impl CommitAvatarAsset { fn new(remote: GitRemote, sha: SharedString) -> Self { Self { remote, sha } } } impl Asset for CommitAvatarAsset { type Source = Self; type Output = Option; fn load( source: Self::Source, cx: &mut App, ) -> impl Future + Send + 'static { let client = cx.http_client(); async move { match source .remote .host .commit_author_avatar_url( &source.remote.owner, &source.remote.repo, source.sha.clone(), client, ) .await { Ok(Some(url)) => Some(SharedString::from(url.to_string())), Ok(None) => None, Err(_) => None, } } } } impl language::File for GitBlob { fn as_local(&self) -> Option<&dyn language::LocalFile> { None } fn disk_state(&self) -> DiskState { if self.is_deleted { DiskState::Deleted } else { DiskState::New } } fn path_style(&self, _: &App) -> PathStyle { PathStyle::Posix } fn path(&self) -> &Arc { self.path.as_ref() } fn full_path(&self, _: &App) -> PathBuf { self.path.as_std_path().to_path_buf() } fn file_name<'a>(&'a self, _: &'a App) -> &'a str { self.path.file_name().unwrap() } fn worktree_id(&self, _: &App) -> WorktreeId { self.worktree_id } fn to_proto(&self, _cx: &App) -> language::proto::File { unimplemented!() } fn is_private(&self) -> bool { false } } // No longer needed since metadata buffer is not created // impl language::File for CommitMetadataFile { // fn as_local(&self) -> Option<&dyn language::LocalFile> { // None // } // // fn disk_state(&self) -> DiskState { // DiskState::New // } // // fn path_style(&self, _: &App) -> PathStyle { // PathStyle::Posix // } // // fn path(&self) -> &Arc { // &self.title // } // // fn full_path(&self, _: &App) -> PathBuf { // self.title.as_std_path().to_path_buf() // } // // fn file_name<'a>(&'a self, _: &'a App) -> &'a str { // self.title.file_name().unwrap_or("commit") // } // // fn worktree_id(&self, _: &App) -> WorktreeId { // self.worktree_id // } // // fn to_proto(&self, _cx: &App) -> language::proto::File { // unimplemented!() // } // // fn is_private(&self) -> bool { // false // } // } struct CommitViewAddon { multibuffer: WeakEntity, } impl Addon for CommitViewAddon { fn render_buffer_header_controls( &self, excerpt: &ExcerptInfo, _window: &Window, cx: &App, ) -> Option { let multibuffer = self.multibuffer.upgrade()?; let snapshot = multibuffer.read(cx).snapshot(cx); let excerpts = snapshot.excerpts().collect::>(); let current_idx = excerpts.iter().position(|(id, _, _)| *id == excerpt.id)?; let (_, _, current_range) = &excerpts[current_idx]; let start_row = current_range.context.start.to_point(&excerpt.buffer).row; let prev_end_row = if current_idx > 0 { let (_, prev_buffer, prev_range) = &excerpts[current_idx - 1]; if prev_buffer.remote_id() == excerpt.buffer_id { prev_range.context.end.to_point(&excerpt.buffer).row } else { 0 } } else { 0 }; let skipped_lines = start_row.saturating_sub(prev_end_row); if skipped_lines > 0 { Some( Label::new(format!("{} unchanged lines", skipped_lines)) .color(Color::Muted) .size(LabelSize::Small) .into_any_element(), ) } else { None } } fn to_any(&self) -> &dyn Any { self } } async fn build_buffer( mut text: String, blob: Arc, language_registry: &Arc, cx: &mut AsyncApp, ) -> Result> { let line_ending = LineEnding::detect(&text); LineEnding::normalize(&mut text); let text = Rope::from(text); let language = cx.update(|cx| language_registry.language_for_file(&blob, Some(&text), cx))?; let language = if let Some(language) = language { language_registry .load_language(&language) .await .ok() .and_then(|e| e.log_err()) } else { None }; let buffer = cx.new(|cx| { let buffer = TextBuffer::new_normalized( ReplicaId::LOCAL, cx.entity_id().as_non_zero_u64().into(), line_ending, text, ); let mut buffer = Buffer::build(buffer, Some(blob), Capability::ReadWrite); buffer.set_language_async(language, cx); buffer })?; Ok(buffer) } async fn build_buffer_diff( mut old_text: Option, buffer: &Entity, language_registry: &Arc, cx: &mut AsyncApp, ) -> Result> { if let Some(old_text) = &mut old_text { LineEnding::normalize(old_text); } let buffer = cx.update(|cx| buffer.read(cx).snapshot())?; let base_buffer = cx .update(|cx| { Buffer::build_snapshot( old_text.as_deref().unwrap_or("").into(), buffer.language().cloned(), Some(language_registry.clone()), cx, ) })? .await; let diff_snapshot = cx .update(|cx| { BufferDiffSnapshot::new_with_base_buffer( buffer.text.clone(), old_text.map(Arc::new), base_buffer, cx, ) })? .await; cx.new(|cx| { let mut diff = BufferDiff::new(&buffer.text, cx); diff.set_snapshot(diff_snapshot, &buffer.text, cx); diff }) } impl EventEmitter for CommitView {} impl Focusable for CommitView { fn focus_handle(&self, cx: &App) -> FocusHandle { self.editor.focus_handle(cx) } } impl Item for CommitView { type Event = EditorEvent; fn tab_icon(&self, _window: &Window, _cx: &App) -> Option { Some(Icon::new(IconName::GitBranch).color(Color::Muted)) } fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement { Label::new(self.tab_content_text(params.detail.unwrap_or_default(), cx)) .color(if params.selected { Color::Default } else { Color::Muted }) .into_any_element() } fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString { let short_sha = self.commit.sha.get(0..7).unwrap_or(&*self.commit.sha); let subject = truncate_and_trailoff(self.commit.message.split('\n').next().unwrap(), 20); format!("{short_sha} - {subject}").into() } fn tab_tooltip_text(&self, _: &App) -> Option { let short_sha = self.commit.sha.get(0..16).unwrap_or(&*self.commit.sha); let subject = self.commit.message.split('\n').next().unwrap(); Some(format!("{short_sha} - {subject}").into()) } fn to_item_events(event: &EditorEvent, f: impl FnMut(ItemEvent)) { Editor::to_item_events(event, f) } fn telemetry_event_text(&self) -> Option<&'static str> { Some("Commit View Opened") } fn deactivated(&mut self, window: &mut Window, cx: &mut Context) { self.editor .update(cx, |editor, cx| editor.deactivated(window, cx)); } fn act_as_type<'a>( &'a self, type_id: TypeId, self_handle: &'a Entity, _: &'a App, ) -> Option { if type_id == TypeId::of::() { Some(self_handle.clone().into()) } else if type_id == TypeId::of::() { Some(self.editor.clone().into()) } else { None } } fn as_searchable(&self, _: &Entity, _: &App) -> Option> { Some(Box::new(self.editor.clone())) } fn for_each_project_item( &self, cx: &App, f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem), ) { self.editor.for_each_project_item(cx, f) } fn set_nav_history( &mut self, nav_history: ItemNavHistory, _: &mut Window, cx: &mut Context, ) { self.editor.update(cx, |editor, _| { editor.set_nav_history(Some(nav_history)); }); } fn navigate( &mut self, data: Box, window: &mut Window, cx: &mut Context, ) -> bool { self.editor .update(cx, |editor, cx| editor.navigate(data, window, cx)) } fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation { ToolbarItemLocation::Hidden } fn breadcrumbs(&self, _theme: &theme::Theme, _cx: &App) -> Option> { None } fn added_to_workspace( &mut self, workspace: &mut Workspace, window: &mut Window, cx: &mut Context, ) { self.editor.update(cx, |editor, cx| { editor.added_to_workspace(workspace, window, cx) }); } fn can_split(&self) -> bool { true } fn clone_on_split( &self, _workspace_id: Option, window: &mut Window, cx: &mut Context, ) -> Task>> where Self: Sized, { Task::ready(Some(cx.new(|cx| { let editor = cx.new(|cx| { self.editor .update(cx, |editor, cx| editor.clone(window, cx)) }); let multibuffer = editor.read(cx).buffer().clone(); let processed_message = if let Some(ref remote) = self.remote { Self::process_github_issues(&self.commit.message, remote) } else { self.commit.message.to_string() }; let markdown = cx.new(|cx| Markdown::new(processed_message.into(), None, None, cx)); Self { editor, multibuffer, commit: self.commit.clone(), stash: self.stash, repository: self.repository.clone(), remote: self.remote.clone(), markdown, } }))) } } impl Render for CommitView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let is_stash = self.stash.is_some(); div() .key_context(if is_stash { "StashDiff" } else { "CommitDiff" }) .bg(cx.theme().colors().editor_background) .flex() .flex_col() .size_full() .child(self.render_header(window, cx)) .child(div().flex_grow().child(self.editor.clone())) } } pub struct CommitViewToolbar { commit_view: Option>, } impl CommitViewToolbar { pub fn new() -> Self { Self { commit_view: None } } } impl EventEmitter for CommitViewToolbar {} impl Render for CommitViewToolbar { fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { div() } } impl ToolbarItemView for CommitViewToolbar { fn set_active_pane_item( &mut self, active_pane_item: Option<&dyn ItemHandle>, _: &mut Window, cx: &mut Context, ) -> ToolbarItemLocation { if let Some(entity) = active_pane_item.and_then(|i| i.act_as::(cx)) && entity.read(cx).stash.is_some() { self.commit_view = Some(entity.downgrade()); return ToolbarItemLocation::PrimaryRight; } ToolbarItemLocation::Hidden } fn pane_focus_update( &mut self, _pane_focused: bool, _window: &mut Window, _cx: &mut Context, ) { } } fn stash_matches_index(sha: &str, stash_index: usize, repo: &Repository) -> bool { repo.stash_entries .entries .get(stash_index) .map(|entry| entry.oid.to_string() == sha) .unwrap_or(false) } fn hover_markdown_style(window: &Window, cx: &App) -> MarkdownStyle { let colors = cx.theme().colors(); let mut style = MarkdownStyle::default(); style.base_text_style = window.text_style(); style.syntax = cx.theme().syntax().clone(); style.selection_background_color = colors.element_selection_background; style.link = TextStyleRefinement { color: Some(colors.text_accent), underline: Some(UnderlineStyle { thickness: px(1.0), color: Some(colors.text_accent), wavy: false, }), ..Default::default() }; style } #[cfg(test)] mod tests { use super::*; use git_hosting_providers::Github; fn create_test_remote() -> GitRemote { GitRemote { host: Arc::new(Github::public_instance()), owner: "zed-industries".into(), repo: "zed".into(), } } #[test] fn test_process_github_issues_simple_issue_number() { let remote = create_test_remote(); let message = "Fix bug #123"; let result = CommitView::process_github_issues(message, &remote); assert_eq!( result, "Fix bug [#123](https://github.com/zed-industries/zed/issues/123)" ); } #[test] fn test_process_github_issues_multiple_issue_numbers() { let remote = create_test_remote(); let message = "Fix #123 and #456"; let result = CommitView::process_github_issues(message, &remote); assert_eq!( result, "Fix [#123](https://github.com/zed-industries/zed/issues/123) and [#456](https://github.com/zed-industries/zed/issues/456)" ); } #[test] fn test_process_github_issues_gh_format() { let remote = create_test_remote(); let message = "Fix GH-789"; let result = CommitView::process_github_issues(message, &remote); assert_eq!( result, "Fix [GH-789](https://github.com/zed-industries/zed/issues/789)" ); } #[test] fn test_process_github_issues_mixed_formats() { let remote = create_test_remote(); let message = "Fix #123 and GH-456"; let result = CommitView::process_github_issues(message, &remote); assert_eq!( result, "Fix [#123](https://github.com/zed-industries/zed/issues/123) and [GH-456](https://github.com/zed-industries/zed/issues/456)" ); } #[test] fn test_process_github_issues_no_issues() { let remote = create_test_remote(); let message = "This is a commit message without any issues"; let result = CommitView::process_github_issues(message, &remote); assert_eq!(result, message); } #[test] fn test_process_github_issues_hash_without_number() { let remote = create_test_remote(); let message = "Use # for comments"; let result = CommitView::process_github_issues(message, &remote); assert_eq!(result, message); } #[test] fn test_process_github_issues_consecutive_issues() { let remote = create_test_remote(); let message = "#123#456"; let result = CommitView::process_github_issues(message, &remote); assert_eq!( result, "[#123](https://github.com/zed-industries/zed/issues/123)[#456](https://github.com/zed-industries/zed/issues/456)" ); } #[test] fn test_process_github_issues_multiline() { let remote = create_test_remote(); let message = "Fix #123\n\nThis also fixes #456"; let result = CommitView::process_github_issues(message, &remote); assert_eq!( result, "Fix [#123](https://github.com/zed-industries/zed/issues/123)\n\nThis also fixes [#456](https://github.com/zed-industries/zed/issues/456)" ); } }