logiguard fork v3: full patch set on verified 8c74db0 tree

Includes prior-session patches (carry forward so the app compiles):
  - crates/gpui/build.rs: cross-compile manifest fix
  - crates/gpui/src/platform.rs: PlatformWindow::activate_with_token trait method
  - crates/gpui/src/window.rs: Window::activate_with_token public API
  - crates/gpui_linux/src/linux/wayland/window.rs: WaylandWindow::activate_with_token + activate() keyboard-serial fix

Plus the focus-serial fix:
  - serial.rs: SerialKind::KeyboardEnter
  - client.rs: store wl_keyboard.enter serial; latest_serial_of()

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mohamad Khani
2026-07-14 01:52:12 +03:30
commit b9819977a5
3984 changed files with 1487015 additions and 0 deletions

93
crates/git_ui/Cargo.toml Normal file
View File

@@ -0,0 +1,93 @@
[package]
name = "git_ui"
version = "0.1.0"
edition.workspace = true
publish.workspace = true
license = "GPL-3.0-or-later"
[lints]
workspace = true
[lib]
name = "git_ui"
path = "src/git_ui.rs"
[features]
test-support = ["multi_buffer/test-support", "remote_connection/test-support"]
[dependencies]
agent_settings.workspace = true
alacritty_terminal.workspace = true
anyhow.workspace = true
askpass.workspace = true
buffer_diff.workspace = true
call.workspace = true
collections.workspace = true
component.workspace = true
db.workspace = true
editor.workspace = true
file_icons.workspace = true
fs.workspace = true
futures.workspace = true
futures-lite.workspace = true
fuzzy.workspace = true
fuzzy_nucleo.workspace = true
git.workspace = true
gpui.workspace = true
itertools.workspace = true
language.workspace = true
language_model.workspace = true
linkify.workspace = true
log.workspace = true
markdown.workspace = true
menu.workspace = true
multi_buffer.workspace = true
notifications.workspace = true
panel.workspace = true
picker.workspace = true
project.workspace = true
prompt_store.workspace = true
proto.workspace = true
rand.workspace = true
remote_connection.workspace = true
remote.workspace = true
schemars.workspace = true
serde.workspace = true
serde_json.workspace = true
settings.workspace = true
smallvec.workspace = true
strum.workspace = true
telemetry.workspace = true
theme.workspace = true
theme_settings.workspace = true
time.workspace = true
time_format.workspace = true
ui.workspace = true
ui_input.workspace = true
util.workspace = true
watch.workspace = true
workspace.workspace = true
zed_actions.workspace = true
zeroize.workspace = true
ztracing.workspace = true
tracing.workspace = true
[target.'cfg(windows)'.dependencies]
windows.workspace = true
[dev-dependencies]
ctor.workspace = true
editor = { workspace = true, features = ["test-support"] }
gpui = { workspace = true, features = ["test-support"] }
indoc.workspace = true
pretty_assertions.workspace = true
project = { workspace = true, features = ["test-support"] }
rand.workspace = true
settings = { workspace = true, features = ["test-support"] }
task.workspace = true
unindent.workspace = true
workspace = { workspace = true, features = ["test-support"] }
zlog.workspace = true
remote_connection = { workspace = true, features = ["test-support"] }
[package.metadata.cargo-machete]
ignored = ["tracing"]

1
crates/git_ui/LICENSE-GPL Symbolic link
View File

@@ -0,0 +1 @@
../../LICENSE-GPL

View File

@@ -0,0 +1,147 @@
use askpass::EncryptedPassword;
use editor::Editor;
use futures::channel::oneshot;
use gpui::{AppContext, DismissEvent, Entity, EventEmitter, Focusable, Styled};
use ui::{
ActiveTheme, AnyElement, App, Button, Clickable, Color, Context, DynamicSpacing, Headline,
HeadlineSize, Icon, IconName, IconSize, InteractiveElement, IntoElement, Label, LabelCommon,
LabelSize, ParentElement, Render, SharedString, StyledExt, StyledTypography, Window, div,
h_flex, v_flex,
};
use util::maybe;
use workspace::ModalView;
use zeroize::Zeroize;
pub(crate) struct AskPassModal {
operation: SharedString,
prompt: SharedString,
editor: Entity<Editor>,
tx: Option<oneshot::Sender<EncryptedPassword>>,
}
impl EventEmitter<DismissEvent> for AskPassModal {}
impl ModalView for AskPassModal {}
impl Focusable for AskPassModal {
fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
self.editor.focus_handle(cx)
}
}
impl AskPassModal {
pub fn new(
operation: SharedString,
prompt: SharedString,
tx: oneshot::Sender<EncryptedPassword>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let editor = cx.new(|cx| {
let mut editor = Editor::single_line(window, cx);
if prompt.contains("yes/no") || prompt.contains("Username") {
editor.set_masked(false, cx);
} else {
editor.set_masked(true, cx);
}
editor
});
Self {
operation,
prompt,
editor,
tx: Some(tx),
}
}
fn cancel(&mut self, _: &menu::Cancel, _window: &mut Window, cx: &mut Context<Self>) {
cx.emit(DismissEvent);
}
fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
maybe!({
let tx = self.tx.take()?;
let mut text = self.editor.update(cx, |this, cx| {
let text = this.text(cx);
this.clear(window, cx);
text
});
let pw = askpass::EncryptedPassword::try_from(text.as_ref()).ok()?;
text.zeroize();
tx.send(pw).ok();
Some(())
});
cx.emit(DismissEvent);
}
fn render_hint(&mut self, cx: &mut Context<Self>) -> Option<AnyElement> {
let color = cx.theme().status().info_background;
if (self.prompt.contains("Password") || self.prompt.contains("Username"))
&& self.prompt.contains("github.com")
{
return Some(
div()
.p_2()
.bg(color)
.border_t_1()
.border_color(cx.theme().status().info_border)
.child(
h_flex().gap_2()
.child(
Icon::new(IconName::Github).size(IconSize::Small)
)
.child(
Label::new("You may need to configure git for Github.")
.size(LabelSize::Small),
)
.child(Button::new("learn-more", "Learn more").color(Color::Accent).label_size(LabelSize::Small).on_click(|_, _, cx| {
cx.open_url("https://docs.github.com/en/get-started/git-basics/set-up-git#authenticating-with-github-from-git")
})),
)
.into_any_element(),
);
}
None
}
}
impl Render for AskPassModal {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.key_context("PasswordPrompt")
.on_action(cx.listener(Self::cancel))
.on_action(cx.listener(Self::confirm))
.elevation_2(cx)
.size_full()
.child(
h_flex()
.font_buffer(cx)
.px(DynamicSpacing::Base12.rems(cx))
.pt(DynamicSpacing::Base08.rems(cx))
.pb(DynamicSpacing::Base04.rems(cx))
.rounded_t_sm()
.w_full()
.gap_1p5()
.child(Icon::new(IconName::GitBranch).size(IconSize::XSmall))
.child(h_flex().gap_1().overflow_x_hidden().child(
div().max_w_96().overflow_x_hidden().text_ellipsis().child(
Headline::new(self.operation.clone()).size(HeadlineSize::XSmall),
),
)),
)
.child(
div()
.font_buffer(cx)
.text_buffer(cx)
.py_2()
.px_3()
.bg(cx.theme().colors().editor_background)
.border_t_1()
.border_color(cx.theme().colors().border_variant)
.size_full()
.overflow_hidden()
.child(self.prompt.clone())
.child(self.editor.clone()),
)
.children(self.render_hint(cx))
}
}

View File

@@ -0,0 +1,439 @@
use crate::{
commit_tooltip::{CommitAvatar, CommitTooltip},
commit_view::CommitView,
};
use editor::{BlameRenderer, Editor, hover_markdown_style};
use git::{blame::BlameEntry, commit::ParsedCommitMessage, repository::CommitSummary};
use gpui::{
ClipboardItem, Entity, Hsla, MouseButton, ScrollHandle, Subscription, TextStyle,
TextStyleRefinement, UnderlineStyle, WeakEntity, prelude::*,
};
use markdown::{Markdown, MarkdownElement};
use project::{git_store::Repository, project_settings::ProjectSettings};
use settings::Settings as _;
use theme_settings::ThemeSettings;
use time::OffsetDateTime;
use ui::{ContextMenu, CopyButton, Divider, prelude::*, tooltip_container};
use workspace::Workspace;
const GIT_BLAME_MAX_AUTHOR_CHARS_DISPLAYED: usize = 20;
pub struct GitBlameRenderer;
impl BlameRenderer for GitBlameRenderer {
fn max_author_length(&self) -> usize {
GIT_BLAME_MAX_AUTHOR_CHARS_DISPLAYED
}
fn render_blame_entry(
&self,
style: &TextStyle,
blame_entry: BlameEntry,
details: Option<ParsedCommitMessage>,
repository: Entity<Repository>,
workspace: WeakEntity<Workspace>,
editor: Entity<Editor>,
ix: usize,
sha_color: Hsla,
window: &mut Window,
cx: &mut App,
) -> Option<AnyElement> {
let relative_timestamp = blame_entry_relative_timestamp(&blame_entry);
let short_commit_id = blame_entry.sha.display_short();
let author_name = blame_entry.author.as_deref().unwrap_or("<no name>");
let name = util::truncate_and_trailoff(author_name, GIT_BLAME_MAX_AUTHOR_CHARS_DISPLAYED);
let avatar = if ProjectSettings::get_global(cx).git.blame.show_avatar {
let author_email = blame_entry.author_mail.as_ref().map(|email| {
SharedString::from(
email
.trim_start_matches('<')
.trim_end_matches('>')
.to_string(),
)
});
Some(
CommitAvatar::new(
&blame_entry.sha.to_string().into(),
author_email,
details.as_ref().and_then(|it| it.remote.as_ref()),
)
.render(window, cx),
)
} else {
None
};
Some(
div()
.mr_2()
.child(
h_flex()
.id(("blame", ix))
.w_full()
.gap_2()
.justify_between()
.font(style.font())
.line_height(style.line_height)
.text_color(cx.theme().status().hint)
.child(
h_flex()
.gap_2()
.child(div().text_color(sha_color).child(short_commit_id))
.children(avatar)
.child(name),
)
.child(relative_timestamp)
.hover(|style| style.bg(cx.theme().colors().element_hover))
.cursor_pointer()
.on_mouse_down(MouseButton::Right, {
let blame_entry = blame_entry.clone();
let details = details.clone();
let editor = editor.clone();
move |event, window, cx| {
cx.stop_propagation();
deploy_blame_entry_context_menu(
&blame_entry,
details.as_ref(),
editor.clone(),
event.position,
window,
cx,
);
}
})
.on_click({
let blame_entry = blame_entry.clone();
let repository = repository.clone();
let workspace = workspace.clone();
move |_, window, cx| {
CommitView::open(
blame_entry.sha.to_string(),
repository.downgrade(),
workspace.clone(),
None,
None,
window,
cx,
)
}
})
.when(!editor.read(cx).has_mouse_context_menu(), |el| {
el.hoverable_tooltip(move |_window, cx| {
cx.new(|cx| {
CommitTooltip::blame_entry(
&blame_entry,
details.clone(),
repository.clone(),
workspace.clone(),
cx,
)
})
.into()
})
}),
)
.into_any(),
)
}
fn render_inline_blame_entry(
&self,
style: &TextStyle,
blame_entry: BlameEntry,
cx: &mut App,
) -> Option<AnyElement> {
let relative_timestamp = blame_entry_relative_timestamp(&blame_entry);
let author = blame_entry.author.as_deref().unwrap_or_default();
let summary_enabled = ProjectSettings::get_global(cx)
.git
.inline_blame
.show_commit_summary;
let text = match blame_entry.summary.as_ref() {
Some(summary) if summary_enabled => {
format!("{}, {} - {}", author, relative_timestamp, summary)
}
_ => format!("{}, {}", author, relative_timestamp),
};
Some(
h_flex()
.id("inline-blame")
.w_full()
.font(style.font())
.text_color(cx.theme().status().hint)
.line_height(style.line_height)
.child(Icon::new(IconName::FileGit).color(Color::Hint))
.child(text)
.gap_2()
.into_any(),
)
}
fn render_blame_entry_popover(
&self,
blame: BlameEntry,
scroll_handle: ScrollHandle,
details: Option<ParsedCommitMessage>,
markdown: Entity<Markdown>,
repository: Entity<Repository>,
workspace: WeakEntity<Workspace>,
window: &mut Window,
cx: &mut App,
) -> Option<AnyElement> {
let commit_time = blame
.committer_time
.and_then(|t| OffsetDateTime::from_unix_timestamp(t).ok())
.unwrap_or(OffsetDateTime::now_utc());
let sha = blame.sha.to_string().into();
let author: SharedString = blame
.author
.clone()
.unwrap_or("<no name>".to_string())
.into();
let author_email = blame.author_mail.as_deref().unwrap_or_default();
let author_email_for_avatar = blame.author_mail.as_ref().map(|email| {
SharedString::from(
email
.trim_start_matches('<')
.trim_end_matches('>')
.to_string(),
)
});
let avatar = CommitAvatar::new(
&sha,
author_email_for_avatar,
details.as_ref().and_then(|it| it.remote.as_ref()),
)
.render(window, cx);
let short_commit_id = sha
.get(..8)
.map(|sha| sha.to_string().into())
.unwrap_or_else(|| sha.clone());
let local_offset = time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC);
let absolute_timestamp = time_format::format_localized_timestamp(
commit_time,
OffsetDateTime::now_utc(),
local_offset,
time_format::TimestampFormat::MediumAbsolute,
);
let link_color = cx.theme().colors().text_accent;
let markdown_style = {
let mut style = hover_markdown_style(window, cx);
style.link.refine(&TextStyleRefinement {
color: Some(link_color),
underline: Some(UnderlineStyle {
color: Some(link_color.opacity(0.4)),
thickness: px(1.0),
..Default::default()
}),
..Default::default()
});
style
};
let message = details
.as_ref()
.map(|_| MarkdownElement::new(markdown.clone(), markdown_style).into_any())
.unwrap_or("<no commit message>".into_any());
let pull_request = details
.as_ref()
.and_then(|details| details.pull_request.clone());
let ui_font_size = ThemeSettings::get_global(cx).ui_font_size(cx);
let message_max_height = window.line_height() * 12 + (ui_font_size / 0.4);
let commit_summary = CommitSummary {
sha: sha.clone(),
subject: details
.as_ref()
.and_then(|details| {
Some(
details
.message
.split('\n')
.next()?
.trim_end()
.to_string()
.into(),
)
})
.unwrap_or_default(),
commit_timestamp: commit_time.unix_timestamp(),
author_name: author.clone(),
has_parent: false,
};
Some(
tooltip_container(cx, |this, cx| {
this.occlude()
.on_mouse_move(|_, _, cx| cx.stop_propagation())
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
.child(
v_flex()
.w(gpui::rems(30.))
.child(
h_flex()
.pb_1()
.gap_2()
.overflow_x_hidden()
.flex_wrap()
.border_b_1()
.border_color(cx.theme().colors().border_variant)
.child(avatar)
.child(author)
.when(!author_email.is_empty(), |this| {
this.child(
div()
.text_color(cx.theme().colors().text_muted)
.child(author_email.to_owned()),
)
}),
)
.child(
div()
.id("inline-blame-commit-message")
.track_scroll(&scroll_handle)
.py_1p5()
.max_h(message_max_height)
.overflow_y_scroll()
.child(message),
)
.child(
h_flex()
.text_color(cx.theme().colors().text_muted)
.w_full()
.justify_between()
.pt_1()
.border_t_1()
.border_color(cx.theme().colors().border_variant)
.child(absolute_timestamp)
.child(
h_flex()
.gap_1()
.when_some(pull_request, |this, pr| {
this.child(
Button::new(
"pull-request-button",
format!("#{}", pr.number),
)
.color(Color::Muted)
.start_icon(
Icon::new(IconName::PullRequest)
.size(IconSize::Small)
.color(Color::Muted),
)
.on_click(move |_, _, cx| {
cx.stop_propagation();
cx.open_url(pr.url.as_str())
}),
)
.child(Divider::vertical())
})
.child(
Button::new(
"commit-sha-button",
short_commit_id.clone(),
)
.color(Color::Muted)
.start_icon(
Icon::new(IconName::FileGit)
.size(IconSize::Small)
.color(Color::Muted),
)
.on_click(move |_, window, cx| {
CommitView::open(
commit_summary.sha.clone().into(),
repository.downgrade(),
workspace.clone(),
None,
None,
window,
cx,
);
cx.stop_propagation();
}),
)
.child(Divider::vertical())
.child(
CopyButton::new("copy-blame-sha", sha.to_string())
.tooltip_label("Copy SHA"),
),
),
),
)
})
.into_any_element(),
)
}
fn open_blame_commit(
&self,
blame_entry: BlameEntry,
repository: Entity<Repository>,
workspace: WeakEntity<Workspace>,
window: &mut Window,
cx: &mut App,
) {
CommitView::open(
blame_entry.sha.to_string(),
repository.downgrade(),
workspace,
None,
None,
window,
cx,
)
}
}
fn deploy_blame_entry_context_menu(
blame_entry: &BlameEntry,
details: Option<&ParsedCommitMessage>,
editor: Entity<Editor>,
position: gpui::Point<Pixels>,
window: &mut Window,
cx: &mut App,
) {
let context_menu = ContextMenu::build(window, cx, move |menu, _, _| {
let sha = format!("{}", blame_entry.sha);
menu.on_blur_subscription(Subscription::new(|| {}))
.entry("Copy Commit SHA", None, move |_, cx| {
cx.write_to_clipboard(ClipboardItem::new_string(sha.clone()));
})
.when_some(
details.and_then(|details| details.permalink.clone()),
|this, url| {
this.entry("Open Permalink", None, move |_, cx| {
cx.open_url(url.as_str())
})
},
)
});
editor.update(cx, move |editor, cx| {
editor.hide_blame_popover(false, cx);
editor.deploy_mouse_context_menu(position, context_menu, window, cx);
cx.notify();
});
}
fn blame_entry_relative_timestamp(blame_entry: &BlameEntry) -> String {
match blame_entry.author_offset_date_time() {
Ok(timestamp) => {
let local_offset =
time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC);
time_format::format_localized_timestamp(
timestamp,
time::OffsetDateTime::now_utc(),
local_offset,
time_format::TimestampFormat::Relative,
)
}
Err(_) => "Error parsing date".to_string(),
}
}

File diff suppressed because it is too large Load Diff

159
crates/git_ui/src/clone.rs Normal file
View File

@@ -0,0 +1,159 @@
use gpui::{App, Context, WeakEntity, Window};
use notifications::status_toast::StatusToast;
use std::sync::Arc;
use ui::{Color, Icon, IconName, IconSize, SharedString};
use util::ResultExt;
use workspace::{self, Workspace};
pub fn clone_and_open(
repo_url: SharedString,
workspace: WeakEntity<Workspace>,
window: &mut Window,
cx: &mut App,
on_success: Arc<
dyn Fn(&mut Workspace, &mut Window, &mut Context<Workspace>) + Send + Sync + 'static,
>,
) {
let destination_prompt = cx.prompt_for_paths(gpui::PathPromptOptions {
files: false,
directories: true,
multiple: false,
prompt: Some("Select as Repository Destination".into()),
});
window
.spawn(cx, async move |cx| {
let mut paths = destination_prompt.await.ok()?.ok()??;
let mut destination_dir = paths.pop()?;
let repo_name = repo_url
.split('/')
.next_back()
.map(|name| name.strip_suffix(".git").unwrap_or(name))
.unwrap_or("repository")
.to_owned();
let clone_task = workspace
.update(cx, |workspace, cx| {
let fs = workspace.app_state().fs.clone();
let destination_dir = destination_dir.clone();
let repo_url = repo_url.clone();
cx.spawn(async move |_workspace, _cx| {
fs.git_clone(destination_dir.as_path(), &repo_url).await
})
})
.ok()?;
if let Err(error) = clone_task.await {
workspace
.update(cx, |workspace, cx| {
let toast = StatusToast::new(error.to_string(), cx, |this, _| {
this.icon(
Icon::new(IconName::XCircle)
.size(IconSize::Small)
.color(Color::Error),
)
.dismiss_button(true)
});
workspace.toggle_status_toast(toast, cx);
})
.log_err();
return None;
}
let has_worktrees = workspace
.read_with(cx, |workspace, cx| {
workspace.project().read(cx).worktrees(cx).next().is_some()
})
.ok()?;
let prompt_answer = if has_worktrees {
cx.update(|window, cx| {
window.prompt(
gpui::PromptLevel::Info,
&format!("Git Clone: {}", repo_name),
None,
&["Add repo to project", "Open repo in new project"],
cx,
)
})
.ok()?
.await
.ok()?
} else {
// Don't ask if project is empty
0
};
destination_dir.push(&repo_name);
match prompt_answer {
0 => {
workspace
.update_in(cx, |workspace, window, cx| {
let create_task = workspace.project().update(cx, |project, cx| {
project.create_worktree(destination_dir.as_path(), true, cx)
});
let workspace_weak = cx.weak_entity();
let on_success = on_success.clone();
cx.spawn_in(window, async move |_window, cx| {
if create_task.await.log_err().is_some() {
workspace_weak
.update_in(cx, |workspace, window, cx| {
(on_success)(workspace, window, cx);
})
.ok();
}
})
.detach();
})
.ok()?;
}
1 => {
workspace
.update(cx, move |workspace, cx| {
let app_state = workspace.app_state().clone();
let destination_path = destination_dir.clone();
let on_success = on_success.clone();
workspace::open_new(
Default::default(),
app_state,
cx,
move |workspace, window, cx| {
cx.activate(true);
let create_task =
workspace.project().update(cx, |project, cx| {
project.create_worktree(
destination_path.as_path(),
true,
cx,
)
});
let workspace_weak = cx.weak_entity();
cx.spawn_in(window, async move |_window, cx| {
if create_task.await.log_err().is_some() {
workspace_weak
.update_in(cx, |workspace, window, cx| {
(on_success)(workspace, window, cx);
})
.ok();
}
})
.detach();
},
)
.detach();
})
.ok();
}
_ => {}
}
Some(())
})
.detach();
}

View File

@@ -0,0 +1,17 @@
You are an expert at writing Git commits. Your job is to write a short clear commit message that summarizes the changes.
If you can accurately express the change in just the subject line, don't include anything in the message body. Only use the body when it is providing *useful* information.
Don't repeat information from the subject line in the message body.
Only return the commit message in your response. Do not include any additional meta-commentary about the task. Do not include the raw diff output in the commit message.
Follow good Git style:
- Separate the subject from the body with a blank line
- Try to limit the subject line to 50 characters
- Capitalize the subject line
- Do not end the subject line with any punctuation
- Use the imperative mood in the subject line
- Wrap the body at 72 characters
- Keep the body short and concise (omit it entirely if not useful)

View File

@@ -0,0 +1,642 @@
use crate::branch_picker::{self, BranchList};
use crate::git_panel::{GitPanel, commit_message_editor, panel_editor_style};
use crate::git_panel_settings::GitPanelSettings;
use git::repository::CommitOptions;
use git::{Amend, Commit, GenerateCommitMessage, Signoff};
use project::DisableAiSettings;
use settings::Settings;
use ui::{
ContextMenu, KeybindingHint, PopoverMenu, PopoverMenuHandle, SplitButton, Tooltip, prelude::*,
};
use editor::{Editor, EditorElement};
use gpui::*;
use util::ResultExt;
use workspace::{
ModalView, Workspace,
dock::{Dock, PanelHandle},
};
// nate: It is a pain to get editors to size correctly and not overflow.
//
// this can get replaced with a simple flex layout with more time/a more thoughtful approach.
#[derive(Debug, Clone, Copy)]
pub struct ModalContainerProperties {
pub modal_width: f32,
pub editor_height: f32,
pub footer_height: f32,
pub container_padding: f32,
pub modal_border_radius: f32,
}
impl ModalContainerProperties {
pub fn new(window: &Window, preferred_char_width: usize) -> Self {
let container_padding = 5.0;
// Calculate width based on character width
let mut modal_width = 460.0;
let style = window.text_style();
let font_id = window.text_system().resolve_font(&style.font());
let font_size = style.font_size.to_pixels(window.rem_size());
if let Ok(em_width) = window.text_system().em_width(font_id, font_size) {
modal_width =
f32::from(preferred_char_width as f32 * em_width + px(container_padding * 2.0));
}
Self {
modal_width,
editor_height: 300.0,
footer_height: 24.0,
container_padding,
modal_border_radius: 12.0,
}
}
pub fn editor_border_radius(&self) -> Pixels {
px(self.modal_border_radius - self.container_padding / 2.0)
}
}
pub struct CommitModal {
git_panel: Entity<GitPanel>,
commit_editor: Entity<Editor>,
restore_dock: RestoreDock,
properties: ModalContainerProperties,
branch_list_handle: PopoverMenuHandle<BranchList>,
commit_menu_handle: PopoverMenuHandle<ContextMenu>,
}
impl Focusable for CommitModal {
fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
self.commit_editor.focus_handle(cx)
}
}
impl EventEmitter<DismissEvent> for CommitModal {}
impl ModalView for CommitModal {
fn on_before_dismiss(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) -> workspace::DismissDecision {
self.git_panel.update(cx, |git_panel, cx| {
git_panel.set_modal_open(false, cx);
});
self.restore_dock
.dock
.update(cx, |dock, cx| {
if let Some(active_index) = self.restore_dock.active_index {
dock.activate_panel(active_index, window, cx)
}
dock.set_open(self.restore_dock.is_open, window, cx)
})
.log_err();
workspace::DismissDecision::Dismiss(true)
}
}
struct RestoreDock {
dock: WeakEntity<Dock>,
is_open: bool,
active_index: Option<usize>,
}
pub enum ForceMode {
Amend,
Commit,
}
impl CommitModal {
pub fn register(workspace: &mut Workspace) {
workspace.register_action(|workspace, _: &Commit, window, cx| {
CommitModal::toggle(workspace, Some(ForceMode::Commit), window, cx);
});
workspace.register_action(|workspace, _: &Amend, window, cx| {
CommitModal::toggle(workspace, Some(ForceMode::Amend), window, cx);
});
}
pub fn toggle(
workspace: &mut Workspace,
force_mode: Option<ForceMode>,
window: &mut Window,
cx: &mut Context<Workspace>,
) {
let Some(git_panel) = workspace.panel::<GitPanel>(cx) else {
return;
};
git_panel.update(cx, |git_panel, cx| {
if let Some(force_mode) = force_mode {
match force_mode {
ForceMode::Amend => {
if git_panel
.active_repository
.as_ref()
.and_then(|repo| repo.read(cx).head_commit.as_ref())
.is_some()
&& !git_panel.amend_pending()
{
git_panel.set_amend_pending(true, cx);
git_panel.load_last_commit_message(cx);
}
}
ForceMode::Commit => {
if git_panel.amend_pending() {
git_panel.set_amend_pending(false, cx);
}
}
}
}
git_panel.set_modal_open(true, cx);
git_panel.load_local_committer(cx);
});
let dock = workspace.dock_at_position(git_panel.position(window, cx));
let is_open = dock.read(cx).is_open();
let active_index = dock.read(cx).active_panel_index();
let dock = dock.downgrade();
let restore_dock_position = RestoreDock {
dock,
is_open,
active_index,
};
workspace.open_panel::<GitPanel>(window, cx);
workspace.toggle_modal(window, cx, move |window, cx| {
CommitModal::new(git_panel, restore_dock_position, window, cx)
})
}
fn new(
git_panel: Entity<GitPanel>,
restore_dock: RestoreDock,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let panel = git_panel.read(cx);
let suggested_commit_message = panel.suggest_commit_message(cx);
let commit_editor = git_panel.update(cx, |git_panel, cx| {
git_panel.set_modal_open(true, cx);
let buffer = git_panel.commit_message_buffer(cx);
let panel_editor = git_panel.commit_editor.clone();
let project = git_panel.project.clone();
cx.new(|cx| {
let mut editor =
commit_message_editor(buffer, None, project.clone(), false, window, cx);
editor.sync_selections(panel_editor, cx).detach();
editor
})
});
let commit_message = commit_editor.read(cx).text(cx);
if let Some(suggested_commit_message) = suggested_commit_message
&& commit_message.is_empty()
{
commit_editor.update(cx, |editor, cx| {
editor.set_placeholder_text(&suggested_commit_message, window, cx);
});
}
let focus_handle = commit_editor.focus_handle(cx);
cx.on_focus_out(&focus_handle, window, |this, _, window, cx| {
if !this.branch_list_handle.is_focused(window, cx)
&& !this.commit_menu_handle.is_focused(window, cx)
{
cx.emit(DismissEvent);
}
})
.detach();
let properties = ModalContainerProperties::new(window, 50);
Self {
git_panel,
commit_editor,
restore_dock,
properties,
branch_list_handle: PopoverMenuHandle::default(),
commit_menu_handle: PopoverMenuHandle::default(),
}
}
fn commit_editor_element(&self, window: &mut Window, cx: &mut Context<Self>) -> EditorElement {
let editor_style = panel_editor_style(true, window, cx);
EditorElement::new(&self.commit_editor, editor_style)
}
pub fn render_commit_editor(
&self,
window: &mut Window,
cx: &mut Context<Self>,
) -> impl IntoElement {
let properties = self.properties;
let padding_t = 3.0;
let padding_b = 6.0;
// magic number for editor not to overflow the container??
let extra_space_hack = 1.5 * window.line_height();
v_flex()
.h(px(properties.editor_height + padding_b + padding_t) + extra_space_hack)
.w_full()
.flex_none()
.rounded(properties.editor_border_radius())
.overflow_hidden()
.px_1p5()
.pt(px(padding_t))
.pb(px(padding_b))
.child(
div()
.h(px(properties.editor_height))
.w_full()
.child(self.commit_editor_element(window, cx)),
)
}
fn render_git_commit_menu(
&self,
id: impl Into<ElementId>,
keybinding_target: Option<FocusHandle>,
) -> impl IntoElement {
PopoverMenu::new(id.into())
.trigger(
ui::ButtonLike::new_rounded_right("commit-split-button-right")
.layer(ui::ElevationIndex::ModalSurface)
.size(ui::ButtonSize::None)
.child(
div()
.px_1()
.child(Icon::new(IconName::ChevronDown).size(IconSize::XSmall)),
),
)
.menu({
let git_panel_entity = self.git_panel.clone();
move |window, cx| {
let git_panel = git_panel_entity.read(cx);
let amend_enabled = git_panel.amend_pending();
let signoff_enabled = git_panel.signoff_enabled();
let has_previous_commit = git_panel.head_commit(cx).is_some();
Some(ContextMenu::build(window, cx, |context_menu, _, _| {
context_menu
.when_some(keybinding_target.clone(), |el, keybinding_target| {
el.context(keybinding_target)
})
.when(has_previous_commit, |this| {
this.toggleable_entry(
"Amend",
amend_enabled,
IconPosition::Start,
Some(Box::new(Amend)),
{
let git_panel = git_panel_entity.downgrade();
move |_, cx| {
git_panel
.update(cx, |git_panel, cx| {
git_panel.toggle_amend_pending(cx);
})
.ok();
}
},
)
})
.toggleable_entry(
"Signoff",
signoff_enabled,
IconPosition::Start,
Some(Box::new(Signoff)),
{
let git_panel = git_panel_entity.clone();
move |window, cx| {
git_panel.update(cx, |git_panel, cx| {
git_panel.toggle_signoff_enabled(&Signoff, window, cx);
})
}
},
)
}))
}
})
.with_handle(self.commit_menu_handle.clone())
.anchor(Anchor::TopRight)
}
pub fn render_footer(&self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let (
can_commit,
tooltip,
commit_label,
co_authors,
generate_commit_message,
active_repo,
is_amend_pending,
is_signoff_enabled,
workspace,
) = self.git_panel.update(cx, |git_panel, cx| {
let (can_commit, tooltip) = git_panel.configure_commit_button(cx);
let title = git_panel.commit_button_title();
let co_authors = git_panel.render_co_authors(cx);
let generate_commit_message = git_panel.render_generate_commit_message_button(cx);
let active_repo = git_panel.active_repository.clone();
let is_amend_pending = git_panel.amend_pending();
let is_signoff_enabled = git_panel.signoff_enabled();
(
can_commit,
tooltip,
title,
co_authors,
generate_commit_message,
active_repo,
is_amend_pending,
is_signoff_enabled,
git_panel.workspace.clone(),
)
});
let branch = active_repo
.as_ref()
.and_then(|repo| repo.read(cx).branch.as_ref())
.map(|b| b.name().to_owned())
.unwrap_or_else(|| "<no branch>".to_owned());
let branch_picker_button = Button::new("branch_picker_button", branch)
.start_icon(
Icon::new(IconName::GitBranch)
.size(IconSize::Small)
.color(Color::Placeholder),
)
.style(ButtonStyle::Transparent)
.color(Color::Muted)
.on_click(cx.listener(|_, _, window, cx| {
window.dispatch_action(zed_actions::git::Branch.boxed_clone(), cx);
}));
let branch_picker = PopoverMenu::new("popover-button")
.menu(move |window, cx| {
Some(branch_picker::popover(
workspace.clone(),
false,
active_repo.clone(),
window,
cx,
))
})
.with_handle(self.branch_list_handle.clone())
.trigger_with_tooltip(
branch_picker_button,
Tooltip::for_action_title("Switch Branch", &zed_actions::git::Branch),
)
.anchor(Anchor::BottomLeft)
.offset(gpui::Point {
x: px(0.0),
y: px(-2.0),
});
let focus_handle = self.focus_handle(cx);
let close_kb_hint = ui::KeyBinding::for_action(&menu::Cancel, cx).map(|close_kb| {
KeybindingHint::new(close_kb, cx.theme().colors().editor_background).suffix("Cancel")
});
h_flex()
.group("commit_editor_footer")
.flex_none()
.w_full()
.items_center()
.justify_between()
.w_full()
.h(px(self.properties.footer_height))
.gap_1()
.child(
h_flex()
.gap_1()
.flex_shrink()
.overflow_x_hidden()
.child(
h_flex()
.flex_shrink()
.overflow_x_hidden()
.child(branch_picker),
)
.children(generate_commit_message)
.children(co_authors),
)
.child(div().flex_1())
.child(
h_flex()
.items_center()
.justify_end()
.flex_none()
.px_1()
.gap_4()
.child(close_kb_hint)
.child(SplitButton::new(
ui::ButtonLike::new_rounded_left(ElementId::Name(
format!("split-button-left-{}", commit_label).into(),
))
.layer(ui::ElevationIndex::ModalSurface)
.size(ui::ButtonSize::Compact)
.child(
div()
.child(Label::new(commit_label).size(LabelSize::Small))
.mr_0p5(),
)
.on_click(cx.listener(move |this, _: &ClickEvent, window, cx| {
telemetry::event!("Git Committed", source = "Git Modal");
this.git_panel.update(cx, |git_panel, cx| {
git_panel.commit_changes(
CommitOptions {
amend: is_amend_pending,
signoff: is_signoff_enabled,
allow_empty: false,
},
window,
cx,
)
});
cx.emit(DismissEvent);
}))
.disabled(!can_commit)
.tooltip({
let focus_handle = focus_handle.clone();
move |_window, cx| {
if can_commit {
Tooltip::with_meta_in(
tooltip,
Some(&git::Commit),
format!(
"git commit{}{}",
if is_amend_pending { " --amend" } else { "" },
if is_signoff_enabled { " --signoff" } else { "" }
),
&focus_handle.clone(),
cx,
)
} else {
Tooltip::simple(tooltip, cx)
}
}
}),
self.render_git_commit_menu(
ElementId::Name(format!("split-button-right-{}", commit_label).into()),
Some(focus_handle),
)
.into_any_element(),
)),
)
}
fn dismiss(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
if self.git_panel.read(cx).amend_pending() {
self.git_panel
.update(cx, |git_panel, cx| git_panel.set_amend_pending(false, cx));
} else {
cx.emit(DismissEvent);
}
}
fn on_commit(&mut self, _: &git::Commit, window: &mut Window, cx: &mut Context<Self>) {
let is_amend = self.git_panel.read(cx).amend_pending();
let did_execute = self.git_panel.update(cx, |git_panel, cx| {
git_panel.commit(&self.commit_editor.focus_handle(cx), window, cx)
});
if did_execute {
if is_amend {
telemetry::event!("Git Amended", source = "Git Modal");
} else {
telemetry::event!("Git Committed", source = "Git Modal");
}
cx.emit(DismissEvent);
}
}
fn on_amend(&mut self, _: &git::Amend, window: &mut Window, cx: &mut Context<Self>) {
if self.git_panel.update(cx, |git_panel, cx| {
git_panel.amend(&self.commit_editor.focus_handle(cx), window, cx)
}) {
telemetry::event!("Git Amended", source = "Git Modal");
cx.emit(DismissEvent);
}
}
fn toggle_branch_selector(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.branch_list_handle.is_focused(window, cx) {
self.focus_handle(cx).focus(window, cx)
} else {
self.branch_list_handle.toggle(window, cx);
}
}
}
impl Render for CommitModal {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let properties = self.properties;
let width = px(properties.modal_width);
let container_padding = px(properties.container_padding);
let border_radius = properties.modal_border_radius;
let editor_focus_handle = self.commit_editor.focus_handle(cx);
let max_title_length = GitPanelSettings::get_global(cx).commit_title_max_length;
let title_exceeds_limit = if max_title_length > 0 {
self.commit_editor
.read(cx)
.text(cx)
.lines()
.next()
.is_some_and(|title| title.len() > max_title_length)
} else {
false
};
v_flex()
.id("commit-modal")
.key_context("GitCommit")
.on_action(cx.listener(Self::dismiss))
.on_action(cx.listener(Self::on_commit))
.on_action(cx.listener(Self::on_amend))
.when(!DisableAiSettings::get_global(cx).disable_ai, |this| {
this.on_action(cx.listener(|this, _: &GenerateCommitMessage, _, cx| {
this.git_panel.update(cx, |panel, cx| {
panel.generate_commit_message(cx);
})
}))
})
.on_action(
cx.listener(|this, _: &zed_actions::git::Branch, window, cx| {
this.toggle_branch_selector(window, cx);
}),
)
.on_action(
cx.listener(|this, _: &zed_actions::git::CheckoutBranch, window, cx| {
this.toggle_branch_selector(window, cx);
}),
)
.on_action(
cx.listener(|this, _: &zed_actions::git::Switch, window, cx| {
this.toggle_branch_selector(window, cx);
}),
)
.w(width)
.h_112()
.p(container_padding)
.elevation_3(cx)
.overflow_hidden()
.flex_none()
.relative()
.bg(cx.theme().colors().elevated_surface_background)
.rounded(px(border_radius))
.border_1()
.border_color(cx.theme().colors().border)
.child(
v_flex()
.id("editor-container")
.cursor_text()
.p_2()
.size_full()
.gap_2()
.justify_between()
.rounded(properties.editor_border_radius())
.overflow_hidden()
.bg(cx.theme().colors().editor_background)
.border_1()
.border_color(if title_exceeds_limit {
cx.theme().status().warning_border
} else {
cx.theme().colors().border_variant
})
.on_click(cx.listener(move |_, _: &ClickEvent, window, cx| {
window.focus(&editor_focus_handle, cx);
}))
.child(self.render_commit_editor(window, cx))
.when(title_exceeds_limit, |this| {
this.child(
h_flex()
.absolute()
.bottom_12()
.w_full()
.py_1()
.px_2()
.gap_1()
.justify_center()
.child(
Icon::new(IconName::Warning)
.size(IconSize::XSmall)
.color(Color::Warning),
)
.child(
Label::new(format!(
"Commit message title exceeds {max_title_length}-character limit."
))
.size(LabelSize::Small),
),
)
})
.child(self.render_footer(window, cx)),
)
}
}

View File

