Git push/pull/fetch (#25445)
Release Notes: - N/A --------- Co-authored-by: Michael Sloan <mgsloan@gmail.com>
This commit is contained in:
parent
b1b6401ce7
commit
ff6844300e
17 changed files with 1242 additions and 180 deletions
|
@ -261,7 +261,7 @@ impl PickerDelegate for BranchListDelegate {
|
|||
.project()
|
||||
.read(cx)
|
||||
.active_repository(cx)
|
||||
.and_then(|repo| repo.read(cx).branch())
|
||||
.and_then(|repo| repo.read(cx).current_branch())
|
||||
.map(|branch| branch.name.to_string())
|
||||
})
|
||||
.ok()
|
||||
|
|
|
@ -159,7 +159,12 @@ impl CommitModal {
|
|||
let branch = git_panel
|
||||
.active_repository
|
||||
.as_ref()
|
||||
.and_then(|repo| repo.read(cx).branch().map(|b| b.name.clone()))
|
||||
.and_then(|repo| {
|
||||
repo.read(cx)
|
||||
.repository_entry
|
||||
.branch()
|
||||
.map(|b| b.name.clone())
|
||||
})
|
||||
.unwrap_or_else(|| "<no branch>".into());
|
||||
let tooltip = if git_panel.has_staged_changes() {
|
||||
"Commit staged changes"
|
||||
|
|
|
@ -4,7 +4,7 @@ use crate::repository_selector::RepositorySelectorPopoverMenu;
|
|||
use crate::{
|
||||
git_panel_settings::GitPanelSettings, git_status_icon, repository_selector::RepositorySelector,
|
||||
};
|
||||
use crate::{project_diff, ProjectDiff};
|
||||
use crate::{picker_prompt, project_diff, ProjectDiff};
|
||||
use collections::HashMap;
|
||||
use db::kvp::KEY_VALUE_STORE;
|
||||
use editor::commit_tooltip::CommitTooltip;
|
||||
|
@ -12,9 +12,9 @@ use editor::{
|
|||
scroll::ScrollbarAutoHide, Editor, EditorElement, EditorMode, EditorSettings, MultiBuffer,
|
||||
ShowScrollbar,
|
||||
};
|
||||
use git::repository::{CommitDetails, ResetMode};
|
||||
use git::repository::{Branch, CommitDetails, PushOptions, Remote, ResetMode, UpstreamTracking};
|
||||
use git::{repository::RepoPath, status::FileStatus, Commit, ToggleStaged};
|
||||
use git::{RestoreTrackedFiles, StageAll, TrashUntrackedFiles, UnstageAll};
|
||||
use git::{Push, RestoreTrackedFiles, StageAll, TrashUntrackedFiles, UnstageAll};
|
||||
use gpui::*;
|
||||
use itertools::Itertools;
|
||||
use language::{Buffer, File};
|
||||
|
@ -27,6 +27,9 @@ use project::{
|
|||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use settings::Settings as _;
|
||||
use std::cell::RefCell;
|
||||
use std::future::Future;
|
||||
use std::rc::Rc;
|
||||
use std::{collections::HashSet, path::PathBuf, sync::Arc, time::Duration, usize};
|
||||
use strum::{IntoEnumIterator, VariantNames};
|
||||
use time::OffsetDateTime;
|
||||
|
@ -34,7 +37,7 @@ use ui::{
|
|||
prelude::*, ButtonLike, Checkbox, ContextMenu, Divider, DividerColor, ElevationIndex, ListItem,
|
||||
ListItemSpacing, Scrollbar, ScrollbarState, Tooltip,
|
||||
};
|
||||
use util::{maybe, ResultExt, TryFutureExt};
|
||||
use util::{maybe, post_inc, ResultExt, TryFutureExt};
|
||||
use workspace::{
|
||||
dock::{DockPosition, Panel, PanelEvent},
|
||||
notifications::{DetachAndPromptErr, NotificationId},
|
||||
|
@ -174,7 +177,11 @@ struct PendingOperation {
|
|||
op_id: usize,
|
||||
}
|
||||
|
||||
type RemoteOperations = Rc<RefCell<HashSet<u32>>>;
|
||||
|
||||
pub struct GitPanel {
|
||||
remote_operation_id: u32,
|
||||
pending_remote_operations: RemoteOperations,
|
||||
pub(crate) active_repository: Option<Entity<Repository>>,
|
||||
commit_editor: Entity<Editor>,
|
||||
conflicted_count: usize,
|
||||
|
@ -206,6 +213,17 @@ pub struct GitPanel {
|
|||
modal_open: bool,
|
||||
}
|
||||
|
||||
struct RemoteOperationGuard {
|
||||
id: u32,
|
||||
pending_remote_operations: RemoteOperations,
|
||||
}
|
||||
|
||||
impl Drop for RemoteOperationGuard {
|
||||
fn drop(&mut self) {
|
||||
self.pending_remote_operations.borrow_mut().remove(&self.id);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn commit_message_editor(
|
||||
commit_message_buffer: Entity<Buffer>,
|
||||
project: Entity<Project>,
|
||||
|
@ -286,6 +304,8 @@ impl GitPanel {
|
|||
cx.new(|cx| RepositorySelector::new(project.clone(), window, cx));
|
||||
|
||||
let mut git_panel = Self {
|
||||
pending_remote_operations: Default::default(),
|
||||
remote_operation_id: 0,
|
||||
active_repository,
|
||||
commit_editor,
|
||||
conflicted_count: 0,
|
||||
|
@ -341,6 +361,16 @@ impl GitPanel {
|
|||
cx.notify();
|
||||
}
|
||||
|
||||
fn start_remote_operation(&mut self) -> RemoteOperationGuard {
|
||||
let id = post_inc(&mut self.remote_operation_id);
|
||||
self.pending_remote_operations.borrow_mut().insert(id);
|
||||
|
||||
RemoteOperationGuard {
|
||||
id,
|
||||
pending_remote_operations: self.pending_remote_operations.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn serialize(&mut self, cx: &mut Context<Self>) {
|
||||
let width = self.width;
|
||||
self.pending_serialization = cx.background_spawn(
|
||||
|
@ -1121,23 +1151,44 @@ impl GitPanel {
|
|||
let Some(repo) = self.active_repository.clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// TODO: Use git merge-base to find the upstream and main branch split
|
||||
let confirmation = Task::ready(true);
|
||||
// let confirmation = if self.commit_editor.read(cx).is_empty(cx) {
|
||||
// Task::ready(true)
|
||||
// } else {
|
||||
// let prompt = window.prompt(
|
||||
// PromptLevel::Warning,
|
||||
// "Uncomitting will replace the current commit message with the previous commit's message",
|
||||
// None,
|
||||
// &["Ok", "Cancel"],
|
||||
// cx,
|
||||
// );
|
||||
// cx.spawn(|_, _| async move { prompt.await.is_ok_and(|i| i == 0) })
|
||||
// };
|
||||
|
||||
let prior_head = self.load_commit_details("HEAD", cx);
|
||||
|
||||
let task = cx.spawn(|_, mut cx| async move {
|
||||
let prior_head = prior_head.await?;
|
||||
|
||||
repo.update(&mut cx, |repo, _| repo.reset("HEAD^", ResetMode::Soft))?
|
||||
.await??;
|
||||
|
||||
Ok(prior_head)
|
||||
});
|
||||
|
||||
let task = cx.spawn_in(window, |this, mut cx| async move {
|
||||
let result = task.await;
|
||||
let result = maybe!(async {
|
||||
if !confirmation.await {
|
||||
Ok(None)
|
||||
} else {
|
||||
let prior_head = prior_head.await?;
|
||||
|
||||
repo.update(&mut cx, |repo, _| repo.reset("HEAD^", ResetMode::Soft))?
|
||||
.await??;
|
||||
|
||||
Ok(Some(prior_head))
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
this.update_in(&mut cx, |this, window, cx| {
|
||||
this.pending_commit.take();
|
||||
match result {
|
||||
Ok(prior_commit) => {
|
||||
Ok(None) => {}
|
||||
Ok(Some(prior_commit)) => {
|
||||
this.commit_editor.update(cx, |editor, cx| {
|
||||
editor.set_text(prior_commit.message, window, cx)
|
||||
});
|
||||
|
@ -1151,6 +1202,125 @@ impl GitPanel {
|
|||
self.pending_commit = Some(task);
|
||||
}
|
||||
|
||||
fn fetch(&mut self, _: &git::Fetch, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(repo) = self.active_repository.clone() else {
|
||||
return;
|
||||
};
|
||||
let guard = self.start_remote_operation();
|
||||
let fetch = repo.read(cx).fetch();
|
||||
cx.spawn(|_, _| async move {
|
||||
fetch.await??;
|
||||
drop(guard);
|
||||
anyhow::Ok(())
|
||||
})
|
||||
.detach_and_log_err(cx);
|
||||
}
|
||||
|
||||
fn pull(&mut self, _: &git::Pull, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let guard = self.start_remote_operation();
|
||||
let remote = self.get_current_remote(window, cx);
|
||||
cx.spawn(move |this, mut cx| async move {
|
||||
let remote = remote.await?;
|
||||
|
||||
this.update(&mut cx, |this, cx| {
|
||||
let Some(repo) = this.active_repository.clone() else {
|
||||
return Err(anyhow::anyhow!("No active repository"));
|
||||
};
|
||||
|
||||
let Some(branch) = repo.read(cx).current_branch() else {
|
||||
return Err(anyhow::anyhow!("No active branch"));
|
||||
};
|
||||
|
||||
Ok(repo.read(cx).pull(branch.name.clone(), remote.name))
|
||||
})??
|
||||
.await??;
|
||||
|
||||
drop(guard);
|
||||
anyhow::Ok(())
|
||||
})
|
||||
.detach_and_log_err(cx);
|
||||
}
|
||||
|
||||
fn push(&mut self, action: &git::Push, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let guard = self.start_remote_operation();
|
||||
let options = action.options;
|
||||
let remote = self.get_current_remote(window, cx);
|
||||
cx.spawn(move |this, mut cx| async move {
|
||||
let remote = remote.await?;
|
||||
|
||||
this.update(&mut cx, |this, cx| {
|
||||
let Some(repo) = this.active_repository.clone() else {
|
||||
return Err(anyhow::anyhow!("No active repository"));
|
||||
};
|
||||
|
||||
let Some(branch) = repo.read(cx).current_branch() else {
|
||||
return Err(anyhow::anyhow!("No active branch"));
|
||||
};
|
||||
|
||||
Ok(repo
|
||||
.read(cx)
|
||||
.push(branch.name.clone(), remote.name, options))
|
||||
})??
|
||||
.await??;
|
||||
|
||||
drop(guard);
|
||||
anyhow::Ok(())
|
||||
})
|
||||
.detach_and_log_err(cx);
|
||||
}
|
||||
|
||||
fn get_current_remote(
|
||||
&mut self,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl Future<Output = Result<Remote>> {
|
||||
let repo = self.active_repository.clone();
|
||||
let workspace = self.workspace.clone();
|
||||
let mut cx = window.to_async(cx);
|
||||
|
||||
async move {
|
||||
let Some(repo) = repo else {
|
||||
return Err(anyhow::anyhow!("No active repository"));
|
||||
};
|
||||
|
||||
let mut current_remotes: Vec<Remote> = repo
|
||||
.update(&mut cx, |repo, cx| {
|
||||
let Some(current_branch) = repo.current_branch() else {
|
||||
return Err(anyhow::anyhow!("No active branch"));
|
||||
};
|
||||
|
||||
Ok(repo.get_remotes(Some(current_branch.name.to_string()), cx))
|
||||
})??
|
||||
.await?;
|
||||
|
||||
if current_remotes.len() == 0 {
|
||||
return Err(anyhow::anyhow!("No active remote"));
|
||||
} else if current_remotes.len() == 1 {
|
||||
return Ok(current_remotes.pop().unwrap());
|
||||
} else {
|
||||
let current_remotes: Vec<_> = current_remotes
|
||||
.into_iter()
|
||||
.map(|remotes| remotes.name)
|
||||
.collect();
|
||||
let selection = cx
|
||||
.update(|window, cx| {
|
||||
picker_prompt::prompt(
|
||||
"Pick which remote to push to",
|
||||
current_remotes.clone(),
|
||||
workspace,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
})?
|
||||
.await?;
|
||||
|
||||
return Ok(Remote {
|
||||
name: current_remotes[selection].clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn potential_co_authors(&self, cx: &App) -> Vec<(String, String)> {
|
||||
let mut new_co_authors = Vec::new();
|
||||
let project = self.project.read(cx);
|
||||
|
@ -1591,15 +1761,23 @@ impl GitPanel {
|
|||
.color(Color::Muted),
|
||||
)
|
||||
.child(self.render_repository_selector(cx))
|
||||
.child(div().flex_grow())
|
||||
.child(div().flex_grow()) // spacer
|
||||
.child(
|
||||
Button::new("diff", "+/-")
|
||||
.tooltip(Tooltip::for_action_title("Open diff", &Diff))
|
||||
.on_click(|_, _, cx| {
|
||||
cx.defer(|cx| {
|
||||
cx.dispatch_action(&Diff);
|
||||
})
|
||||
}),
|
||||
div()
|
||||
.h_flex()
|
||||
.gap_1()
|
||||
.children(self.render_spinner(cx))
|
||||
.children(self.render_sync_button(cx))
|
||||
.children(self.render_pull_button(cx))
|
||||
.child(
|
||||
Button::new("diff", "+/-")
|
||||
.tooltip(Tooltip::for_action_title("Open diff", &Diff))
|
||||
.on_click(|_, _, cx| {
|
||||
cx.defer(|cx| {
|
||||
cx.dispatch_action(&Diff);
|
||||
})
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
|
@ -1607,6 +1785,74 @@ impl GitPanel {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn render_spinner(&self, _cx: &mut Context<Self>) -> Option<impl IntoElement> {
|
||||
(!self.pending_remote_operations.borrow().is_empty()).then(|| {
|
||||
Icon::new(IconName::ArrowCircle)
|
||||
.size(IconSize::XSmall)
|
||||
.color(Color::Info)
|
||||
.with_animation(
|
||||
"arrow-circle",
|
||||
Animation::new(Duration::from_secs(2)).repeat(),
|
||||
|icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
|
||||
)
|
||||
.into_any_element()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn render_sync_button(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> {
|
||||
let active_repository = self.project.read(cx).active_repository(cx);
|
||||
active_repository.as_ref().map(|_| {
|
||||
panel_filled_button("Fetch")
|
||||
.icon(IconName::ArrowCircle)
|
||||
.icon_size(IconSize::Small)
|
||||
.icon_color(Color::Muted)
|
||||
.icon_position(IconPosition::Start)
|
||||
.tooltip(Tooltip::for_action_title("git fetch", &git::Fetch))
|
||||
.on_click(
|
||||
cx.listener(move |this, _, window, cx| this.fetch(&git::Fetch, window, cx)),
|
||||
)
|
||||
.into_any_element()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn render_pull_button(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> {
|
||||
let active_repository = self.project.read(cx).active_repository(cx);
|
||||
active_repository
|
||||
.as_ref()
|
||||
.and_then(|repo| repo.read(cx).current_branch())
|
||||
.and_then(|branch| {
|
||||
branch.upstream.as_ref().map(|upstream| {
|
||||
let status = &upstream.tracking;
|
||||
|
||||
let disabled = status.is_gone();
|
||||
|
||||
panel_filled_button(match status {
|
||||
git::repository::UpstreamTracking::Tracked(status) if status.behind > 0 => {
|
||||
format!("Pull ({})", status.behind)
|
||||
}
|
||||
_ => "Pull".to_string(),
|
||||
})
|
||||
.icon(IconName::ArrowDown)
|
||||
.icon_size(IconSize::Small)
|
||||
.icon_color(Color::Muted)
|
||||
.icon_position(IconPosition::Start)
|
||||
.disabled(status.is_gone())
|
||||
.tooltip(move |window, cx| {
|
||||
if disabled {
|
||||
Tooltip::simple("Upstream is gone", cx)
|
||||
} else {
|
||||
// TODO: Add <origin> and <branch> argument substitutions to this
|
||||
Tooltip::for_action("git pull", &git::Pull, window, cx)
|
||||
}
|
||||
})
|
||||
.on_click(
|
||||
cx.listener(move |this, _, window, cx| this.pull(&git::Pull, window, cx)),
|
||||
)
|
||||
.into_any_element()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub fn render_repository_selector(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let active_repository = self.project.read(cx).active_repository(cx);
|
||||
let repository_display_name = active_repository
|
||||
|
@ -1679,18 +1925,19 @@ impl GitPanel {
|
|||
&& self.pending_commit.is_none()
|
||||
&& !editor.read(cx).is_empty(cx)
|
||||
&& self.has_write_access(cx);
|
||||
|
||||
let panel_editor_style = panel_editor_style(true, window, cx);
|
||||
let enable_coauthors = self.render_co_authors(cx);
|
||||
|
||||
let tooltip = if self.has_staged_changes() {
|
||||
"Commit staged changes"
|
||||
"git commit"
|
||||
} else {
|
||||
"Commit changes to tracked files"
|
||||
"git commit --all"
|
||||
};
|
||||
let title = if self.has_staged_changes() {
|
||||
"Commit"
|
||||
} else {
|
||||
"Commit All"
|
||||
"Commit Tracked"
|
||||
};
|
||||
let editor_focus_handle = self.commit_editor.focus_handle(cx);
|
||||
|
||||
|
@ -1706,7 +1953,7 @@ impl GitPanel {
|
|||
let branch = self
|
||||
.active_repository
|
||||
.as_ref()
|
||||
.and_then(|repo| repo.read(cx).branch().map(|b| b.name.clone()))
|
||||
.and_then(|repo| repo.read(cx).current_branch().map(|b| b.name.clone()))
|
||||
.unwrap_or_else(|| "<no branch>".into());
|
||||
|
||||
let branch_selector = Button::new("branch-selector", branch)
|
||||
|
@ -1769,24 +2016,9 @@ impl GitPanel {
|
|||
|
||||
fn render_previous_commit(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> {
|
||||
let active_repository = self.active_repository.as_ref()?;
|
||||
let branch = active_repository.read(cx).branch()?;
|
||||
let branch = active_repository.read(cx).current_branch()?;
|
||||
let commit = branch.most_recent_commit.as_ref()?.clone();
|
||||
|
||||
if branch.upstream.as_ref().is_some_and(|upstream| {
|
||||
if let Some(tracking) = &upstream.tracking {
|
||||
tracking.ahead == 0
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}) {
|
||||
return None;
|
||||
}
|
||||
let tooltip = if self.has_staged_changes() {
|
||||
"git reset HEAD^ --soft"
|
||||
} else {
|
||||
"git reset HEAD^"
|
||||
};
|
||||
|
||||
let this = cx.entity();
|
||||
Some(
|
||||
h_flex()
|
||||
|
@ -1826,9 +2058,17 @@ impl GitPanel {
|
|||
.icon_size(IconSize::Small)
|
||||
.icon_color(Color::Muted)
|
||||
.icon_position(IconPosition::Start)
|
||||
.tooltip(Tooltip::for_action_title(tooltip, &git::Uncommit))
|
||||
.tooltip(Tooltip::for_action_title(
|
||||
if self.has_staged_changes() {
|
||||
"git reset HEAD^ --soft"
|
||||
} else {
|
||||
"git reset HEAD^"
|
||||
},
|
||||
&git::Uncommit,
|
||||
))
|
||||
.on_click(cx.listener(|this, _, window, cx| this.uncommit(window, cx))),
|
||||
),
|
||||
)
|
||||
.child(self.render_push_button(branch, cx)),
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -2249,6 +2489,69 @@ impl GitPanel {
|
|||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_push_button(&self, branch: &Branch, cx: &Context<Self>) -> AnyElement {
|
||||
let mut disabled = false;
|
||||
|
||||
// TODO: Add <origin> and <branch> argument substitutions to this
|
||||
let button: SharedString;
|
||||
let tooltip: SharedString;
|
||||
let action: Option<Push>;
|
||||
if let Some(upstream) = &branch.upstream {
|
||||
match upstream.tracking {
|
||||
UpstreamTracking::Gone => {
|
||||
button = "Republish".into();
|
||||
tooltip = "git push --set-upstream".into();
|
||||
action = Some(git::Push {
|
||||
options: Some(PushOptions::SetUpstream),
|
||||
});
|
||||
}
|
||||
UpstreamTracking::Tracked(tracking) => {
|
||||
if tracking.behind > 0 {
|
||||
disabled = true;
|
||||
button = "Push".into();
|
||||
tooltip = "Upstream is ahead of local branch".into();
|
||||
action = None;
|
||||
} else if tracking.ahead > 0 {
|
||||
button = format!("Push ({})", tracking.ahead).into();
|
||||
tooltip = "git push".into();
|
||||
action = Some(git::Push { options: None });
|
||||
} else {
|
||||
disabled = true;
|
||||
button = "Push".into();
|
||||
tooltip = "Upstream matches local branch".into();
|
||||
action = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
button = "Publish".into();
|
||||
tooltip = "git push --set-upstream".into();
|
||||
action = Some(git::Push {
|
||||
options: Some(PushOptions::SetUpstream),
|
||||
});
|
||||
};
|
||||
|
||||
panel_filled_button(button)
|
||||
.icon(IconName::ArrowUp)
|
||||
.icon_size(IconSize::Small)
|
||||
.icon_color(Color::Muted)
|
||||
.icon_position(IconPosition::Start)
|
||||
.disabled(disabled)
|
||||
.when_some(action, |this, action| {
|
||||
this.on_click(
|
||||
cx.listener(move |this, _, window, cx| this.push(&action, window, cx)),
|
||||
)
|
||||
})
|
||||
.tooltip(move |window, cx| {
|
||||
if let Some(action) = action.as_ref() {
|
||||
Tooltip::for_action(tooltip.clone(), action, window, cx)
|
||||
} else {
|
||||
Tooltip::simple(tooltip.clone(), cx)
|
||||
}
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn has_write_access(&self, cx: &App) -> bool {
|
||||
!self.project.read(cx).is_read_only(cx)
|
||||
}
|
||||
|
@ -2298,6 +2601,9 @@ impl Render for GitPanel {
|
|||
.on_action(cx.listener(Self::unstage_all))
|
||||
.on_action(cx.listener(Self::discard_tracked_changes))
|
||||
.on_action(cx.listener(Self::clean_all))
|
||||
.on_action(cx.listener(Self::fetch))
|
||||
.on_action(cx.listener(Self::pull))
|
||||
.on_action(cx.listener(Self::push))
|
||||
.when(has_write_access && has_co_authors, |git_panel| {
|
||||
git_panel.on_action(cx.listener(Self::toggle_fill_co_authors))
|
||||
})
|
||||
|
@ -2314,17 +2620,21 @@ impl Render for GitPanel {
|
|||
.size_full()
|
||||
.overflow_hidden()
|
||||
.bg(ElevationIndex::Surface.bg(cx))
|
||||
.child(if has_entries {
|
||||
.child(
|
||||
v_flex()
|
||||
.size_full()
|
||||
.children(self.render_panel_header(window, cx))
|
||||
.child(self.render_entries(has_write_access, window, cx))
|
||||
.map(|this| {
|
||||
if has_entries {
|
||||
this.child(self.render_entries(has_write_access, window, cx))
|
||||
} else {
|
||||
this.child(self.render_empty_state(cx).into_any_element())
|
||||
}
|
||||
})
|
||||
.children(self.render_previous_commit(cx))
|
||||
.child(self.render_commit_editor(window, cx))
|
||||
.into_any_element()
|
||||
} else {
|
||||
self.render_empty_state(cx).into_any_element()
|
||||
})
|
||||
.into_any_element(),
|
||||
)
|
||||
.children(self.context_menu.as_ref().map(|(menu, position, _)| {
|
||||
deferred(
|
||||
anchored()
|
||||
|
|
|
@ -9,6 +9,7 @@ pub mod branch_picker;
|
|||
mod commit_modal;
|
||||
pub mod git_panel;
|
||||
mod git_panel_settings;
|
||||
pub mod picker_prompt;
|
||||
pub mod project_diff;
|
||||
pub mod repository_selector;
|
||||
|
||||
|
|
235
crates/git_ui/src/picker_prompt.rs
Normal file
235
crates/git_ui/src/picker_prompt.rs
Normal file
|
@ -0,0 +1,235 @@
|
|||
use anyhow::{anyhow, Result};
|
||||
use futures::channel::oneshot;
|
||||
use fuzzy::{StringMatch, StringMatchCandidate};
|
||||
|
||||
use core::cmp;
|
||||
use gpui::{
|
||||
rems, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
|
||||
InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription,
|
||||
Task, WeakEntity, Window,
|
||||
};
|
||||
use picker::{Picker, PickerDelegate};
|
||||
use std::sync::Arc;
|
||||
use ui::{prelude::*, HighlightedLabel, ListItem, ListItemSpacing};
|
||||
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<Result<usize>> {
|
||||
if options.is_empty() {
|
||||
return Task::ready(Err(anyhow!("No options")));
|
||||
}
|
||||
let prompt = prompt.to_string().into();
|
||||
|
||||
window.spawn(cx, |mut cx| async move {
|
||||
// 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(&mut cx, |workspace, window, cx| {
|
||||
workspace.toggle_modal(window, cx, |window, cx| {
|
||||
PickerPrompt::new(delegate, 34., window, cx)
|
||||
})
|
||||
})?;
|
||||
|
||||
rx.await?
|
||||
})
|
||||
}
|
||||
|
||||
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<Result<usize>>>,
|
||||
}
|
||||
|
||||
impl PickerPromptDelegate {
|
||||
pub fn new(
|
||||
prompt: Arc<str>,
|
||||
options: Vec<SharedString>,
|
||||
tx: oneshot::Sender<Result<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, move |picker, mut cx| async move {
|
||||
let candidates = picker.update(&mut 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,
|
||||
10000,
|
||||
&Default::default(),
|
||||
cx.background_executor().clone(),
|
||||
)
|
||||
.await
|
||||
};
|
||||
picker
|
||||
.update(&mut 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(Ok(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[ix];
|
||||
let shortened_option = util::truncate_and_trailoff(&hit.string, self.max_match_length);
|
||||
|
||||
Some(
|
||||
ListItem::new(SharedString::from(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))
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue