logiguard fork: GPUI xdg-activation keyboard-focus serial fix
Some checks failed
Update All Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
Triage Project Sync (#84) / Sync triage project (push) Has been cancelled
release_nightly / notify_on_failure (push) Has been cancelled
release_nightly / check_style (push) Has been cancelled
release_nightly / run_tests_windows (push) Has been cancelled
release_nightly / clippy_windows (push) Has been cancelled
release_nightly / bundle_linux_aarch64 (push) Has been cancelled
release_nightly / bundle_linux_x86_64 (push) Has been cancelled
release_nightly / bundle_mac_aarch64 (push) Has been cancelled
release_nightly / bundle_mac_x86_64 (push) Has been cancelled
release_nightly / bundle_windows_aarch64 (push) Has been cancelled
release_nightly / bundle_windows_x86_64 (push) Has been cancelled
release_nightly / build_nix_linux_x86_64 (push) Has been cancelled
release_nightly / build_nix_mac_aarch64 (push) Has been cancelled
release_nightly / update_nightly_tag (push) Has been cancelled
Hotfix Review Monitor / check-hotfix-reviews (push) Has been cancelled
Stale PR Review Reminder / check-stale-prs (push) Has been cancelled
Update Weekly Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
Bump collab-staging Tag / update-collab-staging-tag (push) Has been cancelled
compliance_check / scheduled_compliance_check (push) Has been cancelled
Some checks failed
Update All Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
Triage Project Sync (#84) / Sync triage project (push) Has been cancelled
release_nightly / notify_on_failure (push) Has been cancelled
release_nightly / check_style (push) Has been cancelled
release_nightly / run_tests_windows (push) Has been cancelled
release_nightly / clippy_windows (push) Has been cancelled
release_nightly / bundle_linux_aarch64 (push) Has been cancelled
release_nightly / bundle_linux_x86_64 (push) Has been cancelled
release_nightly / bundle_mac_aarch64 (push) Has been cancelled
release_nightly / bundle_mac_x86_64 (push) Has been cancelled
release_nightly / bundle_windows_aarch64 (push) Has been cancelled
release_nightly / bundle_windows_x86_64 (push) Has been cancelled
release_nightly / build_nix_linux_x86_64 (push) Has been cancelled
release_nightly / build_nix_mac_aarch64 (push) Has been cancelled
release_nightly / update_nightly_tag (push) Has been cancelled
Hotfix Review Monitor / check-hotfix-reviews (push) Has been cancelled
Stale PR Review Reminder / check-stale-prs (push) Has been cancelled
Update Weekly Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
Bump collab-staging Tag / update-collab-staging-tag (push) Has been cancelled
compliance_check / scheduled_compliance_check (push) Has been cancelled
Single-commit orphan branch: full zed-industries/zed @ 8c74db0 source tree with a 3-file patch applied (no upstream history). Patch (crates/gpui_linux/src/linux/wayland/): - serial.rs: add SerialKind::KeyboardEnter - client.rs: store wl_keyboard.enter serial; add latest_serial_of() - window.rs: activate() uses keyboard-enter serial (Mutter focus gate) Mutter honors window activation only when the token carries the keyboard- focus serial from wl_keyboard.enter; GPUI used a stale mouse-press serial. See docs/tray-window-focus-wayland.md in logiguard. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
93
crates/git_ui/Cargo.toml
Normal file
93
crates/git_ui/Cargo.toml
Normal 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
1
crates/git_ui/LICENSE-GPL
Symbolic link
@@ -0,0 +1 @@
|
||||
../../LICENSE-GPL
|
||||
147
crates/git_ui/src/askpass_modal.rs
Normal file
147
crates/git_ui/src/askpass_modal.rs
Normal 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))
|
||||
}
|
||||
}
|
||||
439
crates/git_ui/src/blame_ui.rs
Normal file
439
crates/git_ui/src/blame_ui.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
2555
crates/git_ui/src/branch_picker.rs
Normal file
2555
crates/git_ui/src/branch_picker.rs
Normal file
File diff suppressed because it is too large
Load Diff
159
crates/git_ui/src/clone.rs
Normal file
159
crates/git_ui/src/clone.rs
Normal 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();
|
||||
}
|
||||
17
crates/git_ui/src/commit_message_prompt.txt
Normal file
17
crates/git_ui/src/commit_message_prompt.txt
Normal 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)
|
||||
642
crates/git_ui/src/commit_modal.rs
Normal file
642
crates/git_ui/src/commit_modal.rs
Normal 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)),
|
||||
)
|
||||
}
|
||||
}
|
||||
405
crates/git_ui/src/commit_tooltip.rs
Normal file
405
crates/git_ui/src/commit_tooltip.rs
Normal 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)
|
||||
}
|
||||
1227
crates/git_ui/src/commit_view.rs
Normal file
1227
crates/git_ui/src/commit_view.rs
Normal file
File diff suppressed because it is too large
Load Diff
690
crates/git_ui/src/conflict_view.rs
Normal file
690
crates/git_ui/src/conflict_view.rs
Normal 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);
|
||||
}))
|
||||
}
|
||||
}
|
||||
610
crates/git_ui/src/file_diff_view.rs
Normal file
610
crates/git_ui/src/file_diff_view.rs
Normal 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");
|
||||
});
|
||||
}
|
||||
}
|
||||
9034
crates/git_ui/src/git_panel.rs
Normal file
9034
crates/git_ui/src/git_panel.rs
Normal file
File diff suppressed because it is too large
Load Diff
83
crates/git_ui/src/git_panel_settings.rs
Normal file
83
crates/git_ui/src/git_panel_settings.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
498
crates/git_ui/src/git_picker.rs
Normal file
498
crates/git_ui/src/git_picker.rs
Normal 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
1246
crates/git_ui/src/git_ui.rs
Normal file
File diff suppressed because it is too large
Load Diff
376
crates/git_ui/src/multi_diff_view.rs
Normal file
376
crates/git_ui/src/multi_diff_view.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
239
crates/git_ui/src/picker_prompt.rs
Normal file
239
crates/git_ui/src/picker_prompt.rs
Normal 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))
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
2841
crates/git_ui/src/project_diff.rs
Normal file
2841
crates/git_ui/src/project_diff.rs
Normal file
File diff suppressed because it is too large
Load Diff
309
crates/git_ui/src/remote_output.rs
Normal file
309
crates/git_ui/src/remote_output.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
315
crates/git_ui/src/repository_selector.rs
Normal file
315
crates/git_ui/src/repository_selector.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
784
crates/git_ui/src/stash_picker.rs
Normal file
784
crates/git_ui/src/stash_picker.rs
Normal 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());
|
||||
});
|
||||
}
|
||||
}
|
||||
944
crates/git_ui/src/text_diff_view.rs
Normal file
944
crates/git_ui/src/text_diff_view.rs
Normal 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
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
171
crates/git_ui/src/worktree_names.rs
Normal file
171
crates/git_ui/src/worktree_names.rs
Normal 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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
1532
crates/git_ui/src/worktree_picker.rs
Normal file
1532
crates/git_ui/src/worktree_picker.rs
Normal file
File diff suppressed because it is too large
Load Diff
1018
crates/git_ui/src/worktree_service.rs
Normal file
1018
crates/git_ui/src/worktree_service.rs
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user