@@ -0,0 +1,405 @@
use crate::commit_view::CommitView;
use editor::hover_markdown_style;
use futures::Future;
use git::blame::BlameEntry;
use git::repository::CommitSummary;
use git::{GitRemote, commit::ParsedCommitMessage};
use gpui::{
AbsoluteLength, App, Asset, Element, Entity, MouseButton, ParentElement, Render, ScrollHandle,
StatefulInteractiveElement, WeakEntity, prelude::*,
};
use markdown::{Markdown, MarkdownElement};
use project::git_store::Repository;
use settings::Settings;
use std::hash::Hash;
use theme_settings::ThemeSettings;
use time::{OffsetDateTime, UtcOffset};
use ui::{Avatar, CopyButton, Divider, prelude::*, tooltip_container};
use workspace::Workspace;
#[derive(Clone, Debug)]
pub struct CommitDetails {
pub sha: SharedString,
pub author_name: SharedString,
pub author_email: SharedString,
pub commit_time: OffsetDateTime,
pub message: Option<ParsedCommitMessage>,
}
pub struct CommitAvatar<'a> {
sha: &'a SharedString,
author_email: Option<SharedString>,
remote: Option<&'a GitRemote>,
size: Option<AbsoluteLength>,
}
impl<'a> CommitAvatar<'a> {
pub fn new(
sha: &'a SharedString,
author_email: Option<SharedString>,
remote: Option<&'a GitRemote>,
) -> Self {
Self {
sha,
author_email,
remote,
size: None,
}
}
pub fn from_commit_details(details: &'a CommitDetails) -> Self {
Self {
sha: &details.sha,
author_email: Some(details.author_email.clone()),
remote: details
.message
.as_ref()
.and_then(|details| details.remote.as_ref()),
size: None,
}
}
pub fn size(mut self, size: impl Into<AbsoluteLength>) -> Self {
self.size = Some(size.into());
self
}
pub fn render(&'a self, window: &mut Window, cx: &mut App) -> AnyElement {
let border_color = cx.theme().colors().border_variant;
let border_width = px(1.);
match self.avatar(window, cx) {
None => {
let container_size = self
.size
.map(|s| s.to_pixels(window.rem_size()) + border_width * 2.);
h_flex()
.when_some(container_size, |this, size| this.size(size))
.justify_center()
.rounded_full()
.border(border_width)
.border_color(border_color)
.bg(cx.theme().colors().element_disabled)
.child(
Icon::new(IconName::Person)
.color(Color::Muted)
.size(IconSize::XSmall),
)
.into_any_element()
}
Some(avatar) => avatar
.when_some(self.size, |this, size| this.size(size))
.border_color(border_color)
.into_any_element(),
}
}
pub fn avatar(&'a self, window: &mut Window, cx: &mut App) -> Option<Avatar> {
let remote = self
.remote
.filter(|remote| remote.host_supports_avatars())?;
let avatar_url =
CommitAvatarAsset::new(remote.clone(), self.sha.clone(), self.author_email.clone());
let url = window.use_asset::<CommitAvatarAsset>(&avatar_url, cx)??;
Some(Avatar::new(url.to_string()))
}
}
#[derive(Clone, Debug)]
struct CommitAvatarAsset {
sha: SharedString,
author_email: Option<SharedString>,
remote: GitRemote,
}
impl Hash for CommitAvatarAsset {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.sha.hash(state);
self.remote.host.name().hash(state);
}
}
impl CommitAvatarAsset {
fn new(remote: GitRemote, sha: SharedString, author_email: Option<SharedString>) -> Self {
Self {
remote,
sha,
author_email,
}
}
}
impl Asset for CommitAvatarAsset {
type Source = Self;
type Output = Option<SharedString>;
fn load(
source: Self::Source,
cx: &mut App,
) -> impl Future<Output = Self::Output> + Send + 'static {
let client = cx.http_client();
async move {
source
.remote
.avatar_url(source.sha, source.author_email, client)
.await
.map(|url| SharedString::from(url.to_string()))
}
}
}
pub struct CommitTooltip {
commit: CommitDetails,
scroll_handle: ScrollHandle,
markdown: Entity<Markdown>,
repository: Entity<Repository>,
workspace: WeakEntity<Workspace>,
}
impl CommitTooltip {
pub fn blame_entry(
blame: &BlameEntry,
details: Option<ParsedCommitMessage>,
repository: Entity<Repository>,
workspace: WeakEntity<Workspace>,
cx: &mut Context<Self>,
) -> Self {
let commit_time = blame
.committer_time
.and_then(|t| OffsetDateTime::from_unix_timestamp(t).ok())
.unwrap_or(OffsetDateTime::now_utc());
Self::new(
CommitDetails {
sha: blame.sha.to_string().into(),
commit_time,
author_name: blame
.author
.clone()
.unwrap_or("<no name>".to_string())
.into(),
author_email: blame.author_mail.clone().unwrap_or("".to_string()).into(),
message: details,
},
repository,
workspace,
cx,
)
}
pub fn new(
commit: CommitDetails,
repository: Entity<Repository>,
workspace: WeakEntity<Workspace>,
cx: &mut Context<Self>,
) -> Self {
let markdown = cx.new(|cx| {
Markdown::new(
commit
.message
.as_ref()
.map(|message| message.message.clone())
.unwrap_or_default(),
None,
None,
cx,
)
});
Self {
commit,
repository,
workspace,
scroll_handle: ScrollHandle::new(),
markdown,
}
}
}
impl Render for CommitTooltip {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let avatar = CommitAvatar::from_commit_details(&self.commit).render(window, cx);
let author = self.commit.author_name.clone();
let author_email = self.commit.author_email.clone();
let short_commit_id = self
.commit
.sha
.get(0..8)
.map(|sha| sha.to_string().into())
.unwrap_or_else(|| self.commit.sha.clone());
let full_sha = self.commit.sha.to_string();
let local_offset = UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC);
let absolute_timestamp = time_format::format_localized_timestamp(
self.commit.commit_time,
OffsetDateTime::now_utc(),
local_offset,
time_format::TimestampFormat::MediumAbsolute,
);
let markdown_style = {
let style = hover_markdown_style(window, cx);
style
};
let message = self
.commit
.message
.as_ref()
.map(|_| MarkdownElement::new(self.markdown.clone(), markdown_style).into_any())
.unwrap_or("<no commit message>".into_any());
let pull_request = self
.commit
.message
.as_ref()
.and_then(|details| details.pull_request.clone());
let ui_font_size = ThemeSettings::get_global(cx).ui_font_size(cx);
let message_max_height = window.line_height() * 12 + (ui_font_size / 0.4);
let repo = self.repository.clone();
let workspace = self.workspace.clone();
let commit_summary = CommitSummary {
sha: self.commit.sha.clone(),
subject: self
.commit
.message
.as_ref()
.map_or(Default::default(), |message| {
message
.message
.split('\n')
.next()
.unwrap()
.trim_end()
.to_string()
.into()
}),
commit_timestamp: self.commit.commit_time.unix_timestamp(),
author_name: self.commit.author_name.clone(),
has_parent: false,
};
tooltip_container(cx, move |this, cx| {
this.occlude()
.on_mouse_move(|_, _, cx| cx.stop_propagation())
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
.child(
v_flex()
.w(gpui::rems(30.))
.gap_4()
.child(
h_flex()
.pb_1p5()
.gap_x_2()
.overflow_x_hidden()
.flex_wrap()
.child(avatar)
.child(author)
.when(!author_email.is_empty(), |this| {
this.child(
div()
.text_color(cx.theme().colors().text_muted)
.child(author_email),
)
})
.border_b_1()
.border_color(cx.theme().colors().border_variant),
)
.child(
div()
.id("inline-blame-commit-message")
.child(message)
.max_h(message_max_height)
.overflow_y_scroll()
.track_scroll(&self.scroll_handle),
)
.child(
h_flex()
.text_color(cx.theme().colors().text_muted)
.w_full()
.justify_between()
.pt_1p5()
.border_t_1()
.border_color(cx.theme().colors().border_variant)
.child(absolute_timestamp)
.child(
h_flex()
.gap_1p5()
.when_some(pull_request, |this, pr| {
this.child(
Button::new(
"pull-request-button",
format!("#{}", pr.number),
)
.color(Color::Muted)
.start_icon(
Icon::new(IconName::PullRequest)
.color(Color::Muted),
)
.style(ButtonStyle::Subtle)
.on_click(move |_, _, cx| {
cx.stop_propagation();
cx.open_url(pr.url.as_str())
}),
)
.child(Divider::vertical())
})
.child(
Button::new(
"commit-sha-button",
short_commit_id.clone(),
)
.style(ButtonStyle::Subtle)
.color(Color::Muted)
.start_icon(
Icon::new(IconName::FileGit).color(Color::Muted),
)
.on_click(
move |_, window, cx| {
CommitView::open(
commit_summary.sha.to_string(),
repo.downgrade(),
workspace.clone(),
None,
None,
window,
cx,
);
cx.stop_propagation();
},
),
)
.child(Divider::vertical())
.child(
CopyButton::new("copy-commit-sha", full_sha)
.tooltip_label("Copy SHA"),
),
),
),
)
})
}
}
fn blame_entry_timestamp(blame_entry: &BlameEntry, format: time_format::TimestampFormat) -> String {
match blame_entry.author_offset_date_time() {
Ok(timestamp) => {
let local_offset = UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC);
time_format::format_localized_timestamp(
timestamp,
time::OffsetDateTime::now_utc(),
local_offset,
format,
)
}
Err(_) => "Error parsing date".to_string(),
}
}
pub fn blame_entry_relative_timestamp(blame_entry: &BlameEntry) -> String {
blame_entry_timestamp(blame_entry, time_format::TimestampFormat::Relative)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,690 @@
use agent_settings::AgentSettings;
use collections::{HashMap, HashSet};
use editor::{
ConflictsOurs, ConflictsOursMarker, ConflictsOuter, ConflictsTheirs, ConflictsTheirsMarker,
Editor, EditorEvent, MultiBuffer, RowHighlightOptions,
display_map::{BlockContext, BlockPlacement, BlockProperties, BlockStyle, CustomBlockId},
};
use gpui::{
App, ClickEvent, Context, Empty, Entity, InteractiveElement as _, ParentElement as _,
Subscription, Task, WeakEntity,
};
use language::{Anchor, Buffer, BufferId};
use project::{
ConflictRegion, ConflictSet, ConflictSetUpdate, Project, ProjectItem as _,
git_store::{GitStore, GitStoreEvent, RepositoryEvent},
};
use settings::Settings;
use std::{ops::Range, sync::Arc};
use ui::{ButtonLike, Divider, Tooltip, prelude::*};
use util::{ResultExt as _, debug_panic, maybe};
use workspace::{HideStatusItem, StatusItemView, Workspace, item::ItemHandle};
use zed_actions::agent::{
ConflictContent, ResolveConflictedFilesWithAgent, ResolveConflictsWithAgent,
};
pub(crate) struct ConflictAddon {
buffers: HashMap<BufferId, BufferConflicts>,
}
impl ConflictAddon {
pub(crate) fn conflict_set(&self, buffer_id: BufferId) -> Option<Entity<ConflictSet>> {
self.buffers
.get(&buffer_id)
.map(|entry| entry.conflict_set.clone())
}
}
struct BufferConflicts {
block_ids: Vec<(Range<Anchor>, CustomBlockId)>,
conflict_set: Entity<ConflictSet>,
_subscription: Subscription,
}
impl editor::Addon for ConflictAddon {
fn to_any(&self) -> &dyn std::any::Any {
self
}
fn to_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
Some(self)
}
}
pub fn register_editor(editor: &mut Editor, buffer: Entity<MultiBuffer>, cx: &mut Context<Editor>) {
// Only show conflict UI for singletons and in the project diff.
if !editor.mode().is_full()
|| (!editor.buffer().read(cx).is_singleton()
&& !editor.buffer().read(cx).all_diff_hunks_expanded())
|| editor.read_only(cx)
{
return;
}
editor.register_addon(ConflictAddon {
buffers: Default::default(),
});
let buffers = buffer.read(cx).all_buffers();
for buffer in buffers {
buffer_ranges_updated(editor, buffer, cx);
}
cx.subscribe(&cx.entity(), |editor, _, event, cx| match event {
EditorEvent::BufferRangesUpdated { buffer, .. } => {
buffer_ranges_updated(editor, buffer.clone(), cx)
}
EditorEvent::BuffersRemoved { removed_buffer_ids } => {
buffers_removed(editor, removed_buffer_ids, cx)
}
_ => {}
})
.detach();
}
fn buffer_ranges_updated(editor: &mut Editor, buffer: Entity<Buffer>, cx: &mut Context<Editor>) {
let Some(project) = editor.project() else {
return;
};
let git_store = project.read(cx).git_store().clone();
let buffer_conflicts = editor
.addon_mut::<ConflictAddon>()
.unwrap()
.buffers
.entry(buffer.read(cx).remote_id())
.or_insert_with(|| {
let conflict_set = git_store.update(cx, |git_store, cx| {
git_store.open_conflict_set(buffer.clone(), cx)
});
let subscription = cx.subscribe(&conflict_set, conflicts_updated);
BufferConflicts {
block_ids: Vec::new(),
conflict_set,
_subscription: subscription,
}
});
let conflict_set = buffer_conflicts.conflict_set.clone();
let conflicts_len = conflict_set.read(cx).snapshot().conflicts.len();
let addon_conflicts_len = buffer_conflicts.block_ids.len();
conflicts_updated(
editor,
conflict_set,
&ConflictSetUpdate {
buffer_range: None,
old_range: 0..addon_conflicts_len,
new_range: 0..conflicts_len,
},
cx,
);
}
fn buffers_removed(editor: &mut Editor, removed_buffer_ids: &[BufferId], cx: &mut Context<Editor>) {
let mut removed_block_ids = HashSet::default();
editor
.addon_mut::<ConflictAddon>()
.unwrap()
.buffers
.retain(|buffer_id, buffer| {
if removed_buffer_ids.contains(buffer_id) {
removed_block_ids.extend(buffer.block_ids.iter().map(|(_, block_id)| *block_id));
false
} else {
true
}
});
editor.remove_blocks(removed_block_ids, None, cx);
}
#[ztracing::instrument(skip_all)]
fn conflicts_updated(
editor: &mut Editor,
conflict_set: Entity<ConflictSet>,
event: &ConflictSetUpdate,
cx: &mut Context<Editor>,
) {
let buffer_id = conflict_set.read(cx).snapshot.buffer_id;
let conflict_set = conflict_set.read(cx).snapshot();
let multibuffer = editor.buffer().read(cx);
let snapshot = multibuffer.snapshot(cx);
let old_range = maybe!({
let conflict_addon = editor.addon_mut::<ConflictAddon>().unwrap();
let buffer_conflicts = conflict_addon.buffers.get(&buffer_id)?;
match buffer_conflicts.block_ids.get(event.old_range.clone()) {
Some(_) => Some(event.old_range.clone()),
None => {
debug_panic!(
"conflicts updated event old range is invalid for buffer conflicts view (block_ids len is {:?}, old_range is {:?})",
buffer_conflicts.block_ids.len(),
event.old_range,
);
if event.old_range.start <= event.old_range.end {
Some(
event.old_range.start.min(buffer_conflicts.block_ids.len())
..event.old_range.end.min(buffer_conflicts.block_ids.len()),
)
} else {
None
}
}
}
});
// Remove obsolete highlights and blocks
let conflict_addon = editor.addon_mut::<ConflictAddon>().unwrap();
if let Some((buffer_conflicts, old_range)) = conflict_addon
.buffers
.get_mut(&buffer_id)
.zip(old_range.clone())
{
let old_conflicts = buffer_conflicts.block_ids[old_range].to_owned();
let mut removed_highlighted_ranges = Vec::new();
let mut removed_block_ids = HashSet::default();
for (conflict_range, block_id) in old_conflicts {
let Some(range) = snapshot.buffer_anchor_range_to_anchor_range(conflict_range) else {
continue;
};
removed_highlighted_ranges.push(range.clone());
removed_block_ids.insert(block_id);
}
editor.remove_gutter_highlights::<ConflictsOuter>(removed_highlighted_ranges.clone(), cx);
editor.remove_highlighted_rows::<ConflictsOuter>(removed_highlighted_ranges.clone(), cx);
editor.remove_highlighted_rows::<ConflictsOurs>(removed_highlighted_ranges.clone(), cx);
editor
.remove_highlighted_rows::<ConflictsOursMarker>(removed_highlighted_ranges.clone(), cx);
editor.remove_highlighted_rows::<ConflictsTheirs>(removed_highlighted_ranges.clone(), cx);
editor.remove_highlighted_rows::<ConflictsTheirsMarker>(
removed_highlighted_ranges.clone(),
cx,
);
editor.remove_blocks(removed_block_ids, None, cx);
}
// Add new highlights and blocks
let editor_handle = cx.weak_entity();
let new_conflicts = &conflict_set.conflicts[event.new_range.clone()];
let mut blocks = Vec::new();
for conflict in new_conflicts {
update_conflict_highlighting(editor, conflict, &snapshot, cx);
let Some(anchor) = snapshot.anchor_in_excerpt(conflict.range.start) else {
continue;
};
let editor_handle = editor_handle.clone();
blocks.push(BlockProperties {
placement: BlockPlacement::Above(anchor),
height: Some(1),
style: BlockStyle::Sticky,
render: Arc::new({
let conflict = conflict.clone();
move |cx| render_conflict_buttons(&conflict, editor_handle.clone(), cx)
}),
priority: 0,
})
}
let new_block_ids = editor.insert_blocks(blocks, None, cx);
let conflict_addon = editor.addon_mut::<ConflictAddon>().unwrap();
if let Some((buffer_conflicts, old_range)) =
conflict_addon.buffers.get_mut(&buffer_id).zip(old_range)
{
buffer_conflicts.block_ids.splice(
old_range,
new_conflicts
.iter()
.map(|conflict| conflict.range.clone())
.zip(new_block_ids),
);
}
}
#[ztracing::instrument(skip_all)]
fn update_conflict_highlighting(
editor: &mut Editor,
conflict: &ConflictRegion,
buffer: &editor::MultiBufferSnapshot,
cx: &mut Context<Editor>,
) -> Option<()> {
log::debug!("update conflict highlighting for {conflict:?}");
let outer = buffer.buffer_anchor_range_to_anchor_range(conflict.range.clone())?;
let ours = buffer.buffer_anchor_range_to_anchor_range(conflict.ours.clone())?;
let theirs = buffer.buffer_anchor_range_to_anchor_range(conflict.theirs.clone())?;
let ours_background = cx.theme().colors().version_control_conflict_marker_ours;
let theirs_background = cx.theme().colors().version_control_conflict_marker_theirs;
let options = RowHighlightOptions {
include_gutter: true,
..Default::default()
};
editor.insert_gutter_highlight::<ConflictsOuter>(
outer.start..theirs.end,
|cx| cx.theme().colors().editor_background,
cx,
);
// Prevent diff hunk highlighting within the entire conflict region.
editor.highlight_rows::<ConflictsOuter>(outer.clone(), theirs_background, options, cx);
editor.highlight_rows::<ConflictsOurs>(ours.clone(), ours_background, options, cx);
editor.highlight_rows::<ConflictsOursMarker>(
outer.start..ours.start,
ours_background,
options,
cx,
);
editor.highlight_rows::<ConflictsTheirs>(theirs.clone(), theirs_background, options, cx);
editor.highlight_rows::<ConflictsTheirsMarker>(
theirs.end..outer.end,
theirs_background,
options,
cx,
);
Some(())
}
fn render_conflict_buttons(
conflict: &ConflictRegion,
editor: WeakEntity<Editor>,
cx: &mut BlockContext,
) -> AnyElement {
let is_ai_enabled = AgentSettings::get_global(cx).enabled(cx);
h_flex()
.id(cx.block_id)
.h(cx.line_height)
.ml(cx.margins.gutter.width)
.gap_1()
.bg(cx.theme().colors().editor_background)
.child(
Button::new("head", format!("Use {}", conflict.ours_branch_name))
.label_size(LabelSize::Small)
.on_click({
let editor = editor.clone();
let conflict = conflict.clone();
let ours = conflict.ours.clone();
move |_, window, cx| {
resolve_conflict(
editor.clone(),
conflict.clone(),
vec![ours.clone()],
window,
cx,
)
.detach()
}
}),
)
.child(
Button::new("origin", format!("Use {}", conflict.theirs_branch_name))
.label_size(LabelSize::Small)
.on_click({
let editor = editor.clone();
let conflict = conflict.clone();
let theirs = conflict.theirs.clone();
move |_, window, cx| {
resolve_conflict(
editor.clone(),
conflict.clone(),
vec![theirs.clone()],
window,
cx,
)
.detach()
}
}),
)
.child(
Button::new("both", "Use Both")
.label_size(LabelSize::Small)
.on_click({
let editor = editor.clone();
let conflict = conflict.clone();
let ours = conflict.ours.clone();
let theirs = conflict.theirs.clone();
move |_, window, cx| {
resolve_conflict(
editor.clone(),
conflict.clone(),
vec![ours.clone(), theirs.clone()],
window,
cx,
)
.detach()
}
}),
)
.when(is_ai_enabled, |this| {
this.child(Divider::vertical()).child(
Button::new("resolve-with-agent", "Resolve with Agent")
.label_size(LabelSize::Small)
.start_icon(
Icon::new(IconName::ZedAssistant)
.size(IconSize::Small)
.color(Color::Muted),
)
.on_click({
let conflict = conflict.clone();
move |_, window, cx| {
let content = editor
.update(cx, |editor, cx| {
let multibuffer = editor.buffer().read(cx);
let buffer_id = conflict.ours.end.buffer_id;
let buffer = multibuffer.buffer(buffer_id)?;
let buffer_read = buffer.read(cx);
let snapshot = buffer_read.snapshot();
let conflict_text = snapshot
.text_for_range(conflict.range.clone())
.collect::<String>();
let file_path = buffer_read
.file()
.and_then(|file| file.as_local())
.map(|f| f.abs_path(cx).to_string_lossy().to_string())
.unwrap_or_default();
Some(ConflictContent {
file_path,
conflict_text,
ours_branch_name: conflict.ours_branch_name.to_string(),
theirs_branch_name: conflict.theirs_branch_name.to_string(),
})
})
.ok()
.flatten();
if let Some(content) = content {
window.dispatch_action(
Box::new(ResolveConflictsWithAgent {
conflicts: vec![content],
}),
cx,
);
}
}
}),
)
})
.into_any()
}
fn collect_conflicted_file_paths(project: &Project, cx: &App) -> Vec<String> {
let git_store = project.git_store().read(cx);
let mut paths = Vec::new();
for repo in git_store.repositories().values() {
let snapshot = repo.read(cx).snapshot();
for (repo_path, _) in snapshot.merge.merge_heads_by_conflicted_path.iter() {
let is_currently_conflicted = snapshot
.status_for_path(repo_path)
.is_some_and(|entry| entry.status.is_conflicted());
if !is_currently_conflicted {
continue;
}
if let Some(project_path) = repo.read(cx).repo_path_to_project_path(repo_path, cx) {
paths.push(
project_path
.path
.as_std_path()
.to_string_lossy()
.to_string(),
);
}
}
}
paths
}
pub(crate) fn resolve_conflict(
editor: WeakEntity<Editor>,
resolved_conflict: ConflictRegion,
ranges: Vec<Range<Anchor>>,
window: &mut Window,
cx: &mut App,
) -> Task<()> {
window.spawn(cx, async move |cx| {
let Some((workspace, project, multibuffer, buffer)) = editor
.update(cx, |editor, cx| {
let workspace = editor.workspace()?;
let project = editor.project()?.clone();
let multibuffer = editor.buffer().clone();
let buffer_id = resolved_conflict.ours.end.buffer_id;
let buffer = multibuffer.read(cx).buffer(buffer_id)?;
resolved_conflict.resolve(buffer.clone(), &ranges, cx);
let conflict_addon = editor.addon_mut::<ConflictAddon>().unwrap();
let snapshot = multibuffer.read(cx).snapshot(cx);
let buffer_snapshot = buffer.read(cx).snapshot();
let state = conflict_addon
.buffers
.get_mut(&buffer_snapshot.remote_id())?;
let ix = state
.block_ids
.binary_search_by(|(range, _)| {
range
.start
.cmp(&resolved_conflict.range.start, &buffer_snapshot)
})
.ok()?;
let &(_, block_id) = &state.block_ids[ix];
let range =
snapshot.buffer_anchor_range_to_anchor_range(resolved_conflict.range)?;
editor.remove_gutter_highlights::<ConflictsOuter>(vec![range.clone()], cx);
editor.remove_highlighted_rows::<ConflictsOuter>(vec![range.clone()], cx);
editor.remove_highlighted_rows::<ConflictsOurs>(vec![range.clone()], cx);
editor.remove_highlighted_rows::<ConflictsTheirs>(vec![range.clone()], cx);
editor.remove_highlighted_rows::<ConflictsOursMarker>(vec![range.clone()], cx);
editor.remove_highlighted_rows::<ConflictsTheirsMarker>(vec![range], cx);
editor.remove_blocks(HashSet::from_iter([block_id]), None, cx);
Some((workspace, project, multibuffer, buffer))
})
.ok()
.flatten()
else {
return;
};
let save = project.update(cx, |project, cx| {
if multibuffer.read(cx).all_diff_hunks_expanded() {
project.save_buffer(buffer.clone(), cx)
} else {
Task::ready(Ok(()))
}
});
if save.await.log_err().is_none() {
let open_path = maybe!({
let path = buffer.read_with(cx, |buffer, cx| buffer.project_path(cx))?;
workspace
.update_in(cx, |workspace, window, cx| {
workspace.open_path_preview(path, None, false, false, false, window, cx)
})
.ok()
});
if let Some(open_path) = open_path {
open_path.await.log_err();
}
}
})
}
pub struct MergeConflictIndicator {
project: Entity<Project>,
conflicted_paths: Vec<String>,
last_shown_paths: HashSet<String>,
dismissed: bool,
_subscription: Subscription,
}
impl MergeConflictIndicator {
pub fn new(workspace: &Workspace, cx: &mut Context<Self>) -> Self {
let project = workspace.project().clone();
let git_store = project.read(cx).git_store().clone();
let subscription = cx.subscribe(&git_store, Self::on_git_store_event);
let conflicted_paths = collect_conflicted_file_paths(project.read(cx), cx);
let last_shown_paths: HashSet<String> = conflicted_paths.iter().cloned().collect();
Self {
project,
conflicted_paths,
last_shown_paths,
dismissed: false,
_subscription: subscription,
}
}
fn on_git_store_event(
&mut self,
_git_store: Entity<GitStore>,
event: &GitStoreEvent,
cx: &mut Context<Self>,
) {
let conflicts_changed = matches!(
event,
GitStoreEvent::ConflictsUpdated
| GitStoreEvent::RepositoryUpdated(_, RepositoryEvent::StatusesChanged, _)
);
let agent_settings = AgentSettings::get_global(cx);
if !agent_settings.enabled(cx)
|| !agent_settings.show_merge_conflict_indicator
|| !conflicts_changed
{
return;
}
let project = self.project.read(cx);
if project.is_via_collab() {
return;
}
let paths = collect_conflicted_file_paths(project, cx);
let current_paths_set: HashSet<String> = paths.iter().cloned().collect();
if paths.is_empty() {
self.conflicted_paths.clear();
self.last_shown_paths.clear();
self.dismissed = false;
cx.notify();
} else if self.last_shown_paths != current_paths_set {
self.last_shown_paths = current_paths_set;
self.conflicted_paths = paths;
self.dismissed = false;
cx.notify();
}
}
fn resolve_with_agent(&mut self, window: &mut Window, cx: &mut Context<Self>) {
window.dispatch_action(
Box::new(ResolveConflictedFilesWithAgent {
conflicted_file_paths: self.conflicted_paths.clone(),
}),
cx,
);
self.dismissed = true;
cx.notify();
}
fn dismiss(&mut self, _: &ClickEvent, _window: &mut Window, cx: &mut Context<Self>) {
self.dismissed = true;
cx.notify();
}
}
impl Render for MergeConflictIndicator {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let agent_settings = AgentSettings::get_global(cx);
if !agent_settings.enabled(cx)
|| !agent_settings.show_merge_conflict_indicator
|| self.conflicted_paths.is_empty()
|| self.dismissed
{
return Empty.into_any_element();
}
let file_count = self.conflicted_paths.len();
let message: SharedString = format!(
"Resolve Merge Conflict{} with Agent",
if file_count == 1 { "" } else { "s" }
)
.into();
let tooltip_label: SharedString = format!(
"Found {} {} across the codebase",
file_count,
if file_count == 1 {
"conflict"
} else {
"conflicts"
}
)
.into();
let border_color = cx.theme().colors().text_accent.opacity(0.2);
h_flex()
.h(rems_from_px(22.))
.rounded_sm()
.border_1()
.border_color(border_color)
.child(
ButtonLike::new("update-button")
.child(
h_flex()
.h_full()
.gap_1()
.child(
Icon::new(IconName::GitMergeConflict)
.size(IconSize::Small)
.color(Color::Muted),
)
.child(Label::new(message).size(LabelSize::Small)),
)
.tooltip(move |_, cx| {
Tooltip::with_meta(
tooltip_label.clone(),
None,
"Click to Resolve with Agent",
cx,
)
})
.on_click(cx.listener(|this, _, window, cx| {
this.resolve_with_agent(window, cx);
})),
)
.child(
div().border_l_1().border_color(border_color).child(
IconButton::new("dismiss-merge-conflicts", IconName::Close)
.icon_size(IconSize::XSmall)
.on_click(cx.listener(Self::dismiss)),
),
)
.into_any_element()
}
}
impl StatusItemView for MergeConflictIndicator {
fn set_active_pane_item(
&mut self,
_: Option<&dyn ItemHandle>,
_window: &mut Window,
_: &mut Context<Self>,
) {
}
fn hide_setting(&self, _: &App) -> Option<HideStatusItem> {
Some(HideStatusItem::new(|settings| {
settings
.agent
.get_or_insert_default()
.show_merge_conflict_indicator = Some(false);
}))
}
}

View File

@@ -0,0 +1,610 @@
//! FileDiffView provides a UI for displaying differences between two buffers.
use anyhow::Result;
use buffer_diff::BufferDiff;
use editor::{Editor, EditorEvent, EditorSettings, MultiBuffer, SplittableEditor};
use futures::{FutureExt, select_biased};
use gpui::{
AnyElement, App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, FocusHandle,
Focusable, Font, IntoElement, Render, Task, WeakEntity, Window,
};
use language::{Buffer, HighlightedText, LanguageRegistry};
use project::Project;
use settings::Settings;
use std::{
any::{Any, TypeId},
path::PathBuf,
pin::pin,
sync::Arc,
time::Duration,
};
use ui::{Color, Icon, IconName, Label, LabelCommon as _, SharedString};
use util::paths::PathExt as _;
use workspace::{
Item, ItemHandle as _, ItemNavHistory, ToolbarItemLocation, Workspace,
item::{ItemEvent, SaveOptions, TabContentParams},
searchable::SearchableItemHandle,
};
pub struct FileDiffView {
editor: Entity<SplittableEditor>,
old_buffer: Entity<Buffer>,
new_buffer: Entity<Buffer>,
buffer_changes_tx: watch::Sender<()>,
_recalculate_diff_task: Task<Result<()>>,
}
const RECALCULATE_DIFF_DEBOUNCE: Duration = Duration::from_millis(250);
impl FileDiffView {
#[ztracing::instrument(skip_all)]
pub fn open(
old_path: PathBuf,
new_path: PathBuf,
workspace: WeakEntity<Workspace>,
window: &mut Window,
cx: &mut App,
) -> Task<Result<Entity<Self>>> {
window.spawn(cx, async move |cx| {
let project = workspace.update(cx, |workspace, _| workspace.project().clone())?;
let old_buffer = project
.update(cx, |project, cx| project.open_local_buffer(&old_path, cx))
.await?;
let new_buffer = project
.update(cx, |project, cx| project.open_local_buffer(&new_path, cx))
.await?;
let languages = project.update(cx, |project, _| project.languages().clone());
let buffer_diff = build_buffer_diff(&old_buffer, &new_buffer, languages, cx).await?;
workspace.update_in(cx, |workspace, window, cx| {
let workspace_entity = cx.entity();
let diff_view = cx.new(|cx| {
FileDiffView::new(
old_buffer,
new_buffer,
buffer_diff,
project.clone(),
workspace_entity,
window,
cx,
)
});
let pane = workspace.active_pane();
pane.update(cx, |pane, cx| {
pane.add_item(Box::new(diff_view.clone()), true, true, None, window, cx);
});
diff_view
})
})
}
pub fn new(
old_buffer: Entity<Buffer>,
new_buffer: Entity<Buffer>,
diff: Entity<BufferDiff>,
project: Entity<Project>,
workspace: Entity<Workspace>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let multibuffer = cx.new(|cx| {
let mut multibuffer = MultiBuffer::singleton(new_buffer.clone(), cx);
multibuffer.add_diff(diff.clone(), cx);
multibuffer
});
let editor = cx.new(|cx| {
let splittable = SplittableEditor::new(
EditorSettings::get_global(cx).diff_view_style,
multibuffer.clone(),
project.clone(),
workspace,
window,
cx,
);
splittable.rhs_editor().update(cx, |editor, _| {
editor.start_temporary_diff_override();
});
splittable.disable_diff_hunk_controls(cx);
splittable
});
let (buffer_changes_tx, mut buffer_changes_rx) = watch::channel(());
for buffer in [&old_buffer, &new_buffer] {
cx.subscribe(buffer, move |this, _, event, _| match event {
language::BufferEvent::Edited { .. }
| language::BufferEvent::LanguageChanged(_)
| language::BufferEvent::Reparsed => {
this.buffer_changes_tx.send(()).ok();
}
_ => {}
})
.detach();
}
Self {
editor,
buffer_changes_tx,
old_buffer,
new_buffer,
_recalculate_diff_task: cx.spawn(async move |this, cx| {
while buffer_changes_rx.recv().await.is_ok() {
loop {
let mut timer = cx
.background_executor()
.timer(RECALCULATE_DIFF_DEBOUNCE)
.fuse();
let mut recv = pin!(buffer_changes_rx.recv().fuse());
select_biased! {
_ = timer => break,
_ = recv => continue,
}
}
log::trace!("start recalculating");
let (old_snapshot, new_snapshot) = this.update(cx, |this, cx| {
(
this.old_buffer.read(cx).snapshot(),
this.new_buffer.read(cx).snapshot(),
)
})?;
diff.update(cx, |diff, cx| {
diff.set_base_text(
Some(old_snapshot.text().as_str().into()),
old_snapshot.language().cloned(),
new_snapshot.text.clone(),
cx,
)
})
.await
.ok();
log::trace!("finish recalculating");
}
Ok(())
}),
}
}
}
#[ztracing::instrument(skip_all)]
async fn build_buffer_diff(
old_buffer: &Entity<Buffer>,
new_buffer: &Entity<Buffer>,
language_registry: Arc<LanguageRegistry>,
cx: &mut AsyncApp,
) -> Result<Entity<BufferDiff>> {
let old_buffer_snapshot = old_buffer.read_with(cx, |buffer, _| buffer.snapshot());
let new_buffer_snapshot = new_buffer.read_with(cx, |buffer, _| buffer.snapshot());
let diff = cx.new(|cx| BufferDiff::new(&new_buffer_snapshot.text, cx));
let update = diff
.update(cx, |diff, cx| {
diff.update_diff(
new_buffer_snapshot.text.clone(),
Some(old_buffer_snapshot.text().into()),
Some(true),
new_buffer_snapshot.language().cloned(),
cx,
)
})
.await;
diff.update(cx, |diff, cx| {
diff.language_changed(
new_buffer_snapshot.language().cloned(),
Some(language_registry),
cx,
);
diff.set_snapshot(update, &new_buffer_snapshot.text, cx)
})
.await;
Ok(diff)
}
impl EventEmitter<EditorEvent> for FileDiffView {}
impl Focusable for FileDiffView {
fn focus_handle(&self, cx: &App) -> FocusHandle {
self.editor.focus_handle(cx)
}
}
impl Item for FileDiffView {
type Event = EditorEvent;
fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
Some(Icon::new(IconName::Diff).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 title_text = |buffer: &Entity<Buffer>| {
buffer
.read(cx)
.file()
.and_then(|file| {
Some(
file.full_path(cx)
.file_name()?
.to_string_lossy()
.to_string(),
)
})
.unwrap_or_else(|| "untitled".into())
};
let old_filename = title_text(&self.old_buffer);
let new_filename = title_text(&self.new_buffer);
format!("{old_filename}{new_filename}").into()
}
fn tab_tooltip_text(&self, cx: &App) -> Option<ui::SharedString> {
let path = |buffer: &Entity<Buffer>| {
buffer
.read(cx)
.file()
.map(|file| file.full_path(cx).compact().to_string_lossy().into_owned())
.unwrap_or_else(|| "untitled".into())
};
let old_path = path(&self.old_buffer);
let new_path = path(&self.new_buffer);
Some(format!("{old_path}{new_path}").into())
}
fn to_item_events(event: &EditorEvent, f: &mut dyn FnMut(ItemEvent)) {
Editor::to_item_events(event, f)
}
fn telemetry_event_text(&self) -> Option<&'static str> {
Some("Diff View Opened")
}
fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.editor.deactivated(window, cx);
}
fn act_as_type<'a>(
&'a self,
type_id: TypeId,
self_handle: &'a Entity<Self>,
cx: &'a App,
) -> Option<gpui::AnyEntity> {
if type_id == TypeId::of::<Self>() {
Some(self_handle.clone().into())
} else {
self.editor.act_as_type(type_id, cx)
}
}
fn as_searchable(&self, _: &Entity<Self>, _: &App) -> Option<Box<dyn SearchableItemHandle>> {
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>,
) {
self.editor.update(cx, |editor, cx| {
editor.rhs_editor().update(cx, |editor, _| {
editor.set_nav_history(Some(nav_history));
})
});
}
fn navigate(
&mut self,
data: Arc<dyn Any + Send>,
window: &mut Window,
cx: &mut Context<Self>,
) -> bool {
self.editor.update(cx, |editor, cx| {
editor
.rhs_editor()
.update(cx, |editor, cx| editor.navigate(data, window, cx))
})
}
fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation {
ToolbarItemLocation::PrimaryLeft
}
fn breadcrumbs(&self, cx: &App) -> Option<(Vec<HighlightedText>, Option<Font>)> {
self.editor.breadcrumbs(cx)
}
fn added_to_workspace(
&mut self,
workspace: &mut Workspace,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.editor.update(cx, |editor, cx| {
editor.rhs_editor().update(cx, |editor, cx| {
editor.added_to_workspace(workspace, window, cx)
})
});
}
fn can_save(&self, cx: &App) -> bool {
self.editor.read(cx).rhs_editor().read(cx).can_save(cx)
}
fn save(
&mut self,
options: SaveOptions,
project: Entity<Project>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Task<Result<()>> {
self.editor.save(options, project, window, cx)
}
}
impl Render for FileDiffView {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
self.editor.clone()
}
}
#[cfg(test)]
mod tests {
use super::*;
use editor::test::editor_test_context::assert_state_with_diff;
use gpui::BorrowAppContext;
use gpui::TestAppContext;
use project::{FakeFs, Fs, Project};
use settings::{DiffViewStyle, SettingsStore};
use std::path::PathBuf;
use unindent::unindent;
use util::path;
use workspace::MultiWorkspace;
fn init_test(cx: &mut TestAppContext) {
cx.update(|cx| {
let settings_store = SettingsStore::test(cx);
cx.set_global(settings_store);
cx.update_global::<SettingsStore, _>(|store, cx| {
store.update_user_settings(cx, |settings| {
settings.editor.diff_view_style = Some(DiffViewStyle::Unified);
});
});
theme_settings::init(theme::LoadThemes::JustBase, cx);
});
}
#[gpui::test]
async fn test_diff_view(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/test"),
serde_json::json!({
"old_file.txt": "old line 1\nline 2\nold line 3\nline 4\n",
"new_file.txt": "new line 1\nline 2\nnew line 3\nline 4\n"
}),
)
.await;
let project = Project::test(fs.clone(), [path!("/test").as_ref()], cx).await;
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let diff_view = workspace
.update_in(cx, |workspace, window, cx| {
FileDiffView::open(
path!("/test/old_file.txt").into(),
path!("/test/new_file.txt").into(),
workspace.weak_handle(),
window,
cx,
)
})
.await
.unwrap();
// Verify initial diff
assert_state_with_diff(
&diff_view.read_with(cx, |diff_view, cx| {
diff_view.editor.read(cx).rhs_editor().clone()
}),
cx,
&unindent(
"
- old line 1
+ ˇnew line 1
line 2
- old line 3
+ new line 3
line 4
",
),
);
// Modify the new file on disk
fs.save(
path!("/test/new_file.txt").as_ref(),
&unindent(
"
new line 1
line 2
new line 3
line 4
new line 5
",
)
.into(),
Default::default(),
)
.await
.unwrap();
// The diff now reflects the changes to the new file
cx.executor().advance_clock(RECALCULATE_DIFF_DEBOUNCE);
assert_state_with_diff(
&diff_view.read_with(cx, |diff_view, cx| {
diff_view.editor.read(cx).rhs_editor().clone()
}),
cx,
&unindent(
"
- old line 1
+ ˇnew line 1
line 2
- old line 3
+ new line 3
line 4
+ new line 5
",
),
);
// Modify the old file on disk
fs.save(
path!("/test/old_file.txt").as_ref(),
&unindent(
"
new line 1
line 2
old line 3
line 4
",
)
.into(),
Default::default(),
)
.await
.unwrap();
// The diff now reflects the changes to the new file
cx.executor().advance_clock(RECALCULATE_DIFF_DEBOUNCE);
assert_state_with_diff(
&diff_view.read_with(cx, |diff_view, cx| {
diff_view.editor.read(cx).rhs_editor().clone()
}),
cx,
&unindent(
"
ˇnew line 1
line 2
- old line 3
+ new line 3
line 4
+ new line 5
",
),
);
diff_view.read_with(cx, |diff_view, cx| {
assert_eq!(
diff_view.tab_content_text(0, cx),
"old_file.txt ↔ new_file.txt"
);
assert_eq!(
diff_view.tab_tooltip_text(cx).unwrap(),
format!(
"{}{}",
path!("test/old_file.txt"),
path!("test/new_file.txt")
)
);
})
}
#[gpui::test]
async fn test_save_changes_in_diff_view(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/test"),
serde_json::json!({
"old_file.txt": "old line 1\nline 2\nold line 3\nline 4\n",
"new_file.txt": "new line 1\nline 2\nnew line 3\nline 4\n"
}),
)
.await;
let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let diff_view = workspace
.update_in(cx, |workspace, window, cx| {
FileDiffView::open(
PathBuf::from(path!("/test/old_file.txt")),
PathBuf::from(path!("/test/new_file.txt")),
workspace.weak_handle(),
window,
cx,
)
})
.await
.unwrap();
diff_view.update_in(cx, |diff_view, window, cx| {
diff_view.editor.update(cx, |splittable, cx| {
splittable.rhs_editor().update(cx, |editor, cx| {
editor.insert("modified ", window, cx);
});
});
});
diff_view.update_in(cx, |diff_view, _, cx| {
let buffer = diff_view.new_buffer.read(cx);
assert!(buffer.is_dirty(), "Buffer should be dirty after edits");
});
let save_task = diff_view.update_in(cx, |diff_view, window, cx| {
workspace::Item::save(
diff_view,
workspace::item::SaveOptions::default(),
project.clone(),
window,
cx,
)
});
save_task.await.expect("Save should succeed");
let saved_content = fs.load(path!("/test/new_file.txt").as_ref()).await.unwrap();
assert_eq!(
saved_content,
"modified new line 1\nline 2\nnew line 3\nline 4\n"
);
diff_view.update_in(cx, |diff_view, _, cx| {
let buffer = diff_view.new_buffer.read(cx);
assert!(!buffer.is_dirty(), "Buffer should not be dirty after save");
});
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,83 @@
use editor::{EditorSettings, ui_scrollbar_settings_from_raw};
use gpui::Pixels;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use settings::{RegisterSetting, Settings, StatusStyle};
use ui::{
px,
scrollbars::{ScrollbarVisibility, ShowScrollbar},
};
use workspace::dock::DockPosition;
#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct ScrollbarSettings {
pub show: Option<ShowScrollbar>,
}
#[derive(Debug, Clone, PartialEq, RegisterSetting)]
pub struct GitPanelSettings {
pub button: bool,
pub dock: DockPosition,
pub default_width: Pixels,
pub status_style: StatusStyle,
pub file_icons: bool,
pub folder_icons: bool,
pub scrollbar: ScrollbarSettings,
pub fallback_branch_name: String,
pub sort_by_path: bool,
pub collapse_untracked_diff: bool,
pub tree_view: bool,
pub diff_stats: bool,
pub show_count_badge: bool,
pub starts_open: bool,
pub commit_title_max_length: usize,
}
#[derive(Default)]
pub(crate) struct GitPanelScrollbarAccessor;
impl ScrollbarVisibility for GitPanelScrollbarAccessor {
fn visibility(&self, cx: &ui::App) -> ShowScrollbar {
// TODO: This PR should have defined Editor's `scrollbar.axis`
// as an Option<ScrollbarAxis>, not a ScrollbarAxes as it would allow you to
// `.unwrap_or(EditorSettings::get_global(cx).scrollbar.show)`.
//
// Once this is fixed we can extend the GitPanelSettings with a `scrollbar.axis`
// so we can show each axis based on the settings.
//
// We should fix this. PR: https://github.com/zed-industries/zed/pull/19495
GitPanelSettings::get_global(cx)
.scrollbar
.show
.unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show)
}
}
impl Settings for GitPanelSettings {
fn from_settings(content: &settings::SettingsContent) -> Self {
let git_panel = content.git_panel.clone().unwrap();
Self {
button: git_panel.button.unwrap(),
dock: git_panel.dock.unwrap().into(),
default_width: px(git_panel.default_width.unwrap()),
status_style: git_panel.status_style.unwrap(),
file_icons: git_panel.file_icons.unwrap(),
folder_icons: git_panel.folder_icons.unwrap(),
scrollbar: ScrollbarSettings {
show: git_panel
.scrollbar
.unwrap()
.show
.map(ui_scrollbar_settings_from_raw),
},
fallback_branch_name: git_panel.fallback_branch_name.unwrap(),
sort_by_path: git_panel.sort_by_path.unwrap(),
collapse_untracked_diff: git_panel.collapse_untracked_diff.unwrap(),
tree_view: git_panel.tree_view.unwrap(),
diff_stats: git_panel.diff_stats.unwrap(),
show_count_badge: git_panel.show_count_badge.unwrap(),
starts_open: git_panel.starts_open.unwrap(),
commit_title_max_length: git_panel.commit_title_max_length.unwrap(),
}
}
}

View File

@@ -0,0 +1,498 @@
use std::fmt::Display;
use gpui::{
App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement,
KeyContext, ModifiersChangedEvent, MouseButton, ParentElement, Rems, Render, Styled,
Subscription, WeakEntity, Window, actions, rems,
};
use project::git_store::Repository;
use ui::{
FluentBuilder, ToggleButtonGroup, ToggleButtonGroupStyle, ToggleButtonSimple, Tooltip,
prelude::*,
};
use workspace::{ModalView, Workspace, pane};
use crate::branch_picker::{self, BranchList, DeleteBranch, FilterRemotes, ForceDeleteBranch};
use crate::stash_picker::{self, DropStashItem, ShowStashItem, StashList};
actions!(git_picker, [ActivateBranchesTab, ActivateStashTab,]);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum GitPickerTab {
Branches,
Stash,
}
impl Display for GitPickerTab {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let label = match self {
GitPickerTab::Branches => "Branches",
GitPickerTab::Stash => "Stash",
};
write!(f, "{}", label)
}
}
pub struct GitPicker {
tab: GitPickerTab,
workspace: WeakEntity<Workspace>,
repository: Option<Entity<Repository>>,
width: Rems,
branch_list: Option<Entity<BranchList>>,
stash_list: Option<Entity<StashList>>,
_subscriptions: Vec<Subscription>,
popover_style: bool,
}
impl GitPicker {
pub fn new(
workspace: WeakEntity<Workspace>,
repository: Option<Entity<Repository>>,
initial_tab: GitPickerTab,
width: Rems,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
Self::new_internal(workspace, repository, initial_tab, width, false, window, cx)
}
fn new_internal(
workspace: WeakEntity<Workspace>,
repository: Option<Entity<Repository>>,
initial_tab: GitPickerTab,
width: Rems,
popover_style: bool,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let mut this = Self {
tab: initial_tab,
workspace,
repository,
width,
branch_list: None,
stash_list: None,
_subscriptions: Vec::new(),
popover_style,
};
this.ensure_active_picker(window, cx);
this
}
fn ensure_active_picker(&mut self, window: &mut Window, cx: &mut Context<Self>) {
match self.tab {
GitPickerTab::Branches => {
self.ensure_branch_list(window, cx);
}
GitPickerTab::Stash => {
self.ensure_stash_list(window, cx);
}
}
}
fn ensure_branch_list(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) -> Entity<BranchList> {
if self.branch_list.is_none() {
let show_footer = !self.popover_style;
let branch_list = cx.new(|cx| {
branch_picker::create_embedded(
self.workspace.clone(),
self.repository.clone(),
self.width,
show_footer,
window,
cx,
)
});
let subscription = cx.subscribe(&branch_list, |this, _, _: &DismissEvent, cx| {
if this.tab == GitPickerTab::Branches {
cx.emit(DismissEvent);
}
});
self._subscriptions.push(subscription);
self.branch_list = Some(branch_list);
}
self.branch_list.clone().unwrap()
}
fn ensure_stash_list(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) -> Entity<StashList> {
if self.stash_list.is_none() {
let show_footer = !self.popover_style;
let stash_list = cx.new(|cx| {
stash_picker::create_embedded(
self.repository.clone(),
self.workspace.clone(),
self.width,
show_footer,
window,
cx,
)
});
let subscription = cx.subscribe(&stash_list, |this, _, _: &DismissEvent, cx| {
if this.tab == GitPickerTab::Stash {
cx.emit(DismissEvent);
}
});
self._subscriptions.push(subscription);
self.stash_list = Some(stash_list);
}
self.stash_list.clone().unwrap()
}
fn activate_next_tab(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.tab = match self.tab {
GitPickerTab::Branches => GitPickerTab::Stash,
GitPickerTab::Stash => GitPickerTab::Branches,
};
self.ensure_active_picker(window, cx);
self.focus_active_picker(window, cx);
cx.notify();
}
fn activate_previous_tab(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.tab = match self.tab {
GitPickerTab::Branches => GitPickerTab::Stash,
GitPickerTab::Stash => GitPickerTab::Branches,
};
self.ensure_active_picker(window, cx);
self.focus_active_picker(window, cx);
cx.notify();
}
fn focus_active_picker(&self, window: &mut Window, cx: &mut App) {
match self.tab {
GitPickerTab::Branches => {
if let Some(branch_list) = &self.branch_list {
branch_list.focus_handle(cx).focus(window, cx);
}
}
GitPickerTab::Stash => {
if let Some(stash_list) = &self.stash_list {
stash_list.focus_handle(cx).focus(window, cx);
}
}
}
}
fn render_tab_bar(&self, cx: &mut Context<Self>) -> impl IntoElement {
let focus_handle = self.focus_handle(cx);
let branches_focus_handle = focus_handle.clone();
let stash_focus_handle = focus_handle;
h_flex().p_2().pb_0p5().w_full().child(
ToggleButtonGroup::single_row(
"git-picker-tabs",
[
ToggleButtonSimple::new(
GitPickerTab::Branches.to_string(),
cx.listener(|this, _, window, cx| {
this.tab = GitPickerTab::Branches;
this.ensure_active_picker(window, cx);
this.focus_active_picker(window, cx);
cx.notify();
}),
)
.tooltip(move |_, cx| {
Tooltip::for_action_in(
"Toggle Branch Picker",
&ActivateBranchesTab,
&branches_focus_handle,
cx,
)
}),
ToggleButtonSimple::new(
GitPickerTab::Stash.to_string(),
cx.listener(|this, _, window, cx| {
this.tab = GitPickerTab::Stash;
this.ensure_active_picker(window, cx);
this.focus_active_picker(window, cx);
cx.notify();
}),
)
.tooltip(move |_, cx| {
Tooltip::for_action_in(
"Toggle Stash Picker",
&ActivateStashTab,
&stash_focus_handle,
cx,
)
}),
],
)
.label_size(LabelSize::Default)
.style(ToggleButtonGroupStyle::Outlined)
.auto_width()
.selected_index(match self.tab {
GitPickerTab::Branches => 0,
GitPickerTab::Stash => 1,
}),
)
}
fn render_active_picker(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) -> impl IntoElement {
match self.tab {
GitPickerTab::Branches => {
let branch_list = self.ensure_branch_list(window, cx);
branch_list.into_any_element()
}
GitPickerTab::Stash => {
let stash_list = self.ensure_stash_list(window, cx);
stash_list.into_any_element()
}
}
}
fn handle_modifiers_changed(
&mut self,
ev: &ModifiersChangedEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
match self.tab {
GitPickerTab::Branches => {
if let Some(branch_list) = &self.branch_list {
branch_list.update(cx, |list, cx| {
list.handle_modifiers_changed(ev, window, cx);
});
}
}
GitPickerTab::Stash => {
if let Some(stash_list) = &self.stash_list {
stash_list.update(cx, |list, cx| {
list.handle_modifiers_changed(ev, window, cx);
});
}
}
}
}
fn handle_delete_branch(
&mut self,
_: &DeleteBranch,
window: &mut Window,
cx: &mut Context<Self>,
) {
if let Some(branch_list) = &self.branch_list {
branch_list.update(cx, |list, cx| {
list.handle_delete(&DeleteBranch, window, cx);
});
}
}
fn handle_force_delete_branch(
&mut self,
_: &ForceDeleteBranch,
window: &mut Window,
cx: &mut Context<Self>,
) {
if let Some(branch_list) = &self.branch_list {
branch_list.update(cx, |list, cx| {
list.handle_force_delete(&ForceDeleteBranch, window, cx);
});
}
}
fn handle_filter_remotes(
&mut self,
_: &FilterRemotes,
window: &mut Window,
cx: &mut Context<Self>,
) {
if let Some(branch_list) = &self.branch_list {
branch_list.update(cx, |list, cx| {
list.handle_filter(&FilterRemotes, window, cx);
});
}
}
fn handle_drop_stash(
&mut self,
_: &DropStashItem,
window: &mut Window,
cx: &mut Context<Self>,
) {
if let Some(stash_list) = &self.stash_list {
stash_list.update(cx, |list, cx| {
list.handle_drop_stash(&DropStashItem, window, cx);
});
}
}
fn handle_show_stash(
&mut self,
_: &ShowStashItem,
window: &mut Window,
cx: &mut Context<Self>,
) {
if let Some(stash_list) = &self.stash_list {
stash_list.update(cx, |list, cx| {
list.handle_show_stash(&ShowStashItem, window, cx);
});
}
}
}
impl ModalView for GitPicker {}
impl EventEmitter<DismissEvent> for GitPicker {}
impl Focusable for GitPicker {
fn focus_handle(&self, cx: &App) -> FocusHandle {
match self.tab {
GitPickerTab::Branches => {
if let Some(branch_list) = &self.branch_list {
return branch_list.focus_handle(cx);
}
}
GitPickerTab::Stash => {
if let Some(stash_list) = &self.stash_list {
return stash_list.focus_handle(cx);
}
}
}
cx.focus_handle()
}
}
impl Render for GitPicker {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.occlude()
.w(self.width)
.elevation_3(cx)
.overflow_hidden()
.when(self.popover_style, |el| {
el.on_mouse_down_out(cx.listener(|_, _, _, cx| {
cx.emit(DismissEvent);
}))
})
.key_context({
let mut key_context = KeyContext::new_with_defaults();
key_context.add("Pane");
key_context.add("GitPicker");
match self.tab {
GitPickerTab::Branches => key_context.add("GitBranchSelector"),
GitPickerTab::Stash => key_context.add("StashList"),
}
key_context
})
.on_mouse_down(MouseButton::Left, |_, _, cx| {
cx.stop_propagation();
})
.on_action(cx.listener(|_, _: &menu::Cancel, _, cx| {
cx.emit(DismissEvent);
}))
.on_action(cx.listener(|this, _: &pane::ActivateNextItem, window, cx| {
this.activate_next_tab(window, cx);
}))
.on_action(
cx.listener(|this, _: &pane::ActivatePreviousItem, window, cx| {
this.activate_previous_tab(window, cx);
}),
)
.on_action(cx.listener(|this, _: &ActivateBranchesTab, window, cx| {
this.tab = GitPickerTab::Branches;
this.ensure_active_picker(window, cx);
this.focus_active_picker(window, cx);
cx.notify();
}))
.on_action(cx.listener(|this, _: &ActivateStashTab, window, cx| {
this.tab = GitPickerTab::Stash;
this.ensure_active_picker(window, cx);
this.focus_active_picker(window, cx);
cx.notify();
}))
.on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
.when(self.tab == GitPickerTab::Branches, |el| {
el.on_action(cx.listener(Self::handle_delete_branch))
.on_action(cx.listener(Self::handle_force_delete_branch))
.on_action(cx.listener(Self::handle_filter_remotes))
})
.when(self.tab == GitPickerTab::Stash, |el| {
el.on_action(cx.listener(Self::handle_drop_stash))
.on_action(cx.listener(Self::handle_show_stash))
})
.child(self.render_tab_bar(cx))
.child(self.render_active_picker(window, cx))
}
}
pub fn open_branches(
workspace: &mut Workspace,
_: &zed_actions::git::Branch,
window: &mut Window,
cx: &mut Context<Workspace>,
) {
open_with_tab(workspace, GitPickerTab::Branches, window, cx);
}
pub fn open_stash(
workspace: &mut Workspace,
_: &zed_actions::git::ViewStash,
window: &mut Window,
cx: &mut Context<Workspace>,
) {
open_with_tab(workspace, GitPickerTab::Stash, window, cx);
}
fn open_with_tab(
workspace: &mut Workspace,
tab: GitPickerTab,
window: &mut Window,
cx: &mut Context<Workspace>,
) {
let workspace_handle = workspace.weak_handle();
let repository = workspace.project().read(cx).active_repository(cx);
workspace.toggle_modal(window, cx, |window, cx| {
GitPicker::new(workspace_handle, repository, tab, rems(34.), window, cx)
})
}
pub fn popover(
workspace: WeakEntity<Workspace>,
repository: Option<Entity<Repository>>,
initial_tab: GitPickerTab,
width: Rems,
window: &mut Window,
cx: &mut App,
) -> Entity<GitPicker> {
cx.new(|cx| {
let picker =
GitPicker::new_internal(workspace, repository, initial_tab, width, true, window, cx);
picker.focus_handle(cx).focus(window, cx);
picker
})
}
pub fn register(workspace: &mut Workspace) {
workspace.register_action(|workspace, _: &zed_actions::git::Branch, window, cx| {
open_with_tab(workspace, GitPickerTab::Branches, window, cx);
});
workspace.register_action(|workspace, _: &zed_actions::git::Switch, window, cx| {
open_with_tab(workspace, GitPickerTab::Branches, window, cx);
});
workspace.register_action(
|workspace, _: &zed_actions::git::CheckoutBranch, window, cx| {
open_with_tab(workspace, GitPickerTab::Branches, window, cx);
},
);
workspace.register_action(|workspace, _: &zed_actions::git::ViewStash, window, cx| {
open_with_tab(workspace, GitPickerTab::Stash, window, cx);
});
}

1246
crates/git_ui/src/git_ui.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,376 @@
use anyhow::Result;
use buffer_diff::BufferDiff;
use editor::{Editor, EditorEvent, MultiBuffer, multibuffer_context_lines};
use gpui::{
AnyElement, App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, FocusHandle,
Focusable, Font, IntoElement, Render, SharedString, Task, Window,
};
use language::{Buffer, Capability, HighlightedText, OffsetRangeExt};
use multi_buffer::PathKey;
use project::Project;
use std::{
any::{Any, TypeId},
path::{Path, PathBuf},
sync::Arc,
};
use ui::{Color, Icon, IconName, Label, LabelCommon as _};
use util::paths::PathStyle;
use util::rel_path::RelPath;
use workspace::{
Item, ItemHandle as _, ItemNavHistory, ToolbarItemLocation, Workspace,
item::{ItemEvent, SaveOptions, TabContentParams},
searchable::SearchableItemHandle,
};
pub struct MultiDiffView {
editor: Entity<Editor>,
file_count: usize,
}
struct Entry {
index: usize,
new_path: PathBuf,
new_buffer: Entity<Buffer>,
diff: Entity<BufferDiff>,
}
async fn load_entries(
diff_pairs: Vec<[String; 2]>,
project: &Entity<Project>,
cx: &mut AsyncApp,
) -> Result<(Vec<Entry>, Option<PathBuf>)> {
let mut entries = Vec::with_capacity(diff_pairs.len());
let mut all_paths = Vec::with_capacity(diff_pairs.len());
for (ix, pair) in diff_pairs.into_iter().enumerate() {
let old_path = PathBuf::from(&pair[0]);
let new_path = PathBuf::from(&pair[1]);
let old_buffer = project
.update(cx, |project, cx| project.open_local_buffer(&old_path, cx))
.await?;
let new_buffer = project
.update(cx, |project, cx| project.open_local_buffer(&new_path, cx))
.await?;
let diff = build_buffer_diff(&old_buffer, &new_buffer, cx).await?;
all_paths.push(new_path.clone());
entries.push(Entry {
index: ix,
new_path,
new_buffer: new_buffer.clone(),
diff,
});
}
let common_root = common_prefix(&all_paths);
Ok((entries, common_root))
}
fn register_entry(
multibuffer: &Entity<MultiBuffer>,
entry: Entry,
common_root: &Option<PathBuf>,
context_lines: u32,
cx: &mut Context<Workspace>,
) {
let snapshot = entry.new_buffer.read(cx).snapshot();
let diff_snapshot = entry.diff.read(cx).snapshot(cx);
let ranges: Vec<std::ops::Range<language::Point>> = diff_snapshot
.hunks(&snapshot)
.map(|hunk| hunk.buffer_range.to_point(&snapshot))
.collect();
let display_rel = common_root
.as_ref()
.and_then(|root| entry.new_path.strip_prefix(root).ok())
.map(|rel| {
RelPath::new(rel, PathStyle::local())
.map(|r| r.into_owned().into())
.unwrap_or_else(|_| {
RelPath::new(Path::new("untitled"), PathStyle::Posix)
.unwrap()
.into_owned()
.into()
})
})
.unwrap_or_else(|| {
entry
.new_path
.file_name()
.and_then(|n| n.to_str())
.and_then(|s| RelPath::new(Path::new(s), PathStyle::Posix).ok())
.map(|r| r.into_owned().into())
.unwrap_or_else(|| {
RelPath::new(Path::new("untitled"), PathStyle::Posix)
.unwrap()
.into_owned()
.into()
})
});
let path_key = PathKey::with_sort_prefix(entry.index as u64, display_rel);
multibuffer.update(cx, |multibuffer, cx| {
multibuffer.set_excerpts_for_path(
path_key,
entry.new_buffer.clone(),
ranges,
context_lines,
cx,
);
multibuffer.add_diff(entry.diff.clone(), cx);
});
}
fn common_prefix(paths: &[PathBuf]) -> Option<PathBuf> {
let mut iter = paths.iter();
let mut prefix = iter.next()?.clone();
for path in iter {
while !path.starts_with(&prefix) {
if !prefix.pop() {
return Some(PathBuf::new());
}
}
}
Some(prefix)
}
async fn build_buffer_diff(
old_buffer: &Entity<Buffer>,
new_buffer: &Entity<Buffer>,
cx: &mut AsyncApp,
) -> Result<Entity<BufferDiff>> {
let old_buffer_snapshot = old_buffer.read_with(cx, |buffer, _| buffer.snapshot());
let new_buffer_snapshot = new_buffer.read_with(cx, |buffer, _| buffer.snapshot());
let diff = cx.new(|cx| BufferDiff::new(&new_buffer_snapshot.text, cx));
let update = diff
.update(cx, |diff, cx| {
diff.update_diff(
new_buffer_snapshot.text.clone(),
Some(old_buffer_snapshot.text().into()),
Some(true),
new_buffer_snapshot.language().cloned(),
cx,
)
})
.await;
diff.update(cx, |diff, cx| {
diff.set_snapshot(update, &new_buffer_snapshot.text, cx)
})
.await;
Ok(diff)
}
impl MultiDiffView {
pub fn open(
diff_pairs: Vec<[String; 2]>,
workspace: &Workspace,
window: &mut Window,
cx: &mut App,
) -> Task<Result<Entity<Self>>> {
let project = workspace.project().clone();
let workspace = workspace.weak_handle();
let context_lines = multibuffer_context_lines(cx);
window.spawn(cx, async move |cx| {
let (entries, common_root) = load_entries(diff_pairs, &project, cx).await?;
workspace.update_in(cx, |workspace, window, cx| {
let multibuffer = cx.new(|cx| {
let mut multibuffer = MultiBuffer::new(Capability::ReadWrite);
multibuffer.set_all_diff_hunks_expanded(cx);
multibuffer
});
let file_count = entries.len();
for entry in entries {
register_entry(&multibuffer, entry, &common_root, context_lines, cx);
}
let diff_view = cx.new(|cx| {
Self::new(multibuffer.clone(), project.clone(), file_count, window, cx)
});
let pane = workspace.active_pane();
pane.update(cx, |pane, cx| {
pane.add_item(Box::new(diff_view.clone()), true, true, None, window, cx);
});
// Hide the left dock (file explorer) for a cleaner diff view
workspace.left_dock().update(cx, |dock, cx| {
dock.set_open(false, window, cx);
});
diff_view
})
})
}
fn new(
multibuffer: Entity<MultiBuffer>,
project: Entity<Project>,
file_count: usize,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let editor = cx.new(|cx| {
let mut editor =
Editor::for_multibuffer(multibuffer, Some(project.clone()), window, cx);
editor.start_temporary_diff_override();
editor.disable_diagnostics(cx);
editor.set_expand_all_diff_hunks(cx);
editor.set_render_diff_hunk_controls(
Arc::new(|_, _, _, _, _, _, _, _| gpui::Empty.into_any_element()),
cx,
);
editor
});
Self { editor, file_count }
}
fn title(&self) -> SharedString {
let suffix = if self.file_count == 1 {
"1 file".to_string()
} else {
format!("{} files", self.file_count)
};
format!("Diff ({suffix})").into()
}
}
impl EventEmitter<EditorEvent> for MultiDiffView {}
impl Focusable for MultiDiffView {
fn focus_handle(&self, cx: &App) -> FocusHandle {
self.editor.focus_handle(cx)
}
}
impl Item for MultiDiffView {
type Event = EditorEvent;
fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
Some(Icon::new(IconName::Diff).color(Color::Muted))
}
fn tab_content(&self, params: TabContentParams, _window: &Window, _cx: &App) -> AnyElement {
Label::new(self.title())
.color(if params.selected {
Color::Default
} else {
Color::Muted
})
.into_any_element()
}
fn tab_tooltip_text(&self, _cx: &App) -> Option<ui::SharedString> {
Some(self.title())
}
fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
self.title()
}
fn to_item_events(event: &EditorEvent, f: &mut dyn FnMut(ItemEvent)) {
Editor::to_item_events(event, f)
}
fn telemetry_event_text(&self) -> Option<&'static str> {
Some("Diff View Opened")
}
fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.editor
.update(cx, |editor, cx| editor.deactivated(window, cx));
}
fn act_as_type<'a>(
&'a self,
type_id: TypeId,
self_handle: &'a Entity<Self>,
_: &'a App,
) -> Option<gpui::AnyEntity> {
if type_id == TypeId::of::<Self>() {
Some(self_handle.clone().into())
} else if type_id == TypeId::of::<Editor>() {
Some(self.editor.clone().into())
} else {
None
}
}
fn as_searchable(&self, _: &Entity<Self>, _: &App) -> Option<Box<dyn SearchableItemHandle>> {
Some(Box::new(self.editor.clone()))
}
fn set_nav_history(
&mut self,
nav_history: ItemNavHistory,
_: &mut Window,
cx: &mut Context<Self>,
) {
self.editor.update(cx, |editor, _| {
editor.set_nav_history(Some(nav_history));
});
}
fn navigate(
&mut self,
data: Arc<dyn Any + Send>,
window: &mut Window,
cx: &mut Context<Self>,
) -> bool {
self.editor
.update(cx, |editor, cx| editor.navigate(data, window, cx))
}
fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation {
ToolbarItemLocation::PrimaryLeft
}
fn breadcrumbs(&self, cx: &App) -> Option<(Vec<HighlightedText>, Option<Font>)> {
self.editor.breadcrumbs(cx)
}
fn added_to_workspace(
&mut self,
workspace: &mut Workspace,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.editor.update(cx, |editor, cx| {
editor.added_to_workspace(workspace, window, cx)
});
}
fn can_save(&self, cx: &App) -> bool {
self.editor.read(cx).can_save(cx)
}
fn save(
&mut self,
options: SaveOptions,
project: Entity<Project>,
window: &mut Window,
cx: &mut Context<Self>,
) -> gpui::Task<Result<()>> {
self.editor
.update(cx, |editor, cx| editor.save(options, project, window, cx))
}
}
impl Render for MultiDiffView {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
self.editor.clone()
}
}

View File

@@ -0,0 +1,239 @@
use futures::channel::oneshot;
use fuzzy::{StringMatch, StringMatchCandidate};
use core::cmp;
use gpui::{
App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement,
IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Task, WeakEntity,
Window, rems,
};
use picker::{Picker, PickerDelegate};
use std::sync::Arc;
use ui::{HighlightedLabel, ListItem, ListItemSpacing, prelude::*};
use util::ResultExt;
use workspace::{ModalView, Workspace};
pub struct PickerPrompt {
pub picker: Entity<Picker<PickerPromptDelegate>>,
rem_width: f32,
_subscription: Subscription,
}
pub fn prompt(
prompt: &str,
options: Vec<SharedString>,
workspace: WeakEntity<Workspace>,
window: &mut Window,
cx: &mut App,
) -> Task<Option<usize>> {
if options.is_empty() {
return Task::ready(None);
} else if options.len() == 1 {
return Task::ready(Some(0));
}
let prompt = prompt.to_string().into();
window.spawn(cx, async move |cx| {
// Modal branch picker has a longer trailoff than a popover one.
let (tx, rx) = oneshot::channel();
let delegate = PickerPromptDelegate::new(prompt, options, tx, 70);
workspace
.update_in(cx, |workspace, window, cx| {
workspace.toggle_modal(window, cx, |window, cx| {
PickerPrompt::new(delegate, 34., window, cx)
})
})
.ok();
(rx.await).ok()
})
}
impl PickerPrompt {
fn new(
delegate: PickerPromptDelegate,
rem_width: f32,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx));
let _subscription = cx.subscribe(&picker, |_, _, _, cx| cx.emit(DismissEvent));
Self {
picker,
rem_width,
_subscription,
}
}
}
impl ModalView for PickerPrompt {}
impl EventEmitter<DismissEvent> for PickerPrompt {}
impl Focusable for PickerPrompt {
fn focus_handle(&self, cx: &App) -> FocusHandle {
self.picker.focus_handle(cx)
}
}
impl Render for PickerPrompt {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.w(rems(self.rem_width))
.child(self.picker.clone())
.on_mouse_down_out(cx.listener(|this, _, window, cx| {
this.picker.update(cx, |this, cx| {
this.cancel(&Default::default(), window, cx);
})
}))
}
}
pub struct PickerPromptDelegate {
prompt: Arc<str>,
matches: Vec<StringMatch>,
all_options: Vec<SharedString>,
selected_index: usize,
max_match_length: usize,
tx: Option<oneshot::Sender<usize>>,
}
impl PickerPromptDelegate {
pub fn new(
prompt: Arc<str>,
options: Vec<SharedString>,
tx: oneshot::Sender<usize>,
max_chars: usize,
) -> Self {
Self {
prompt,
all_options: options,
matches: vec![],
selected_index: 0,
max_match_length: max_chars,
tx: Some(tx),
}
}
}
impl PickerDelegate for PickerPromptDelegate {
type ListItem = ListItem;
fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
self.prompt.clone()
}
fn match_count(&self) -> usize {
self.matches.len()
}
fn selected_index(&self) -> usize {
self.selected_index
}
fn set_selected_index(
&mut self,
ix: usize,
_window: &mut Window,
_: &mut Context<Picker<Self>>,
) {
self.selected_index = ix;
}
fn update_matches(
&mut self,
query: String,
window: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Task<()> {
cx.spawn_in(window, async move |picker, cx| {
let candidates = picker.read_with(cx, |picker, _| {
picker
.delegate
.all_options
.iter()
.enumerate()
.map(|(ix, option)| StringMatchCandidate::new(ix, option))
.collect::<Vec<StringMatchCandidate>>()
});
let Some(candidates) = candidates.log_err() else {
return;
};
let matches: Vec<StringMatch> = if query.is_empty() {
candidates
.into_iter()
.enumerate()
.map(|(index, candidate)| StringMatch {
candidate_id: index,
string: candidate.string,
positions: Vec::new(),
score: 0.0,
})
.collect()
} else {
fuzzy::match_strings(
&candidates,
&query,
true,
true,
10000,
&Default::default(),
cx.background_executor().clone(),
)
.await
};
picker
.update(cx, |picker, _| {
let delegate = &mut picker.delegate;
delegate.matches = matches;
if delegate.matches.is_empty() {
delegate.selected_index = 0;
} else {
delegate.selected_index =
cmp::min(delegate.selected_index, delegate.matches.len() - 1);
}
})
.log_err();
})
}
fn confirm(&mut self, _: bool, _window: &mut Window, cx: &mut Context<Picker<Self>>) {
let Some(option) = self.matches.get(self.selected_index()) else {
return;
};
self.tx.take().map(|tx| tx.send(option.candidate_id));
cx.emit(DismissEvent);
}
fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<Self>>) {
cx.emit(DismissEvent);
}
fn render_match(
&self,
ix: usize,
selected: bool,
_window: &mut Window,
_cx: &mut Context<Picker<Self>>,
) -> Option<Self::ListItem> {
let hit = &self.matches.get(ix)?;
let shortened_option = util::truncate_and_trailoff(&hit.string, self.max_match_length);
Some(
ListItem::new(format!("picker-prompt-menu-{ix}"))
.inset(true)
.spacing(ListItemSpacing::Sparse)
.toggle_state(selected)
.map(|el| {
let highlights: Vec<_> = hit
.positions
.iter()
.filter(|&&index| index < self.max_match_length)
.copied()
.collect();
el.child(HighlightedLabel::new(shortened_option, highlights))
}),
)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,309 @@
use anyhow::Context as _;
use git::repository::{Remote, RemoteCommandOutput};
use linkify::{LinkFinder, LinkKind};
use ui::SharedString;
use util::ResultExt as _;
#[derive(Clone)]
pub enum RemoteAction {
Fetch(Option<Remote>),
Pull(Remote),
Push(SharedString, Remote),
}
impl RemoteAction {
pub fn name(&self) -> &'static str {
match self {
RemoteAction::Fetch(_) => "fetch",
RemoteAction::Pull(_) => "pull",
RemoteAction::Push(_, _) => "push",
}
}
}
pub enum SuccessStyle {
Toast,
ToastWithLog { output: RemoteCommandOutput },
PushPrLink { text: String, link: String },
}
pub struct SuccessMessage {
pub message: String,
pub style: SuccessStyle,
}
pub fn format_output(action: &RemoteAction, output: RemoteCommandOutput) -> SuccessMessage {
match action {
RemoteAction::Fetch(remote) => {
if output.stderr.is_empty() {
SuccessMessage {
message: "Fetch: Already up to date".into(),
style: SuccessStyle::Toast,
}
} else {
let message = match remote {
Some(remote) => format!("Synchronized with {}", remote.name),
None => "Synchronized with remotes".into(),
};
SuccessMessage {
message,
style: SuccessStyle::ToastWithLog { output },
}
}
}
RemoteAction::Pull(remote_ref) => {
let get_changes = |output: &RemoteCommandOutput| -> anyhow::Result<u32> {
let last_line = output
.stdout
.lines()
.last()
.context("Failed to get last line of output")?
.trim();
let files_changed = last_line
.split_whitespace()
.next()
.context("Failed to get first word of last line")?
.parse()?;
Ok(files_changed)
};
if output.stdout.ends_with("Already up to date.\n") {
SuccessMessage {
message: "Pull: Already up to date".into(),
style: SuccessStyle::Toast,
}
} else if output.stdout.starts_with("Updating") {
let files_changed = get_changes(&output).log_err();
let message = if let Some(files_changed) = files_changed {
format!(
"Received {} file change{} from {}",
files_changed,
if files_changed == 1 { "" } else { "s" },
remote_ref.name
)
} else {
format!("Fast forwarded from {}", remote_ref.name)
};
SuccessMessage {
message,
style: SuccessStyle::ToastWithLog { output },
}
} else if output.stdout.starts_with("Merge") {
let files_changed = get_changes(&output).log_err();
let message = if let Some(files_changed) = files_changed {
format!(
"Merged {} file change{} from {}",
files_changed,
if files_changed == 1 { "" } else { "s" },
remote_ref.name
)
} else {
format!("Merged from {}", remote_ref.name)
};
SuccessMessage {
message,
style: SuccessStyle::ToastWithLog { output },
}
} else if output.stdout.contains("Successfully rebased") {
SuccessMessage {
message: format!("Successfully rebased from {}", remote_ref.name),
style: SuccessStyle::ToastWithLog { output },
}
} else {
SuccessMessage {
message: format!("Successfully pulled from {}", remote_ref.name),
style: SuccessStyle::ToastWithLog { output },
}
}
}
RemoteAction::Push(branch_name, remote_ref) => {
let message = if output.stderr.ends_with("Everything up-to-date\n") {
"Push: Everything is up-to-date".to_string()
} else {
format!("Pushed {} to {}", branch_name, remote_ref.name)
};
let style = if output.stderr.ends_with("Everything up-to-date\n") {
Some(SuccessStyle::Toast)
} else if output.stderr.contains("\nremote: ") {
let pr_hints = [
("Create a pull request", "Create Pull Request"), // GitHub
("Create pull request", "Create Pull Request"), // Bitbucket
("create a merge request", "Create Merge Request"), // GitLab
("View merge request", "View Merge Request"), // GitLab
];
pr_hints
.iter()
.find(|(indicator, _)| output.stderr.contains(indicator))
.and_then(|(_, mapped)| {
let finder = LinkFinder::new();
output
.stderr
.lines()
.filter(|line| line.trim_start().starts_with("remote:"))
.find_map(|line| {
finder
.links(line)
.find(|link| *link.kind() == LinkKind::Url)
.map(|link| SuccessStyle::PushPrLink {
text: mapped.to_string(),
link: link.as_str().to_string(),
})
})
})
} else {
None
};
SuccessMessage {
message,
style: style.unwrap_or(SuccessStyle::ToastWithLog { output }),
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use indoc::indoc;
#[test]
fn test_push_new_branch_pull_request() {
let action = RemoteAction::Push(
SharedString::new_static("test_branch"),
Remote {
name: SharedString::new_static("test_remote"),
},
);
let output = RemoteCommandOutput {
stdout: String::new(),
stderr: indoc! { "
Total 0 (delta 0), reused 0 (delta 0), pack-reused 0 (from 0)
remote:
remote: Create a pull request for 'test' on GitHub by visiting:
remote: https://example.com/test/test/pull/new/test
remote:
To example.com:test/test.git
* [new branch] test -> test
"}
.to_string(),
};
let msg = format_output(&action, output);
if let SuccessStyle::PushPrLink { text: hint, link } = &msg.style {
assert_eq!(hint, "Create Pull Request");
assert_eq!(link, "https://example.com/test/test/pull/new/test");
} else {
panic!("Expected PushPrLink variant");
}
}
#[test]
fn test_push_new_branch_merge_request() {
let action = RemoteAction::Push(
SharedString::new_static("test_branch"),
Remote {
name: SharedString::new_static("test_remote"),
},
);
let output = RemoteCommandOutput {
stdout: String::new(),
stderr: indoc! {"
Total 0 (delta 0), reused 0 (delta 0), pack-reused 0 (from 0)
remote:
remote: To create a merge request for test, visit:
remote: https://example.com/test/test/-/merge_requests/new?merge_request%5Bsource_branch%5D=test
remote:
To example.com:test/test.git
* [new branch] test -> test
"}
.to_string()
};
let msg = format_output(&action, output);
if let SuccessStyle::PushPrLink { text, link } = &msg.style {
assert_eq!(text, "Create Merge Request");
assert_eq!(
link,
"https://example.com/test/test/-/merge_requests/new?merge_request%5Bsource_branch%5D=test"
);
} else {
panic!("Expected PushPrLink variant");
}
}
#[test]
fn test_push_branch_existing_merge_request() {
let action = RemoteAction::Push(
SharedString::new_static("test_branch"),
Remote {
name: SharedString::new_static("test_remote"),
},
);
let output = RemoteCommandOutput {
stdout: String::new(),
// Simulate an extraneous link that should not be found in top 3 lines
stderr: indoc! {"
** WARNING: connection is not using a post-quantum key exchange algorithm.
** This session may be vulnerable to \"store now, decrypt later\" attacks.
** The server may need to be upgraded. See https://openssh.com/pq.html
Total 0 (delta 0), reused 0 (delta 0), pack-reused 0 (from 0)
remote:
remote: View merge request for test:
remote: https://example.com/test/test/-/merge_requests/99999
remote:
To example.com:test/test.git
+ 80bd3c83be...e03d499d2e test -> test
"}
.to_string(),
};
let msg = format_output(&action, output);
if let SuccessStyle::PushPrLink { text, link } = &msg.style {
assert_eq!(text, "View Merge Request");
assert_eq!(link, "https://example.com/test/test/-/merge_requests/99999");
} else {
panic!("Expected PushPrLink variant");
}
}
#[test]
fn test_push_new_branch_no_link() {
let action = RemoteAction::Push(
SharedString::new_static("test_branch"),
Remote {
name: SharedString::new_static("test_remote"),
},
);
let output = RemoteCommandOutput {
stdout: String::new(),
stderr: indoc! { "
To http://example.com/test/test.git
* [new branch] test -> test
",
}
.to_string(),
};
let msg = format_output(&action, output);
if let SuccessStyle::ToastWithLog { output } = &msg.style {
assert_eq!(
output.stderr,
"To http://example.com/test/test.git\n * [new branch] test -> test\n"
);
} else {
panic!("Expected ToastWithLog variant");
}
}
}

View File

@@ -0,0 +1,315 @@
use crate::git_status_icon;
use git::status::{FileStatus, StatusCode, TrackedStatus, UnmergedStatus, UnmergedStatusCode};
use gpui::{App, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Task, WeakEntity};
use itertools::Itertools;
use picker::{Picker, PickerDelegate, PickerEditorPosition};
use project::{Project, git_store::Repository};
use std::sync::Arc;
use ui::{ListItem, ListItemSpacing, prelude::*};
use workspace::{ModalView, Workspace};
pub fn register(workspace: &mut Workspace) {
workspace.register_action(open);
}
pub fn open(
workspace: &mut Workspace,
_: &zed_actions::git::SelectRepo,
window: &mut Window,
cx: &mut Context<Workspace>,
) {
let project = workspace.project().clone();
workspace.toggle_modal(window, cx, |window, cx| {
RepositorySelector::new(project, rems(34.), window, cx)
})
}
pub struct RepositorySelector {
width: Rems,
picker: Entity<Picker<RepositorySelectorDelegate>>,
}
impl RepositorySelector {
pub fn new(
project_handle: Entity<Project>,
width: Rems,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let git_store = project_handle.read(cx).git_store().clone();
let repository_entries = git_store.update(cx, |git_store, _cx| {
let mut repos: Vec<_> = git_store.repositories().values().cloned().collect();
repos.sort_by(|a, b| {
a.read(_cx)
.display_name()
.to_lowercase()
.cmp(&b.read(_cx).display_name().to_lowercase())
});
repos
});
let filtered_repositories = repository_entries.clone();
let widest_item_ix = repository_entries.iter().position_max_by(|a, b| {
a.read(cx)
.display_name()
.len()
.cmp(&b.read(cx).display_name().len())
});
let active_repository = git_store.read(cx).active_repository();
let selected_index = active_repository
.as_ref()
.and_then(|active| filtered_repositories.iter().position(|repo| repo == active))
.unwrap_or(0);
let delegate = RepositorySelectorDelegate {
repository_selector: cx.entity().downgrade(),
repository_entries,
filtered_repositories,
active_repository,
selected_index,
};
let picker = cx.new(|cx| {
Picker::uniform_list(delegate, window, cx)
.widest_item(widest_item_ix)
.max_height(Some(rems(20.).into()))
.show_scrollbar(true)
});
RepositorySelector { picker, width }
}
}
//pub(crate) fn filtered_repository_entries(
// git_store: &GitStore,
// cx: &App,
//) -> Vec<Entity<Repository>> {
// let repositories = git_store
// .repositories()
// .values()
// .sorted_by_key(|repo| {
// let repo = repo.read(cx);
// (
// repo.dot_git_abs_path.clone(),
// repo.worktree_abs_path.clone(),
// )
// })
// .collect::<Vec<&Entity<Repository>>>();
//
// repositories
// .chunk_by(|a, b| a.read(cx).dot_git_abs_path == b.read(cx).dot_git_abs_path)
// .flat_map(|chunk| {
// let has_non_single_file_worktree = chunk
// .iter()
// .any(|repo| !repo.read(cx).is_from_single_file_worktree);
// chunk.iter().filter(move |repo| {
// // Remove any entry that comes from a single file worktree and represents a repository that is also represented by a non-single-file worktree.
// !repo.read(cx).is_from_single_file_worktree || !has_non_single_file_worktree
// })
// })
// .map(|&repo| repo.clone())
// .collect()
//}
impl EventEmitter<DismissEvent> for RepositorySelector {}
impl Focusable for RepositorySelector {
fn focus_handle(&self, cx: &App) -> FocusHandle {
self.picker.focus_handle(cx)
}
}
impl Render for RepositorySelector {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div()
.key_context("GitRepositorySelector")
.w(self.width)
.child(self.picker.clone())
}
}
impl ModalView for RepositorySelector {}
pub struct RepositorySelectorDelegate {
repository_selector: WeakEntity<RepositorySelector>,
repository_entries: Vec<Entity<Repository>>,
filtered_repositories: Vec<Entity<Repository>>,
active_repository: Option<Entity<Repository>>,
selected_index: usize,
}
impl RepositorySelectorDelegate {
pub fn update_repository_entries(&mut self, all_repositories: Vec<Entity<Repository>>) {
self.repository_entries = all_repositories.clone();
self.filtered_repositories = all_repositories;
self.selected_index = self
.active_repository
.as_ref()
.and_then(|active| {
self.filtered_repositories
.iter()
.position(|repo| repo == active)
})
.unwrap_or(0);
}
}
impl PickerDelegate for RepositorySelectorDelegate {
type ListItem = ListItem;
fn match_count(&self) -> usize {
self.filtered_repositories.len()
}
fn selected_index(&self) -> usize {
self.selected_index
}
fn set_selected_index(
&mut self,
ix: usize,
_window: &mut Window,
cx: &mut Context<Picker<Self>>,
) {
self.selected_index = ix.min(self.filtered_repositories.len().saturating_sub(1));
cx.notify();
}
fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
"Select a repository...".into()
}
fn editor_position(&self) -> PickerEditorPosition {
PickerEditorPosition::End
}
fn update_matches(
&mut self,
query: String,
window: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Task<()> {
let all_repositories = self.repository_entries.clone();
let repo_names: Vec<(Entity<Repository>, String)> = all_repositories
.iter()
.map(|repo| (repo.clone(), repo.read(cx).display_name().to_lowercase()))
.collect();
cx.spawn_in(window, async move |this, cx| {
let filtered_repositories = cx
.background_spawn(async move {
if query.is_empty() {
all_repositories
} else {
let query_lower = query.to_lowercase();
repo_names
.into_iter()
.filter(|(_, display_name)| display_name.contains(&query_lower))
.map(|(repo, _)| repo)
.collect()
}
})
.await;
this.update_in(cx, |this, window, cx| {
let mut sorted_repositories = filtered_repositories;
sorted_repositories.sort_by(|a, b| {
a.read(cx)
.display_name()
.to_lowercase()
.cmp(&b.read(cx).display_name().to_lowercase())
});
let selected_index = this
.delegate
.active_repository
.as_ref()
.and_then(|active| sorted_repositories.iter().position(|repo| repo == active))
.unwrap_or(0);
this.delegate.filtered_repositories = sorted_repositories;
this.delegate.set_selected_index(selected_index, window, cx);
cx.notify();
})
.ok();
})
}
fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
let Some(selected_repo) = self.filtered_repositories.get(self.selected_index) else {
return;
};
selected_repo.update(cx, |selected_repo, cx| {
selected_repo.set_as_active_repository(cx)
});
self.dismissed(window, cx);
}
fn dismissed(&mut self, _window: &mut Window, cx: &mut Context<Picker<Self>>) {
self.repository_selector
.update(cx, |_this, cx| cx.emit(DismissEvent))
.ok();
}
fn render_match(
&self,
ix: usize,
selected: bool,
_window: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Option<Self::ListItem> {
let repo_info = self.filtered_repositories.get(ix)?;
let repo = repo_info.read(cx);
let display_name = repo.display_name();
let summary = repo.status_summary();
let is_active = self
.active_repository
.as_ref()
.is_some_and(|active| active == repo_info);
let mut item = ListItem::new(ix)
.inset(true)
.spacing(ListItemSpacing::Sparse)
.toggle_state(selected)
.child(
h_flex()
.gap_1()
.child(Label::new(display_name))
.when(is_active, |this| {
this.child(
Icon::new(IconName::Check)
.size(IconSize::Small)
.color(Color::Accent),
)
}),
);
if summary.count > 0 {
let status = if summary.conflict > 0 {
FileStatus::Unmerged(UnmergedStatus {
first_head: UnmergedStatusCode::Updated,
second_head: UnmergedStatusCode::Updated,
})
} else if summary.worktree.deleted > 0 || summary.index.deleted > 0 {
FileStatus::Tracked(TrackedStatus {
index_status: StatusCode::Deleted,
worktree_status: StatusCode::Unmodified,
})
} else if summary.worktree.modified > 0 || summary.index.modified > 0 {
FileStatus::Tracked(TrackedStatus {
index_status: StatusCode::Modified,
worktree_status: StatusCode::Unmodified,
})
} else {
FileStatus::Tracked(TrackedStatus {
index_status: StatusCode::Added,
worktree_status: StatusCode::Unmodified,
})
};
item = item.end_slot(div().pr_2().child(git_status_icon(status)));
}
Some(item)
}
}

View File

@@ -0,0 +1,784 @@
use fuzzy::StringMatchCandidate;
use git::stash::StashEntry;
use gpui::{
Action, AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
InteractiveElement, IntoElement, Modifiers, ModifiersChangedEvent, ParentElement, Render,
SharedString, Styled, Subscription, Task, TaskExt, WeakEntity, Window, actions, rems,
};
use picker::{Picker, PickerDelegate};
use project::git_store::{Repository, RepositoryEvent};
use std::sync::Arc;
use time::{OffsetDateTime, UtcOffset};
use time_format;
use ui::{HighlightedLabel, KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*};
use util::ResultExt;
use workspace::notifications::DetachAndPromptErr;
use workspace::{ModalView, Workspace};
use crate::commit_view::CommitView;
use crate::stash_picker;
actions!(
stash_picker,
[
/// Drop the selected stash entry.
DropStashItem,
/// Show the diff view of the selected stash entry.
ShowStashItem,
]
);
pub fn open(
workspace: &mut Workspace,
_: &zed_actions::git::ViewStash,
window: &mut Window,
cx: &mut Context<Workspace>,
) {
let repository = workspace.project().read(cx).active_repository(cx);
let weak_workspace = workspace.weak_handle();
workspace.toggle_modal(window, cx, |window, cx| {
StashList::new(repository, weak_workspace, rems(34.), window, cx)
})
}
pub fn create_embedded(
repository: Option<Entity<Repository>>,
workspace: WeakEntity<Workspace>,
width: Rems,
show_footer: bool,
window: &mut Window,
cx: &mut Context<StashList>,
) -> StashList {
StashList::new_embedded(repository, workspace, width, show_footer, window, cx)
}
pub struct StashList {
width: Rems,
pub picker: Entity<Picker<StashListDelegate>>,
picker_focus_handle: FocusHandle,
_subscriptions: Vec<Subscription>,
}
impl StashList {
fn new(
repository: Option<Entity<Repository>>,
workspace: WeakEntity<Workspace>,
width: Rems,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let mut this = Self::new_inner(repository, workspace, width, false, window, cx);
this._subscriptions
.push(cx.subscribe(&this.picker, |_, _, _, cx| {
cx.emit(DismissEvent);
}));
this
}
fn new_inner(
repository: Option<Entity<Repository>>,
workspace: WeakEntity<Workspace>,
width: Rems,
embedded: bool,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let mut _subscriptions = Vec::new();
let stash_request = repository
.clone()
.map(|repository| repository.read_with(cx, |repo, _| repo.cached_stash()));
if let Some(repo) = repository.clone() {
_subscriptions.push(
cx.subscribe_in(&repo, window, |this, _, event, window, cx| {
if matches!(event, RepositoryEvent::StashEntriesChanged) {
let stash_entries = this.picker.read_with(cx, |picker, cx| {
picker
.delegate
.repo
.clone()
.map(|repo| repo.read(cx).cached_stash().entries.to_vec())
});
this.picker.update(cx, |this, cx| {
this.delegate.all_stash_entries = stash_entries;
this.refresh(window, cx);
});
}
}),
)
}
cx.spawn_in(window, async move |this, cx| {
let stash_entries = stash_request
.map(|git_stash| git_stash.entries.to_vec())
.unwrap_or_default();
this.update_in(cx, |this, window, cx| {
this.picker.update(cx, |picker, cx| {
picker.delegate.all_stash_entries = Some(stash_entries);
picker.refresh(window, cx);
})
})?;
anyhow::Ok(())
})
.detach_and_log_err(cx);
let delegate = StashListDelegate::new(repository, workspace, window, cx);
let picker = cx.new(|cx| {
Picker::uniform_list(delegate, window, cx)
.show_scrollbar(true)
.modal(!embedded)
});
let picker_focus_handle = picker.focus_handle(cx);
picker.update(cx, |picker, _| {
picker.delegate.focus_handle = picker_focus_handle.clone();
picker.delegate.show_footer = !embedded;
});
Self {
picker,
picker_focus_handle,
width,
_subscriptions,
}
}
fn new_embedded(
repository: Option<Entity<Repository>>,
workspace: WeakEntity<Workspace>,
width: Rems,
show_footer: bool,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let mut this = Self::new_inner(repository, workspace, width, true, window, cx);
this.picker.update(cx, |picker, _| {
picker.delegate.show_footer = show_footer;
});
this._subscriptions
.push(cx.subscribe(&this.picker, |_, _, _, cx| {
cx.emit(DismissEvent);
}));
this
}
pub fn handle_drop_stash(
&mut self,
_: &DropStashItem,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.picker.update(cx, |picker, cx| {
picker
.delegate
.drop_stash_at(picker.delegate.selected_index(), window, cx);
});
cx.notify();
}
pub fn handle_show_stash(
&mut self,
_: &ShowStashItem,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.picker.update(cx, |picker, cx| {
picker
.delegate
.show_stash_at(picker.delegate.selected_index(), window, cx);
});
cx.emit(DismissEvent);
}
pub fn handle_modifiers_changed(
&mut self,
ev: &ModifiersChangedEvent,
_: &mut Window,
cx: &mut Context<Self>,
) {
self.picker
.update(cx, |picker, _| picker.delegate.modifiers = ev.modifiers)
}
}
impl ModalView for StashList {}
impl EventEmitter<DismissEvent> for StashList {}
impl Focusable for StashList {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.picker_focus_handle.clone()
}
}
impl Render for StashList {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.key_context("StashList")
.w(self.width)
.on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
.on_action(cx.listener(Self::handle_drop_stash))
.on_action(cx.listener(Self::handle_show_stash))
.child(self.picker.clone())
}
}
#[derive(Debug, Clone)]
struct StashEntryMatch {
entry: StashEntry,
positions: Vec<usize>,
formatted_timestamp: String,
formatted_absolute_timestamp: String,
}
pub struct StashListDelegate {
matches: Vec<StashEntryMatch>,
all_stash_entries: Option<Vec<StashEntry>>,
repo: Option<Entity<Repository>>,
workspace: WeakEntity<Workspace>,
selected_index: usize,
last_query: String,
modifiers: Modifiers,
focus_handle: FocusHandle,
timezone: UtcOffset,
show_footer: bool,
}
impl StashListDelegate {
fn new(
repo: Option<Entity<Repository>>,
workspace: WeakEntity<Workspace>,
_window: &mut Window,
cx: &mut Context<StashList>,
) -> Self {
let timezone = UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC);
Self {
matches: vec![],
repo,
workspace,
all_stash_entries: None,
selected_index: 0,
last_query: Default::default(),
modifiers: Default::default(),
focus_handle: cx.focus_handle(),
timezone,
show_footer: false,
}
}
fn format_message(ix: usize, message: &String) -> String {
format!("#{}: {}", ix, message)
}
fn format_timestamp(timestamp: i64, timezone: UtcOffset) -> String {
let timestamp =
OffsetDateTime::from_unix_timestamp(timestamp).unwrap_or(OffsetDateTime::now_utc());
time_format::format_localized_timestamp(
timestamp,
OffsetDateTime::now_utc(),
timezone,
time_format::TimestampFormat::Relative,
)
}
fn format_absolute_timestamp(timestamp: i64, timezone: UtcOffset) -> String {
let timestamp =
OffsetDateTime::from_unix_timestamp(timestamp).unwrap_or(OffsetDateTime::now_utc());
time_format::format_localized_timestamp(
timestamp,
OffsetDateTime::now_utc(),
timezone,
time_format::TimestampFormat::EnhancedAbsolute,
)
}
fn drop_stash_at(&self, ix: usize, window: &mut Window, cx: &mut Context<Picker<Self>>) {
let Some(entry_match) = self.matches.get(ix) else {
return;
};
let stash_index = entry_match.entry.index;
let Some(repo) = self.repo.clone() else {
return;
};
cx.spawn(async move |_, cx| {
repo.update(cx, |repo, cx| repo.stash_drop(Some(stash_index), cx))
.await??;
Ok(())
})
.detach_and_prompt_err("Failed to drop stash", window, cx, |e, _, _| {
Some(e.to_string())
});
}
fn show_stash_at(&self, ix: usize, window: &mut Window, cx: &mut Context<Picker<Self>>) {
let Some(entry_match) = self.matches.get(ix) else {
return;
};
let stash_sha = entry_match.entry.oid.to_string();
let stash_index = entry_match.entry.index;
let Some(repo) = self.repo.clone() else {
return;
};
CommitView::open(
stash_sha,
repo.downgrade(),
self.workspace.clone(),
Some(stash_index),
None,
window,
cx,
);
}
fn pop_stash(&self, stash_index: usize, window: &mut Window, cx: &mut Context<Picker<Self>>) {
let Some(repo) = self.repo.clone() else {
return;
};
cx.spawn(async move |_, cx| {
repo.update(cx, |repo, cx| repo.stash_pop(Some(stash_index), cx))
.await?;
Ok(())
})
.detach_and_prompt_err("Failed to pop stash", window, cx, |e, _, _| {
Some(e.to_string())
});
cx.emit(DismissEvent);
}
fn apply_stash(&self, stash_index: usize, window: &mut Window, cx: &mut Context<Picker<Self>>) {
let Some(repo) = self.repo.clone() else {
return;
};
cx.spawn(async move |_, cx| {
repo.update(cx, |repo, cx| repo.stash_apply(Some(stash_index), cx))
.await?;
Ok(())
})
.detach_and_prompt_err("Failed to apply stash", window, cx, |e, _, _| {
Some(e.to_string())
});
cx.emit(DismissEvent);
}
}
impl PickerDelegate for StashListDelegate {
type ListItem = ListItem;
fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
"Select a stash…".into()
}
fn match_count(&self) -> usize {
self.matches.len()
}
fn selected_index(&self) -> usize {
self.selected_index
}
fn set_selected_index(
&mut self,
ix: usize,
_window: &mut Window,
_: &mut Context<Picker<Self>>,
) {
self.selected_index = ix;
}
fn update_matches(
&mut self,
query: String,
window: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Task<()> {
let Some(all_stash_entries) = self.all_stash_entries.clone() else {
return Task::ready(());
};
let timezone = self.timezone;
cx.spawn_in(window, async move |picker, cx| {
let matches: Vec<StashEntryMatch> = if query.is_empty() {
all_stash_entries
.into_iter()
.map(|entry| {
let formatted_timestamp = Self::format_timestamp(entry.timestamp, timezone);
let formatted_absolute_timestamp =
Self::format_absolute_timestamp(entry.timestamp, timezone);
StashEntryMatch {
entry,
positions: Vec::new(),
formatted_timestamp,
formatted_absolute_timestamp,
}
})
.collect()
} else {
let candidates = all_stash_entries
.iter()
.enumerate()
.map(|(ix, entry)| {
StringMatchCandidate::new(
ix,
&Self::format_message(entry.index, &entry.message),
)
})
.collect::<Vec<StringMatchCandidate>>();
fuzzy::match_strings(
&candidates,
&query,
false,
true,
10000,
&Default::default(),
cx.background_executor().clone(),
)
.await
.into_iter()
.map(|candidate| {
let entry = all_stash_entries[candidate.candidate_id].clone();
let formatted_timestamp = Self::format_timestamp(entry.timestamp, timezone);
let formatted_absolute_timestamp =
Self::format_absolute_timestamp(entry.timestamp, timezone);
StashEntryMatch {
entry,
positions: candidate.positions,
formatted_timestamp,
formatted_absolute_timestamp,
}
})
.collect()
};
picker
.update(cx, |picker, _| {
let delegate = &mut picker.delegate;
delegate.matches = matches;
if delegate.matches.is_empty() {
delegate.selected_index = 0;
} else {
delegate.selected_index =
core::cmp::min(delegate.selected_index, delegate.matches.len() - 1);
}
delegate.last_query = query;
})
.log_err();
})
}
fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
let Some(entry_match) = self.matches.get(self.selected_index()) else {
return;
};
let stash_index = entry_match.entry.index;
if secondary {
self.pop_stash(stash_index, window, cx);
} else {
self.apply_stash(stash_index, window, cx);
}
}
fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<Self>>) {
cx.emit(DismissEvent);
}
fn render_match(
&self,
ix: usize,
selected: bool,
_window: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Option<Self::ListItem> {
let entry_match = &self.matches[ix];
let stash_message =
Self::format_message(entry_match.entry.index, &entry_match.entry.message);
let positions = entry_match.positions.clone();
let stash_label = HighlightedLabel::new(stash_message, positions)
.truncate()
.into_any_element();
let branch_name = entry_match.entry.branch.clone().unwrap_or_default();
let branch_info = h_flex()
.gap_1p5()
.w_full()
.child(
Label::new(branch_name)
.truncate()
.color(Color::Muted)
.size(LabelSize::Small),
)
.child(
Label::new("")
.alpha(0.5)
.color(Color::Muted)
.size(LabelSize::Small),
)
.child(
Label::new(entry_match.formatted_timestamp.clone())
.color(Color::Muted)
.size(LabelSize::Small),
);
let view_button = {
let focus_handle = self.focus_handle.clone();
IconButton::new(("view-stash", ix), IconName::Eye)
.icon_size(IconSize::Small)
.tooltip(move |_, cx| {
Tooltip::for_action_in("View Stash", &ShowStashItem, &focus_handle, cx)
})
.on_click(cx.listener(move |this, _, window, cx| {
this.delegate.show_stash_at(ix, window, cx);
}))
};
let pop_button = {
let focus_handle = self.focus_handle.clone();
IconButton::new(("pop-stash", ix), IconName::MaximizeAlt)
.icon_size(IconSize::Small)
.tooltip(move |_, cx| {
Tooltip::for_action_in("Pop Stash", &menu::SecondaryConfirm, &focus_handle, cx)
})
.on_click(|_, window, cx| {
window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx);
})
};
let drop_button = {
let focus_handle = self.focus_handle.clone();
IconButton::new(("drop-stash", ix), IconName::Trash)
.icon_size(IconSize::Small)
.tooltip(move |_, cx| {
Tooltip::for_action_in("Drop Stash", &DropStashItem, &focus_handle, cx)
})
.on_click(cx.listener(move |this, _, window, cx| {
this.delegate.drop_stash_at(ix, window, cx);
}))
};
Some(
ListItem::new(format!("stash-{ix}"))
.inset(true)
.spacing(ListItemSpacing::Sparse)
.toggle_state(selected)
.child(
h_flex()
.min_w_0()
.w_full()
.gap_2p5()
.child(
Icon::new(IconName::BoxOpen)
.size(IconSize::Small)
.color(Color::Muted),
)
.child(
v_flex()
.id(format!("stash-tooltip-{ix}"))
.min_w_0()
.w_full()
.child(stash_label)
.child(branch_info)
.tooltip({
let stash_message = Self::format_message(
entry_match.entry.index,
&entry_match.entry.message,
);
let absolute_timestamp =
entry_match.formatted_absolute_timestamp.clone();
Tooltip::element(move |_, _| {
v_flex()
.child(Label::new(stash_message.clone()))
.child(
Label::new(absolute_timestamp.clone())
.size(LabelSize::Small)
.color(Color::Muted),
)
.into_any_element()
})
}),
),
)
.end_slot(
h_flex()
.gap_0p5()
.child(view_button)
.child(pop_button)
.child(drop_button),
)
.show_end_slot_on_hover(),
)
}
fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
Some("No stashes found".into())
}
fn render_footer(&self, _: &mut Window, cx: &mut Context<Picker<Self>>) -> Option<AnyElement> {
if !self.show_footer || self.matches.is_empty() {
return None;
}
let focus_handle = self.focus_handle.clone();
Some(
h_flex()
.w_full()
.p_1p5()
.gap_0p5()
.justify_end()
.flex_wrap()
.border_t_1()
.border_color(cx.theme().colors().border_variant)
.child(
Button::new("drop-stash", "Drop")
.key_binding(
KeyBinding::for_action_in(
&stash_picker::DropStashItem,
&focus_handle,
cx,
)
.map(|kb| kb.size(rems_from_px(12.))),
)
.on_click(|_, window, cx| {
window.dispatch_action(stash_picker::DropStashItem.boxed_clone(), cx)
}),
)
.child(
Button::new("view-stash", "View")
.key_binding(
KeyBinding::for_action_in(
&stash_picker::ShowStashItem,
&focus_handle,
cx,
)
.map(|kb| kb.size(rems_from_px(12.))),
)
.on_click(cx.listener(move |picker, _, window, cx| {
cx.stop_propagation();
let selected_ix = picker.delegate.selected_index();
picker.delegate.show_stash_at(selected_ix, window, cx);
})),
)
.child(
Button::new("pop-stash", "Pop")
.key_binding(
KeyBinding::for_action_in(&menu::SecondaryConfirm, &focus_handle, cx)
.map(|kb| kb.size(rems_from_px(12.))),
)
.on_click(|_, window, cx| {
window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx)
}),
)
.child(
Button::new("apply-stash", "Apply")
.key_binding(
KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx)
.map(|kb| kb.size(rems_from_px(12.))),
)
.on_click(|_, window, cx| {
window.dispatch_action(menu::Confirm.boxed_clone(), cx)
}),
)
.into_any(),
)
}
}
#[cfg(test)]
mod tests {
use std::str::FromStr;
use super::*;
use git::{Oid, stash::StashEntry};
use gpui::{TestAppContext, VisualTestContext, rems};
use picker::PickerDelegate;
use project::{FakeFs, Project};
use settings::SettingsStore;
use workspace::MultiWorkspace;
fn init_test(cx: &mut TestAppContext) {
cx.update(|cx| {
let settings_store = SettingsStore::test(cx);
cx.set_global(settings_store);
theme_settings::init(theme::LoadThemes::JustBase, cx);
editor::init(cx);
})
}
/// Convenience function for creating `StashEntry` instances during tests.
/// Feel free to update in case you need to provide extra fields.
fn stash_entry(index: usize, message: &str, branch: Option<&str>) -> StashEntry {
let oid = Oid::from_str(&format!("{:0>40x}", index)).unwrap();
StashEntry {
index,
oid,
message: message.to_string(),
branch: branch.map(Into::into),
timestamp: 1000 - index as i64,
}
}
#[gpui::test]
async fn test_show_stash_dismisses(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, [], cx).await;
let multi_workspace =
cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
let cx = &mut VisualTestContext::from_window(*multi_workspace, cx);
let workspace = multi_workspace
.update(cx, |workspace, _, _| workspace.workspace().clone())
.unwrap();
let stash_entries = vec![
stash_entry(0, "stash #0", Some("main")),
stash_entry(1, "stash #1", Some("develop")),
];
let stash_list = workspace.update_in(cx, |workspace, window, cx| {
let weak_workspace = workspace.weak_handle();
workspace.toggle_modal(window, cx, move |window, cx| {
StashList::new(None, weak_workspace, rems(34.), window, cx)
});
assert!(workspace.active_modal::<StashList>(cx).is_some());
workspace.active_modal::<StashList>(cx).unwrap()
});
cx.run_until_parked();
stash_list.update(cx, |stash_list, cx| {
stash_list.picker.update(cx, |picker, _| {
picker.delegate.all_stash_entries = Some(stash_entries);
});
});
stash_list
.update_in(cx, |stash_list, window, cx| {
stash_list.picker.update(cx, |picker, cx| {
picker.delegate.update_matches(String::new(), window, cx)
})
})
.await;
cx.run_until_parked();
stash_list.update_in(cx, |stash_list, window, cx| {
assert_eq!(stash_list.picker.read(cx).delegate.matches.len(), 2);
stash_list.handle_show_stash(&Default::default(), window, cx);
});
workspace.update(cx, |workspace, cx| {
assert!(workspace.active_modal::<StashList>(cx).is_none());
});
}
}

View File

@@ -0,0 +1,944 @@
//! TextDiffView currently provides a UI for displaying differences between the clipboard and selected text.
use anyhow::Result;
use buffer_diff::BufferDiff;
use editor::{
Editor, EditorEvent, EditorSettings, MultiBuffer, SplittableEditor, ToPoint,
actions::DiffClipboardWithSelectionData,
};
use futures::{FutureExt, select_biased};
use gpui::{
AnyElement, App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, FocusHandle,
Focusable, IntoElement, Render, Task, Window,
};
use language::{self, Buffer, OffsetRangeExt, Point};
use project::Project;
use settings::Settings;
use std::{
any::{Any, TypeId},
cmp,
ops::Range,
pin::pin,
sync::Arc,
time::Duration,
};
use ui::{Color, Icon, IconName, Label, LabelCommon as _, SharedString};
use util::paths::PathExt;
use workspace::{
Item, ItemNavHistory, Workspace,
item::{ItemEvent, SaveOptions, TabContentParams},
searchable::SearchableItemHandle,
};
pub struct TextDiffView {
diff_editor: Entity<SplittableEditor>,
title: SharedString,
path: Option<SharedString>,
buffer_changes_tx: watch::Sender<()>,
_recalculate_diff_task: Task<Result<()>>,
}
const RECALCULATE_DIFF_DEBOUNCE: Duration = Duration::from_millis(250);
impl TextDiffView {
pub fn open(
diff_data: &DiffClipboardWithSelectionData,
workspace: &Workspace,
window: &mut Window,
cx: &mut App,
) -> Option<Task<Result<Entity<Self>>>> {
let source_editor = diff_data.editor.clone();
let selection_data = source_editor.update(cx, |editor, cx| {
let multibuffer = editor.buffer();
let multibuffer_snapshot = multibuffer.read(cx).snapshot(cx);
let first_selection = editor.selections.newest_anchor();
let (source_buffer, buffer_range) = multibuffer_snapshot
.anchor_range_to_buffer_anchor_range(first_selection.range())?;
let max_point = source_buffer.max_point();
let buffer_range = buffer_range.to_point(source_buffer);
let source_buffer = multibuffer.read(cx).buffer(source_buffer.remote_id())?;
if buffer_range.is_empty() {
let full_range = Point::new(0, 0)..max_point;
return Some((source_buffer, full_range));
}
let expanded_start = Point::new(buffer_range.start.row, 0);
let expanded_end = if buffer_range.end.column > 0 {
let next_row = buffer_range.end.row + 1;
cmp::min(max_point, Point::new(next_row, 0))
} else {
buffer_range.end
};
Some((source_buffer, expanded_start..expanded_end))
});
let Some((source_buffer, expanded_selection_range)) = selection_data else {
log::warn!("There should always be at least one selection in Zed. This is a bug.");
return None;
};
source_editor.update(cx, |source_editor, cx| {
let multibuffer = source_editor.buffer();
let mb_range = {
let mb = multibuffer.read(cx);
let start_anchor =
mb.buffer_point_to_anchor(&source_buffer, expanded_selection_range.start, cx);
let end_anchor =
mb.buffer_point_to_anchor(&source_buffer, expanded_selection_range.end, cx);
start_anchor.zip(end_anchor).map(|(s, e)| {
let snapshot = mb.snapshot(cx);
s.to_point(&snapshot)..e.to_point(&snapshot)
})
};
if let Some(range) = mb_range {
source_editor.change_selections(Default::default(), window, cx, |s| {
s.select_ranges(vec![range]);
});
}
});
let source_buffer_snapshot = source_buffer.read(cx).snapshot();
let mut clipboard_text = diff_data.clipboard_text.clone();
if !clipboard_text.ends_with("\n") {
clipboard_text.push_str("\n");
}
let workspace = workspace.weak_handle();
let diff_buffer = cx.new(|cx| BufferDiff::new(&source_buffer_snapshot.text, cx));
let clipboard_buffer = build_clipboard_buffer(
clipboard_text,
&source_buffer,
expanded_selection_range.clone(),
cx,
);
let task = window.spawn(cx, async move |cx| {
update_diff_buffer(&diff_buffer, &source_buffer, &clipboard_buffer, cx).await?;
workspace.update_in(cx, |workspace, window, cx| {
let project = workspace.project().clone();
let workspace_entity = cx.entity();
let diff_view = cx.new(|cx| {
TextDiffView::new(
clipboard_buffer,
source_editor,
source_buffer,
expanded_selection_range,
diff_buffer,
project,
workspace_entity,
window,
cx,
)
});
let pane = workspace.active_pane();
pane.update(cx, |pane, cx| {
pane.add_item(Box::new(diff_view.clone()), true, true, None, window, cx);
});
diff_view
})
});
Some(task)
}
pub fn new(
clipboard_buffer: Entity<Buffer>,
source_editor: Entity<Editor>,
source_buffer: Entity<Buffer>,
source_range: Range<Point>,
diff_buffer: Entity<BufferDiff>,
project: Entity<Project>,
workspace: Entity<Workspace>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let multibuffer = cx.new(|cx| {
let mut multibuffer = MultiBuffer::new(language::Capability::ReadWrite);
multibuffer.set_excerpts_for_buffer(source_buffer.clone(), [source_range], 0, cx);
multibuffer.add_diff(diff_buffer.clone(), cx);
multibuffer
});
let diff_editor = cx.new(|cx| {
let splittable = SplittableEditor::new(
EditorSettings::get_global(cx).diff_view_style,
multibuffer,
project,
workspace,
window,
cx,
);
splittable.disable_diff_hunk_controls(cx);
splittable.rhs_editor().update(cx, |editor, _cx| {
editor.start_temporary_diff_override();
});
splittable
});
let (buffer_changes_tx, mut buffer_changes_rx) = watch::channel(());
cx.subscribe(&source_buffer, move |this, _, event, _| match event {
language::BufferEvent::Edited { .. }
| language::BufferEvent::LanguageChanged(_)
| language::BufferEvent::Reparsed => {
this.buffer_changes_tx.send(()).ok();
}
_ => {}
})
.detach();
let editor = source_editor.read(cx);
let title = editor.buffer().read(cx).title(cx).to_string();
let selection_location_text = selection_location_text(editor, cx);
let selection_location_title = selection_location_text
.as_ref()
.map(|text| format!("{} @ {}", title, text))
.unwrap_or(title);
let path = editor
.buffer()
.read(cx)
.as_singleton()
.and_then(|b| {
b.read(cx)
.file()
.map(|f| f.full_path(cx).compact().to_string_lossy().into_owned())
})
.unwrap_or("untitled".into());
let selection_location_path = selection_location_text
.map(|text| format!("{} @ {}", path, text))
.unwrap_or(path);
Self {
diff_editor,
title: format!("Clipboard ↔ {selection_location_title}").into(),
path: Some(format!("Clipboard ↔ {selection_location_path}").into()),
buffer_changes_tx,
_recalculate_diff_task: cx.spawn(async move |_, cx| {
while buffer_changes_rx.recv().await.is_ok() {
loop {
let mut timer = cx
.background_executor()
.timer(RECALCULATE_DIFF_DEBOUNCE)
.fuse();
let mut recv = pin!(buffer_changes_rx.recv().fuse());
select_biased! {
_ = timer => break,
_ = recv => continue,
}
}
log::trace!("start recalculating");
update_diff_buffer(&diff_buffer, &source_buffer, &clipboard_buffer, cx).await?;
log::trace!("finish recalculating");
}
Ok(())
}),
}
}
}
fn build_clipboard_buffer(
text: String,
source_buffer: &Entity<Buffer>,
replacement_range: Range<Point>,
cx: &mut App,
) -> Entity<Buffer> {
let source_buffer_snapshot = source_buffer.read(cx).snapshot();
cx.new(|cx| {
let mut buffer = language::Buffer::local(source_buffer_snapshot.text(), cx);
let language = source_buffer.read(cx).language().cloned();
buffer.set_language(language, cx);
let range_start = source_buffer_snapshot.point_to_offset(replacement_range.start);
let range_end = source_buffer_snapshot.point_to_offset(replacement_range.end);
buffer.edit([(range_start..range_end, text)], None, cx);
buffer
})
}
async fn update_diff_buffer(
diff: &Entity<BufferDiff>,
source_buffer: &Entity<Buffer>,
clipboard_buffer: &Entity<Buffer>,
cx: &mut AsyncApp,
) -> Result<()> {
let source_buffer_snapshot = source_buffer.read_with(cx, |buffer, _| buffer.snapshot());
let language = source_buffer_snapshot.language().cloned();
let language_registry = source_buffer.read_with(cx, |buffer, _| buffer.language_registry());
let base_buffer_snapshot = clipboard_buffer.read_with(cx, |buffer, _| buffer.snapshot());
let base_text = base_buffer_snapshot.text();
let update = diff
.update(cx, |diff, cx| {
diff.update_diff(
source_buffer_snapshot.text.clone(),
Some(Arc::from(base_text.as_str())),
Some(true),
language.clone(),
cx,
)
})
.await;
diff.update(cx, |diff, cx| {
diff.language_changed(language, language_registry, cx);
diff.set_snapshot(update, &source_buffer_snapshot.text, cx)
})
.await;
Ok(())
}
impl EventEmitter<EditorEvent> for TextDiffView {}
impl Focusable for TextDiffView {
fn focus_handle(&self, cx: &App) -> FocusHandle {
self.diff_editor.focus_handle(cx)
}
}
impl Item for TextDiffView {
type Event = EditorEvent;
fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
Some(Icon::new(IconName::Diff).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, _: &App) -> SharedString {
self.title.clone()
}
fn tab_tooltip_text(&self, _: &App) -> Option<SharedString> {
self.path.clone()
}
fn to_item_events(event: &EditorEvent, f: &mut dyn FnMut(ItemEvent)) {
Editor::to_item_events(event, f)
}
fn telemetry_event_text(&self) -> Option<&'static str> {
Some("Selection Diff View Opened")
}
fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.diff_editor
.update(cx, |editor, cx| editor.deactivated(window, cx));
}
fn act_as_type<'a>(
&'a self,
type_id: TypeId,
self_handle: &'a Entity<Self>,
cx: &'a App,
) -> Option<gpui::AnyEntity> {
if type_id == TypeId::of::<Self>() {
Some(self_handle.clone().into())
} else if type_id == TypeId::of::<SplittableEditor>() {
Some(self.diff_editor.clone().into())
} else if type_id == TypeId::of::<Editor>() {
Some(self.diff_editor.read(cx).rhs_editor().clone().into())
} else {
None
}
}
fn as_searchable(&self, _: &Entity<Self>, _: &App) -> Option<Box<dyn SearchableItemHandle>> {
Some(Box::new(self.diff_editor.clone()))
}
fn for_each_project_item(
&self,
cx: &App,
f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
) {
self.diff_editor.read(cx).for_each_project_item(cx, f)
}
fn set_nav_history(
&mut self,
nav_history: ItemNavHistory,
_: &mut Window,
cx: &mut Context<Self>,
) {
let rhs = self.diff_editor.read(cx).rhs_editor().clone();
rhs.update(cx, |editor, _| {
editor.set_nav_history(Some(nav_history));
});
}
fn navigate(
&mut self,
data: Arc<dyn Any + Send>,
window: &mut Window,
cx: &mut Context<Self>,
) -> bool {
self.diff_editor
.update(cx, |editor, cx| editor.navigate(data, window, cx))
}
fn added_to_workspace(
&mut self,
workspace: &mut Workspace,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.diff_editor.update(cx, |editor, cx| {
editor.added_to_workspace(workspace, window, cx)
});
}
fn can_save(&self, cx: &App) -> bool {
// The editor handles the new buffer, so delegate to it
self.diff_editor.read(cx).can_save(cx)
}
fn save(
&mut self,
options: SaveOptions,
project: Entity<Project>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Task<Result<()>> {
// Delegate saving to the editor, which manages the new buffer
self.diff_editor
.update(cx, |editor, cx| editor.save(options, project, window, cx))
}
}
pub fn selection_location_text(editor: &Editor, cx: &App) -> Option<String> {
let buffer = editor.buffer().read(cx);
let buffer_snapshot = buffer.snapshot(cx);
let first_selection = editor.selections.disjoint_anchors().first()?;
let selection_start = first_selection.start.to_point(&buffer_snapshot);
let selection_end = first_selection.end.to_point(&buffer_snapshot);
let start_row = selection_start.row;
let start_column = selection_start.column;
let end_row = selection_end.row;
let end_column = selection_end.column;
let range_text = if start_row == end_row {
format!("L{}:{}-{}", start_row + 1, start_column + 1, end_column + 1)
} else {
format!(
"L{}:{}-L{}:{}",
start_row + 1,
start_column + 1,
end_row + 1,
end_column + 1
)
};
Some(range_text)
}
impl Render for TextDiffView {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
self.diff_editor.clone()
}
}
#[cfg(test)]
mod tests {
use super::*;
use editor::{MultiBufferOffset, PathKey, test::editor_test_context::assert_state_with_diff};
use gpui::{BorrowAppContext, TestAppContext, VisualContext};
use language::Point;
use project::{FakeFs, Project};
use serde_json::json;
use settings::{DiffViewStyle, SettingsStore};
use unindent::unindent;
use util::{path, test::marked_text_ranges};
use workspace::MultiWorkspace;
fn init_test(cx: &mut TestAppContext) {
cx.update(|cx| {
let settings_store = SettingsStore::test(cx);
cx.set_global(settings_store);
cx.update_global::<SettingsStore, _>(|store, cx| {
store.update_user_settings(cx, |settings| {
settings.editor.diff_view_style = Some(DiffViewStyle::Unified);
});
});
theme_settings::init(theme::LoadThemes::JustBase, cx);
});
}
#[gpui::test]
async fn test_diffing_clipboard_against_empty_selection_uses_full_buffer_selection(
cx: &mut TestAppContext,
) {
base_test(
path!("/test"),
path!("/test/text.txt"),
"def process_incoming_inventory(items, warehouse_id):\n pass\n",
"def process_outgoing_inventory(items, warehouse_id):\n passˇ\n",
&unindent(
"
- def process_incoming_inventory(items, warehouse_id):
+ ˇdef process_outgoing_inventory(items, warehouse_id):
pass
",
),
"Clipboard ↔ text.txt @ L1:1-L3:1",
&format!("Clipboard ↔ {} @ L1:1-L3:1", path!("test/text.txt")),
cx,
)
.await;
}
#[gpui::test]
async fn test_diffing_clipboard_against_multiline_selection_expands_to_full_lines(
cx: &mut TestAppContext,
) {
base_test(
path!("/test"),
path!("/test/text.txt"),
"def process_incoming_inventory(items, warehouse_id):\n pass\n",
"«def process_outgoing_inventory(items, warehouse_id):\n passˇ»\n",
&unindent(
"
- def process_incoming_inventory(items, warehouse_id):
+ ˇdef process_outgoing_inventory(items, warehouse_id):
pass
",
),
"Clipboard ↔ text.txt @ L1:1-L3:1",
&format!("Clipboard ↔ {} @ L1:1-L3:1", path!("test/text.txt")),
cx,
)
.await;
}
#[gpui::test]
async fn test_diffing_clipboard_against_single_line_selection(cx: &mut TestAppContext) {
base_test(
path!("/test"),
path!("/test/text.txt"),
"a",
"«bbˇ»",
&unindent(
"
- a
+ ˇbb",
),
"Clipboard ↔ text.txt @ L1:1-3",
&format!("Clipboard ↔ {} @ L1:1-3", path!("test/text.txt")),
cx,
)
.await;
}
#[gpui::test]
async fn test_diffing_clipboard_with_leading_whitespace_against_line(cx: &mut TestAppContext) {
base_test(
path!("/test"),
path!("/test/text.txt"),
" a",
"«bbˇ»",
&unindent(
"
- a
+ ˇbb",
),
"Clipboard ↔ text.txt @ L1:1-3",
&format!("Clipboard ↔ {} @ L1:1-3", path!("test/text.txt")),
cx,
)
.await;
}
#[gpui::test]
async fn test_diffing_clipboard_against_line_with_leading_whitespace(cx: &mut TestAppContext) {
base_test(
path!("/test"),
path!("/test/text.txt"),
"a",
" «bbˇ»",
&unindent(
"
- a
+ ˇ bb",
),
"Clipboard ↔ text.txt @ L1:1-7",
&format!("Clipboard ↔ {} @ L1:1-7", path!("test/text.txt")),
cx,
)
.await;
}
#[gpui::test]
async fn test_diffing_clipboard_against_line_with_leading_whitespace_included_in_selection(
cx: &mut TestAppContext,
) {
base_test(
path!("/test"),
path!("/test/text.txt"),
"a",
"« bbˇ»",
&unindent(
"
- a
+ ˇ bb",
),
"Clipboard ↔ text.txt @ L1:1-7",
&format!("Clipboard ↔ {} @ L1:1-7", path!("test/text.txt")),
cx,
)
.await;
}
#[gpui::test]
async fn test_diffing_clipboard_with_leading_whitespace_against_line_with_leading_whitespace(
cx: &mut TestAppContext,
) {
base_test(
path!("/test"),
path!("/test/text.txt"),
" a",
" «bbˇ»",
&unindent(
"
- a
+ ˇ bb",
),
"Clipboard ↔ text.txt @ L1:1-7",
&format!("Clipboard ↔ {} @ L1:1-7", path!("test/text.txt")),
cx,
)
.await;
}
#[gpui::test]
async fn test_diffing_clipboard_with_leading_whitespace_against_line_with_leading_whitespace_included_in_selection(
cx: &mut TestAppContext,
) {
base_test(
path!("/test"),
path!("/test/text.txt"),
" a",
"« bbˇ»",
&unindent(
"
- a
+ ˇ bb",
),
"Clipboard ↔ text.txt @ L1:1-7",
&format!("Clipboard ↔ {} @ L1:1-7", path!("test/text.txt")),
cx,
)
.await;
}
#[gpui::test]
async fn test_diffing_clipboard_against_partial_selection_expands_to_include_trailing_characters(
cx: &mut TestAppContext,
) {
base_test(
path!("/test"),
path!("/test/text.txt"),
"a",
"«bˇ»b",
&unindent(
"
- a
+ ˇbb",
),
"Clipboard ↔ text.txt @ L1:1-3",
&format!("Clipboard ↔ {} @ L1:1-3", path!("test/text.txt")),
cx,
)
.await;
}
#[gpui::test]
async fn test_diffing_clipboard_from_multibuffer_with_selection(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/project"),
json!({
"a.txt": "alpha\nbeta\ngamma",
"b.txt": "one\ntwo\nthree"
}),
)
.await;
let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
let buffer_a = project
.update(cx, |project, cx| {
project.open_local_buffer(path!("/project/a.txt"), cx)
})
.await
.unwrap();
let buffer_b = project
.update(cx, |project, cx| {
project.open_local_buffer(path!("/project/b.txt"), cx)
})
.await
.unwrap();
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let editor = cx.new_window_entity(|window, cx| {
let multibuffer = cx.new(|cx| {
let mut mb = MultiBuffer::new(language::Capability::ReadWrite);
mb.set_excerpts_for_path(
PathKey::sorted(0),
buffer_a.clone(),
[Point::new(0, 0)..Point::new(2, 5)],
0,
cx,
);
mb.set_excerpts_for_path(
PathKey::sorted(1),
buffer_b.clone(),
[Point::new(0, 0)..Point::new(2, 5)],
0,
cx,
);
mb
});
let mut editor =
Editor::for_multibuffer(multibuffer, Some(project.clone()), window, cx);
// Select "beta" inside the first excerpt
editor.change_selections(Default::default(), window, cx, |s| {
s.select_ranges([MultiBufferOffset(6)..MultiBufferOffset(10)]);
});
editor
});
let diff_view = workspace
.update_in(cx, |workspace, window, cx| {
TextDiffView::open(
&DiffClipboardWithSelectionData {
clipboard_text: "REPLACED".to_string(),
editor,
},
workspace,
window,
cx,
)
})
.unwrap()
.await
.unwrap();
cx.executor().run_until_parked();
diff_view.read_with(cx, |diff_view, _cx| {
assert!(
diff_view.title.contains("Clipboard"),
"diff view should have opened with a clipboard diff title, got: {}",
diff_view.title
);
});
}
#[gpui::test]
async fn test_diffing_clipboard_from_multibuffer_with_empty_selection(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/project"),
json!({
"a.txt": "alpha\nbeta\ngamma",
"b.txt": "one\ntwo\nthree"
}),
)
.await;
let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
let buffer_a = project
.update(cx, |project, cx| {
project.open_local_buffer(path!("/project/a.txt"), cx)
})
.await
.unwrap();
let buffer_b = project
.update(cx, |project, cx| {
project.open_local_buffer(path!("/project/b.txt"), cx)
})
.await
.unwrap();
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let editor = cx.new_window_entity(|window, cx| {
let multibuffer = cx.new(|cx| {
let mut mb = MultiBuffer::new(language::Capability::ReadWrite);
mb.set_excerpts_for_path(
PathKey::sorted(0),
buffer_a.clone(),
[Point::new(0, 0)..Point::new(2, 5)],
0,
cx,
);
mb.set_excerpts_for_path(
PathKey::sorted(1),
buffer_b.clone(),
[Point::new(0, 0)..Point::new(2, 5)],
0,
cx,
);
mb
});
let mut editor =
Editor::for_multibuffer(multibuffer, Some(project.clone()), window, cx);
// Cursor inside the first excerpt (no selection)
editor.change_selections(Default::default(), window, cx, |s| {
s.select_ranges([MultiBufferOffset(6)..MultiBufferOffset(6)]);
});
editor
});
let diff_view = workspace
.update_in(cx, |workspace, window, cx| {
TextDiffView::open(
&DiffClipboardWithSelectionData {
clipboard_text: "REPLACED".to_string(),
editor,
},
workspace,
window,
cx,
)
})
.unwrap()
.await
.unwrap();
cx.executor().run_until_parked();
// Empty selection should diff the full underlying buffer
diff_view.read_with(cx, |diff_view, _cx| {
assert!(
diff_view.title.contains("Clipboard"),
"diff view should have opened with a clipboard diff title, got: {}",
diff_view.title
);
});
}
async fn base_test(
project_root: &str,
file_path: &str,
clipboard_text: &str,
editor_text: &str,
expected_diff: &str,
expected_tab_title: &str,
expected_tab_tooltip: &str,
cx: &mut TestAppContext,
) {
init_test(cx);
let file_name = std::path::Path::new(file_path)
.file_name()
.unwrap()
.to_str()
.unwrap();
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
project_root,
json!({
file_name: editor_text
}),
)
.await;
let project = Project::test(fs, [project_root.as_ref()], cx).await;
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let buffer = project
.update(cx, |project, cx| project.open_local_buffer(file_path, cx))
.await
.unwrap();
let editor = cx.new_window_entity(|window, cx| {
let mut editor = Editor::for_buffer(buffer, None, window, cx);
let (unmarked_text, selection_ranges) = marked_text_ranges(editor_text, false);
editor.set_text(unmarked_text, window, cx);
editor.change_selections(Default::default(), window, cx, |s| {
s.select_ranges(
selection_ranges
.into_iter()
.map(|range| MultiBufferOffset(range.start)..MultiBufferOffset(range.end)),
)
});
editor
});
let diff_view = workspace
.update_in(cx, |workspace, window, cx| {
TextDiffView::open(
&DiffClipboardWithSelectionData {
clipboard_text: clipboard_text.to_string(),
editor,
},
workspace,
window,
cx,
)
})
.unwrap()
.await
.unwrap();
cx.executor().run_until_parked();
assert_state_with_diff(
&diff_view.read_with(cx, |diff_view, cx| {
diff_view.diff_editor.read(cx).rhs_editor().clone()
}),
cx,
expected_diff,
);
diff_view.read_with(cx, |diff_view, cx| {
assert_eq!(diff_view.tab_content_text(0, cx), expected_tab_title);
assert_eq!(
diff_view.tab_tooltip_text(cx).unwrap(),
expected_tab_tooltip
);
});
}
}

View File

@@ -0,0 +1,171 @@
use collections::HashSet;
use rand::Rng;
const ADJECTIVES: &[&str] = &[
"able", "agate", "airy", "alpine", "amber", "ample", "aqua", "arctic", "arid", "ashen",
"astral", "autumn", "avid", "balmy", "birch", "bold", "boreal", "brave", "breezy", "brief",
"bright", "brisk", "broad", "bronze", "calm", "cerith", "cheery", "civil", "clean", "clear",
"clever", "cobalt", "cool", "copper", "coral", "cozy", "crisp", "cubic", "cyan", "deft",
"dense", "dewy", "direct", "dusky", "dusty", "early", "earnest", "earthy", "elder", "elfin",
"equal", "even", "exact", "faint", "fair", "fast", "fawn", "ferny", "fiery", "fine", "firm",
"fleet", "floral", "focal", "fond", "frank", "fresh", "frosty", "full", "gentle", "gilded",
"glacial", "glad", "glossy", "golden", "grand", "green", "gusty", "hale", "happy", "hardy",
"hazel", "hearty", "hilly", "humble", "hushed", "icy", "ideal", "inky", "iron", "ivory",
"jade", "jovial", "keen", "kind", "lapis", "leafy", "level", "light", "lilac", "limber",
"lively", "lofty", "loyal", "lucid", "lunar", "major", "maple", "marshy", "mellow", "merry",
"mild", "milky", "misty", "modest", "mossy", "muted", "narrow", "naval", "neat", "nimble",
"noble", "north", "novel", "oaken", "ochre", "olive", "onyx", "opal", "optic", "ornate",
"oval", "owed", "ozone", "pale", "pastel", "pearl", "pecan", "peppy", "pilot", "placid",
"plain", "plucky", "plum", "plush", "poised", "polar", "polished", "poplar", "prime", "proof",
"proud", "quartz", "quick", "quiet", "rainy", "rapid", "raspy", "ready", "regal", "roomy",
"rooted", "rosy", "round", "royal", "ruddy", "russet", "sage", "salty", "sandy", "satin",
"scenic", "sedge", "serene", "sheer", "silky", "silver", "sleek", "smart", "smooth", "snowy",
"snug", "solar", "solid", "south", "spry", "stark", "steady", "steel", "steep", "still",
"stocky", "stoic", "stony", "stout", "sturdy", "suede", "sunny", "supple", "sure", "tall",
"tangy", "tawny", "teal", "terse", "thick", "tidal", "tidy", "timber", "topaz", "total",
"trim", "tropic", "tulip", "upper", "urban", "vast", "velvet", "verde", "vivid", "vocal",
"warm", "waxen", "west", "whole", "wide", "wild", "wise", "witty", "woven", "young", "zealous",
"zephyr", "zesty", "zinc",
];
const NOUNS: &[&str] = &[
"acorn", "almond", "anvil", "apricot", "arbor", "atlas", "badge", "badger", "basin", "bay",
"beacon", "beam", "bell", "birch", "blade", "bloom", "bluff", "bobcat", "bolt", "breeze",
"bridge", "brook", "bunting", "burrow", "cabin", "cairn", "canyon", "cape", "cedar", "chasm",
"cliff", "clover", "coast", "cobble", "colt", "comet", "conch", "condor", "coral", "cove",
"coyote", "crane", "crater", "creek", "crest", "curlew", "daisy", "dale", "dawn", "den",
"dove", "drake", "drift", "drum", "dune", "dusk", "eagle", "eel", "egret", "elk", "emu",
"falcon", "fawn", "fennel", "fern", "ferret", "ferry", "fig", "finch", "fjord", "flicker",
"flint", "flower", "fox", "frost", "gale", "garnet", "gate", "gazelle", "geyser", "glade",
"glen", "gorge", "granite", "grove", "gull", "harbor", "hare", "haven", "hawk", "hazel",
"heath", "hedge", "heron", "hill", "hollow", "horizon", "ibis", "inlet", "isle", "ivy",
"jackal", "jasper", "juniper", "kinglet", "kitten", "knoll", "lagoon", "lake", "lantern",
"larch", "lark", "laurel", "lava", "leaf", "ledge", "lily", "linden", "lodge", "loft", "loon",
"lotus", "mantle", "maple", "marble", "marsh", "marten", "meadow", "merlin", "mill", "minnow",
"moon", "moose", "moss", "moth", "newt", "north", "nutmeg", "oak", "oasis", "obsidian",
"orbit", "orchid", "oriole", "osprey", "otter", "owl", "palm", "panther", "pass", "peach",
"peak", "pebble", "pelican", "peony", "perch", "pier", "pike", "pine", "plover", "plume",
"pond", "poppy", "prairie", "prism", "quail", "quarry", "quartz", "rain", "rampart", "raven",
"ravine", "reed", "reef", "ridge", "river", "robin", "rook", "rowan", "sage", "salmon",
"sequoia", "shore", "shrew", "shrike", "sigma", "sky", "slope", "snipe", "snow", "sparrow",
"spruce", "stag", "star", "starling", "stoat", "stone", "stork", "storm", "strand", "summit",
"sycamore", "tern", "terrace", "thistle", "thorn", "thrush", "tide", "timber", "toucan",
"trail", "trout", "tulip", "tundra", "turtle", "vale", "valley", "veranda", "violet", "viper",
"vole", "walrus", "warbler", "willow", "wolf", "wren", "yak", "zenith",
];
/// Generates a worktree name in `"adjective-noun"` format (e.g. `"calm-river"`).
///
/// Tries up to 10 random combinations, skipping any name that already appears
/// in `existing_names`. Returns `None` if no unused name is found.
pub fn generate_worktree_name(existing_names: &[&str], rng: &mut impl Rng) -> Option<String> {
let existing: HashSet<&str> = existing_names.iter().copied().collect();
for _ in 0..10 {
let adjective = ADJECTIVES[rng.random_range(0..ADJECTIVES.len())];
let noun = NOUNS[rng.random_range(0..NOUNS.len())];
let name = format!("{adjective}-{noun}");
if !existing.contains(name.as_str()) {
return Some(name);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use rand::rngs::StdRng;
#[gpui::test(iterations = 10)]
fn test_generate_worktree_name_format(mut rng: StdRng) {
let name = generate_worktree_name(&[], &mut rng).unwrap();
let (adjective, noun) = name.split_once('-').expect("name should contain a hyphen");
assert!(
ADJECTIVES.contains(&adjective),
"{adjective:?} is not in ADJECTIVES"
);
assert!(NOUNS.contains(&noun), "{noun:?} is not in NOUNS");
}
#[gpui::test(iterations = 100)]
fn test_generate_worktree_name_avoids_existing(mut rng: StdRng) {
let existing = &["swift-falcon", "calm-river", "bold-cedar"];
let name = generate_worktree_name(existing, &mut rng).unwrap();
for &branch in existing {
assert_ne!(
name, branch,
"generated name should not match an existing branch"
);
}
}
#[gpui::test]
fn test_generate_worktree_name_returns_none_when_stuck(mut rng: StdRng) {
let all_names: Vec<String> = ADJECTIVES
.iter()
.flat_map(|adj| NOUNS.iter().map(move |noun| format!("{adj}-{noun}")))
.collect();
let refs: Vec<&str> = all_names.iter().map(|s| s.as_str()).collect();
let result = generate_worktree_name(&refs, &mut rng);
assert!(result.is_none());
}
#[test]
fn test_adjectives_are_valid() {
let mut seen = HashSet::default();
for &word in ADJECTIVES {
assert!(seen.insert(word), "duplicate entry in ADJECTIVES: {word:?}");
}
for window in ADJECTIVES.windows(2) {
assert!(
window[0] < window[1],
"ADJECTIVES is not sorted: {0:?} should come before {1:?}",
window[0],
window[1],
);
}
for &word in ADJECTIVES {
assert!(
!word.contains('-'),
"ADJECTIVES entry contains a hyphen: {word:?}"
);
assert!(
word.chars().all(|c| c.is_lowercase()),
"ADJECTIVES entry is not all lowercase: {word:?}"
);
}
}
#[test]
fn test_nouns_are_valid() {
let mut seen = HashSet::default();
for &word in NOUNS {
assert!(seen.insert(word), "duplicate entry in NOUNS: {word:?}");
}
for window in NOUNS.windows(2) {
assert!(
window[0] < window[1],
"NOUNS is not sorted: {0:?} should come before {1:?}",
window[0],
window[1],
);
}
for &word in NOUNS {
assert!(
!word.contains('-'),
"NOUNS entry contains a hyphen: {word:?}"
);
assert!(
word.chars().all(|c| c.is_lowercase()),
"NOUNS entry is not all lowercase: {word:?}"
);
}
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff