build: Decouple git_ui from recent_projects (#48062)

- **git_ui: Decouple git_ui from the recent_projects crate**
- **Move git_ui closer to editor**

Release Notes:

- N/A
This commit is contained in:
Piotr Osiewicz 2026-02-02 13:30:07 +01:00 committed by GitHub
parent e74ceba097
commit e99c11dee6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 670 additions and 615 deletions

31
Cargo.lock generated
View file

@ -7294,8 +7294,8 @@ dependencies = [
"project",
"prompt_store",
"rand 0.9.2",
"recent_projects",
"remote",
"remote_connection",
"schemars",
"serde",
"serde_json",
@ -11456,10 +11456,7 @@ dependencies = [
name = "panel"
version = "0.1.0"
dependencies = [
"editor",
"gpui",
"settings",
"theme",
"ui",
"workspace",
]
@ -13495,7 +13492,6 @@ version = "0.1.0"
dependencies = [
"anyhow",
"askpass",
"auto_update",
"dap",
"db",
"dev_container",
@ -13510,7 +13506,6 @@ dependencies = [
"indoc",
"language",
"log",
"markdown",
"menu",
"node_runtime",
"open_path_prompt",
@ -13520,6 +13515,7 @@ dependencies = [
"project",
"release_channel",
"remote",
"remote_connection",
"remote_server",
"semver",
"serde",
@ -13528,7 +13524,6 @@ dependencies = [
"smol",
"task",
"telemetry",
"theme",
"ui",
"util",
"windows-registry 0.6.1",
@ -13708,6 +13703,28 @@ dependencies = [
"which 6.0.3",
]
[[package]]
name = "remote_connection"
version = "0.1.0"
dependencies = [
"anyhow",
"askpass",
"auto_update",
"futures 0.3.31",
"gpui",
"log",
"markdown",
"menu",
"release_channel",
"remote",
"semver",
"settings",
"theme",
"ui",
"ui_input",
"workspace",
]
[[package]]
name = "remote_server"
version = "0.1.0"

View file

@ -144,6 +144,7 @@ members = [
"crates/release_channel",
"crates/scheduler",
"crates/remote",
"crates/remote_connection",
"crates/remote_server",
"crates/repl",
"crates/reqwest_client",
@ -383,6 +384,7 @@ recent_projects = { path = "crates/recent_projects" }
refineable = { path = "crates/refineable" }
release_channel = { path = "crates/release_channel" }
remote = { path = "crates/remote" }
remote_connection = { path = "crates/remote_connection" }
remote_server = { path = "crates/remote_server" }
repl = { path = "crates/repl" }
reqwest_client = { path = "crates/reqwest_client" }

View file

@ -27879,6 +27879,12 @@ impl ui_input::ErasedEditor for ErasedEditorImpl {
(callback)(event, window, cx);
})
}
fn set_masked(&self, masked: bool, _window: &mut Window, cx: &mut App) {
self.0.update(cx, |editor, cx| {
editor.set_masked(masked, cx);
});
}
}
impl<T> Default for InvalidationStack<T> {
fn default() -> Self {

View file

@ -13,7 +13,7 @@ name = "git_ui"
path = "src/git_ui.rs"
[features]
test-support = ["multi_buffer/test-support", "recent_projects/test-support"]
test-support = ["multi_buffer/test-support", "remote_connection/test-support"]
[dependencies]
agent_settings.workspace = true
@ -45,7 +45,7 @@ panel.workspace = true
picker.workspace = true
project.workspace = true
prompt_store.workspace = true
recent_projects.workspace = true
remote_connection.workspace = true
remote.workspace = true
schemars.workspace = true
serde.workspace = true
@ -82,7 +82,7 @@ settings = { workspace = true, features = ["test-support"] }
unindent.workspace = true
workspace = { workspace = true, features = ["test-support"] }
zlog.workspace = true
recent_projects = { workspace = true, features = ["test-support"] }
remote_connection = { workspace = true, features = ["test-support"] }
[package.metadata.cargo-machete]
ignored = ["tracing"]

View file

@ -1,8 +1,8 @@
use crate::branch_picker::{self, BranchList};
use crate::git_panel::{GitPanel, commit_message_editor};
use crate::git_panel::{GitPanel, commit_message_editor, panel_editor_style};
use git::repository::CommitOptions;
use git::{Amend, Commit, GenerateCommitMessage, Signoff};
use panel::{panel_button, panel_editor_style};
use panel::panel_button;
use project::DisableAiSettings;
use settings::Settings;
use ui::{

View file

@ -15,11 +15,11 @@ use askpass::AskPassDelegate;
use cloud_llm_client::CompletionIntent;
use collections::{BTreeMap, HashMap, HashSet};
use db::kvp::KEY_VALUE_STORE;
use editor::RewrapOptions;
use editor::{
Direction, Editor, EditorElement, EditorMode, MultiBuffer, MultiBufferOffset,
actions::ExpandAllDiffHunks,
};
use editor::{EditorStyle, RewrapOptions};
use futures::StreamExt as _;
use git::commit::ParsedCommitMessage;
use git::repository::{
@ -37,8 +37,8 @@ use git::{
use gpui::{
Action, AsyncApp, AsyncWindowContext, Bounds, ClickEvent, Corner, DismissEvent, Empty, Entity,
EventEmitter, FocusHandle, Focusable, KeyContext, MouseButton, MouseDownEvent, Point,
PromptLevel, ScrollStrategy, Subscription, Task, UniformListScrollHandle, WeakEntity, actions,
anchored, deferred, point, size, uniform_list,
PromptLevel, ScrollStrategy, Subscription, Task, TextStyle, UniformListScrollHandle,
WeakEntity, actions, anchored, deferred, point, size, uniform_list,
};
use itertools::Itertools;
use language::{Buffer, File};
@ -48,10 +48,7 @@ use language_model::{
use menu;
use multi_buffer::ExcerptInfo;
use notifications::status_toast::{StatusToast, ToastIcon};
use panel::{
PanelHeader, panel_button, panel_editor_container, panel_editor_style, panel_filled_button,
panel_icon_button,
};
use panel::{PanelHeader, panel_button, panel_filled_button, panel_icon_button};
use project::{
Fs, Project, ProjectPath,
git_store::{GitStoreEvent, Repository, RepositoryEvent, RepositoryId, pending_op},
@ -65,6 +62,7 @@ use std::ops::Range;
use std::path::Path;
use std::{sync::Arc, time::Duration, usize};
use strum::{IntoEnumIterator, VariantNames};
use theme::ThemeSettings;
use time::OffsetDateTime;
use ui::{
ButtonLike, Checkbox, CommonAnimationExt, ContextMenu, ElevationIndex, IndentGuideColors,
@ -5600,6 +5598,55 @@ impl Panel for GitPanel {
impl PanelHeader for GitPanel {}
pub fn panel_editor_container(_window: &mut Window, cx: &mut App) -> Div {
v_flex()
.size_full()
.gap(px(8.))
.p_2()
.bg(cx.theme().colors().editor_background)
}
pub(crate) fn panel_editor_style(monospace: bool, window: &Window, cx: &App) -> EditorStyle {
let settings = ThemeSettings::get_global(cx);
let font_size = TextSize::Small.rems(cx).to_pixels(window.rem_size());
let (font_family, font_fallbacks, font_features, font_weight, line_height) = if monospace {
(
settings.buffer_font.family.clone(),
settings.buffer_font.fallbacks.clone(),
settings.buffer_font.features.clone(),
settings.buffer_font.weight,
font_size * settings.buffer_line_height.value(),
)
} else {
(
settings.ui_font.family.clone(),
settings.ui_font.fallbacks.clone(),
settings.ui_font.features.clone(),
settings.ui_font.weight,
window.line_height(),
)
};
EditorStyle {
background: cx.theme().colors().editor_background,
local_player: cx.theme().players().local(),
text: TextStyle {
color: cx.theme().colors().text,
font_family,
font_fallbacks,
font_features,
font_size: TextSize::Small.rems(cx).into(),
font_weight,
line_height: line_height.into(),
..Default::default()
},
syntax: cx.theme().syntax().clone(),
..Default::default()
}
}
struct GitPanelMessageTooltip {
commit_tooltip: Option<Entity<CommitTooltip>>,
}

View file

@ -15,8 +15,8 @@ use project::{
git_store::Repository,
trusted_worktrees::{PathTrust, TrustedWorktrees},
};
use recent_projects::{RemoteConnectionModal, connect};
use remote::{RemoteConnectionOptions, remote_client::ConnectionIdentifier};
use remote_connection::{RemoteConnectionModal, connect};
use std::{path::PathBuf, sync::Arc};
use ui::{HighlightedLabel, KeyBinding, ListItem, ListItemSpacing, prelude::*};
use util::ResultExt;

View file

@ -12,9 +12,6 @@ workspace = true
path = "src/panel.rs"
[dependencies]
editor.workspace = true
gpui.workspace = true
settings.workspace = true
theme.workspace = true
ui.workspace = true
workspace.workspace = true

View file

@ -1,8 +1,5 @@
//! # panel
use editor::{Editor, EditorElement, EditorStyle};
use gpui::{Entity, TextStyle, actions};
use settings::Settings;
use theme::ThemeSettings;
use gpui::actions;
use ui::{Tab, prelude::*};
actions!(
@ -76,61 +73,3 @@ pub fn panel_icon_button(id: impl Into<SharedString>, icon: IconName) -> ui::Ico
pub fn panel_filled_icon_button(id: impl Into<SharedString>, icon: IconName) -> ui::IconButton {
panel_icon_button(id, icon).style(ui::ButtonStyle::Filled)
}
pub fn panel_editor_container(_window: &mut Window, cx: &mut App) -> Div {
v_flex()
.size_full()
.gap(px(8.))
.p_2()
.bg(cx.theme().colors().editor_background)
}
pub fn panel_editor_style(monospace: bool, window: &Window, cx: &App) -> EditorStyle {
let settings = ThemeSettings::get_global(cx);
let font_size = TextSize::Small.rems(cx).to_pixels(window.rem_size());
let (font_family, font_fallbacks, font_features, font_weight, line_height) = if monospace {
(
settings.buffer_font.family.clone(),
settings.buffer_font.fallbacks.clone(),
settings.buffer_font.features.clone(),
settings.buffer_font.weight,
font_size * settings.buffer_line_height.value(),
)
} else {
(
settings.ui_font.family.clone(),
settings.ui_font.fallbacks.clone(),
settings.ui_font.features.clone(),
settings.ui_font.weight,
window.line_height(),
)
};
EditorStyle {
background: cx.theme().colors().editor_background,
local_player: cx.theme().players().local(),
text: TextStyle {
color: cx.theme().colors().text,
font_family,
font_fallbacks,
font_features,
font_size: TextSize::Small.rems(cx).into(),
font_weight,
line_height: line_height.into(),
..Default::default()
},
syntax: cx.theme().syntax().clone(),
..Default::default()
}
}
pub fn panel_editor_element(
editor: &Entity<Editor>,
monospace: bool,
window: &mut Window,
cx: &mut App,
) -> EditorElement {
EditorElement::new(editor, panel_editor_style(monospace, window, cx))
}

View file

@ -19,7 +19,6 @@ test-support = ["remote/test-support", "project/test-support", "workspace/test-s
[dependencies]
anyhow.workspace = true
askpass.workspace = true
auto_update.workspace = true
db.workspace = true
dev_container.workspace = true
editor.workspace = true
@ -29,7 +28,6 @@ fuzzy.workspace = true
gpui.workspace = true
language.workspace = true
log.workspace = true
markdown.workspace = true
menu.workspace = true
node_runtime.workspace = true
open_path_prompt.workspace = true
@ -39,6 +37,7 @@ picker.workspace = true
project.workspace = true
release_channel.workspace = true
remote.workspace = true
remote_connection.workspace = true
semver.workspace = true
serde.workspace = true
serde_json.workspace = true
@ -46,7 +45,6 @@ settings.workspace = true
smol.workspace = true
task.workspace = true
telemetry.workspace = true
theme.workspace = true
ui.workspace = true
util.workspace = true
workspace.workspace = true

View file

@ -11,7 +11,8 @@ mod wsl_picker;
use dev_container::start_dev_container;
use remote::RemoteConnectionOptions;
pub use remote_connections::{RemoteConnectionModal, connect, open_remote_project};
pub use remote_connection::{RemoteConnectionModal, connect};
pub use remote_connections::open_remote_project;
use disconnected_overlay::DisconnectedOverlay;
use fuzzy::{StringMatch, StringMatchCandidate};

View file

@ -5,33 +5,26 @@ use std::{
use anyhow::{Context as _, Result};
use askpass::EncryptedPassword;
use auto_update::AutoUpdater;
use editor::Editor;
use extension_host::ExtensionStore;
use futures::{FutureExt as _, channel::oneshot, select};
use gpui::{
AnyWindowHandle, App, AsyncApp, DismissEvent, Entity, EventEmitter, Focusable, FontFeatures,
ParentElement as _, PromptLevel, Render, SharedString, Task, TextStyleRefinement, WeakEntity,
};
use gpui::{AppContext, AsyncApp, PromptLevel};
use language::{CursorShape, Point};
use markdown::{Markdown, MarkdownElement, MarkdownStyle};
use language::Point;
use project::trusted_worktrees;
use release_channel::ReleaseChannel;
use remote::{
ConnectionIdentifier, DockerConnectionOptions, Interactive, RemoteClient, RemoteConnection,
RemoteConnectionOptions, RemotePlatform, SshConnectionOptions,
DockerConnectionOptions, Interactive, RemoteConnection, RemoteConnectionOptions,
SshConnectionOptions,
};
use semver::Version;
pub use settings::SshConnection;
use settings::{DevContainerConnection, ExtendingVec, RegisterSetting, Settings, WslConnection};
use theme::ThemeSettings;
use ui::{
ActiveTheme, Color, CommonAnimationExt, Context, InteractiveElement, IntoElement, KeyBinding,
LabelCommon, ListItem, Styled, Window, prelude::*,
};
use util::paths::PathWithPosition;
use workspace::{AppState, ModalView, Workspace};
use workspace::{AppState, Workspace};
pub use remote_connection::{
RemoteClientDelegate, RemoteConnectionModal, RemoteConnectionPrompt, SshConnectionHeader,
connect,
};
#[derive(RegisterSetting)]
pub struct RemoteSettings {
@ -129,505 +122,6 @@ impl Settings for RemoteSettings {
}
}
pub struct RemoteConnectionPrompt {
connection_string: SharedString,
nickname: Option<SharedString>,
is_wsl: bool,
is_devcontainer: bool,
status_message: Option<SharedString>,
prompt: Option<(Entity<Markdown>, oneshot::Sender<EncryptedPassword>)>,
cancellation: Option<oneshot::Sender<()>>,
editor: Entity<Editor>,
}
impl Drop for RemoteConnectionPrompt {
fn drop(&mut self) {
if let Some(cancel) = self.cancellation.take() {
log::debug!("cancelling remote connection");
cancel.send(()).ok();
}
}
}
pub struct RemoteConnectionModal {
pub prompt: Entity<RemoteConnectionPrompt>,
paths: Vec<PathBuf>,
finished: bool,
}
impl RemoteConnectionPrompt {
pub(crate) fn new(
connection_string: String,
nickname: Option<String>,
is_wsl: bool,
is_devcontainer: bool,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
Self {
connection_string: connection_string.into(),
nickname: nickname.map(|nickname| nickname.into()),
is_wsl,
is_devcontainer,
editor: cx.new(|cx| Editor::single_line(window, cx)),
status_message: None,
cancellation: None,
prompt: None,
}
}
pub fn set_cancellation_tx(&mut self, tx: oneshot::Sender<()>) {
self.cancellation = Some(tx);
}
fn set_prompt(
&mut self,
prompt: String,
tx: oneshot::Sender<EncryptedPassword>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let theme = ThemeSettings::get_global(cx);
let refinement = TextStyleRefinement {
font_family: Some(theme.buffer_font.family.clone()),
font_features: Some(FontFeatures::disable_ligatures()),
font_size: Some(theme.buffer_font_size(cx).into()),
color: Some(cx.theme().colors().editor_foreground),
background_color: Some(gpui::transparent_black()),
..Default::default()
};
self.editor.update(cx, |editor, cx| {
if prompt.contains("yes/no") {
editor.set_masked(false, cx);
} else {
editor.set_masked(true, cx);
}
editor.set_text_style_refinement(refinement);
editor.set_cursor_shape(CursorShape::Block, cx);
});
let markdown = cx.new(|cx| Markdown::new_text(prompt.into(), cx));
self.prompt = Some((markdown, tx));
self.status_message.take();
window.focus(&self.editor.focus_handle(cx), cx);
cx.notify();
}
pub fn set_status(&mut self, status: Option<String>, cx: &mut Context<Self>) {
self.status_message = status.map(|s| s.into());
cx.notify();
}
pub fn confirm(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if let Some((_, tx)) = self.prompt.take() {
self.status_message = Some("Connecting".into());
self.editor.update(cx, |editor, cx| {
let pw = editor.text(cx);
if let Ok(secure) = EncryptedPassword::try_from(pw.as_ref()) {
tx.send(secure).ok();
}
editor.clear(window, cx);
});
}
}
}
impl Render for RemoteConnectionPrompt {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let theme = ThemeSettings::get_global(cx);
let mut text_style = window.text_style();
let refinement = TextStyleRefinement {
font_family: Some(theme.buffer_font.family.clone()),
font_features: Some(FontFeatures::disable_ligatures()),
font_size: Some(theme.buffer_font_size(cx).into()),
color: Some(cx.theme().colors().editor_foreground),
background_color: Some(gpui::transparent_black()),
..Default::default()
};
text_style.refine(&refinement);
let markdown_style = MarkdownStyle {
base_text_style: text_style,
selection_background_color: cx.theme().colors().element_selection_background,
..Default::default()
};
v_flex()
.key_context("PasswordPrompt")
.p_2()
.size_full()
.text_buffer(cx)
.when_some(self.status_message.clone(), |el, status_message| {
el.child(
h_flex()
.gap_2()
.child(
Icon::new(IconName::ArrowCircle)
.color(Color::Muted)
.with_rotate_animation(2),
)
.child(
div()
.text_ellipsis()
.overflow_x_hidden()
.child(format!("{}", status_message)),
),
)
})
.when_some(self.prompt.as_ref(), |el, prompt| {
el.child(
div()
.size_full()
.overflow_hidden()
.child(MarkdownElement::new(prompt.0.clone(), markdown_style))
.child(self.editor.clone()),
)
.when(window.capslock().on, |el| {
el.child(Label::new("⚠️ ⇪ is on"))
})
})
}
}
impl RemoteConnectionModal {
pub fn new(
connection_options: &RemoteConnectionOptions,
paths: Vec<PathBuf>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let (connection_string, nickname, is_wsl, is_devcontainer) = match connection_options {
RemoteConnectionOptions::Ssh(options) => (
options.connection_string(),
options.nickname.clone(),
false,
false,
),
RemoteConnectionOptions::Wsl(options) => {
(options.distro_name.clone(), None, true, false)
}
RemoteConnectionOptions::Docker(options) => (options.name.clone(), None, false, true),
#[cfg(any(test, feature = "test-support"))]
RemoteConnectionOptions::Mock(options) => {
(format!("mock-{}", options.id), None, false, false)
}
};
Self {
prompt: cx.new(|cx| {
RemoteConnectionPrompt::new(
connection_string,
nickname,
is_wsl,
is_devcontainer,
window,
cx,
)
}),
finished: false,
paths,
}
}
fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
self.prompt
.update(cx, |prompt, cx| prompt.confirm(window, cx))
}
pub fn finished(&mut self, cx: &mut Context<Self>) {
self.finished = true;
cx.emit(DismissEvent);
}
fn dismiss(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
if let Some(tx) = self
.prompt
.update(cx, |prompt, _cx| prompt.cancellation.take())
{
log::debug!("cancelling remote connection");
tx.send(()).ok();
}
self.finished(cx);
}
}
pub(crate) struct SshConnectionHeader {
pub(crate) connection_string: SharedString,
pub(crate) paths: Vec<PathBuf>,
pub(crate) nickname: Option<SharedString>,
pub(crate) is_wsl: bool,
pub(crate) is_devcontainer: bool,
}
impl RenderOnce for SshConnectionHeader {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let mut header_color = theme.colors().text;
header_color.fade_out(0.96);
let (main_label, meta_label) = if let Some(nickname) = self.nickname {
(nickname, Some(format!("({})", self.connection_string)))
} else {
(self.connection_string, None)
};
let icon = if self.is_wsl {
IconName::Linux
} else if self.is_devcontainer {
IconName::Box
} else {
IconName::Server
};
h_flex()
.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(icon).size(IconSize::Small))
.child(
h_flex()
.gap_1()
.overflow_x_hidden()
.child(
div()
.max_w_96()
.overflow_x_hidden()
.text_ellipsis()
.child(Headline::new(main_label).size(HeadlineSize::XSmall)),
)
.children(
meta_label.map(|label| {
Label::new(label).color(Color::Muted).size(LabelSize::Small)
}),
)
.child(div().overflow_x_hidden().text_ellipsis().children(
self.paths.into_iter().map(|path| {
Label::new(path.to_string_lossy().into_owned())
.size(LabelSize::Small)
.color(Color::Muted)
}),
)),
)
}
}
impl Render for RemoteConnectionModal {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl ui::IntoElement {
let nickname = self.prompt.read(cx).nickname.clone();
let connection_string = self.prompt.read(cx).connection_string.clone();
let is_wsl = self.prompt.read(cx).is_wsl;
let is_devcontainer = self.prompt.read(cx).is_devcontainer;
let theme = cx.theme().clone();
let body_color = theme.colors().editor_background;
v_flex()
.elevation_3(cx)
.w(rems(34.))
.border_1()
.border_color(theme.colors().border)
.key_context("SshConnectionModal")
.track_focus(&self.focus_handle(cx))
.on_action(cx.listener(Self::dismiss))
.on_action(cx.listener(Self::confirm))
.child(
SshConnectionHeader {
paths: self.paths.clone(),
connection_string,
nickname,
is_wsl,
is_devcontainer,
}
.render(window, cx),
)
.child(
div()
.w_full()
.bg(body_color)
.border_y_1()
.border_color(theme.colors().border_variant)
.child(self.prompt.clone()),
)
.child(
div().w_full().py_1().child(
ListItem::new("li-devcontainer-go-back")
.inset(true)
.spacing(ui::ListItemSpacing::Sparse)
.start_slot(Icon::new(IconName::Close).color(Color::Muted))
.child(Label::new("Cancel"))
.end_slot(
KeyBinding::for_action_in(&menu::Cancel, &self.focus_handle(cx), cx)
.size(rems_from_px(12.)),
)
.on_click(cx.listener(|this, _, window, cx| {
this.dismiss(&menu::Cancel, window, cx);
})),
),
)
}
}
impl Focusable for RemoteConnectionModal {
fn focus_handle(&self, cx: &gpui::App) -> gpui::FocusHandle {
self.prompt.read(cx).editor.focus_handle(cx)
}
}
impl EventEmitter<DismissEvent> for RemoteConnectionModal {}
impl ModalView for RemoteConnectionModal {
fn on_before_dismiss(
&mut self,
_window: &mut Window,
_: &mut Context<Self>,
) -> workspace::DismissDecision {
workspace::DismissDecision::Dismiss(self.finished)
}
fn fade_out_background(&self) -> bool {
true
}
}
#[derive(Clone)]
pub struct RemoteClientDelegate {
window: AnyWindowHandle,
ui: WeakEntity<RemoteConnectionPrompt>,
known_password: Option<EncryptedPassword>,
}
impl remote::RemoteClientDelegate for RemoteClientDelegate {
fn ask_password(
&self,
prompt: String,
tx: oneshot::Sender<EncryptedPassword>,
cx: &mut AsyncApp,
) {
let mut known_password = self.known_password.clone();
if let Some(password) = known_password.take() {
tx.send(password).ok();
} else {
self.window
.update(cx, |_, window, cx| {
self.ui.update(cx, |modal, cx| {
modal.set_prompt(prompt, tx, window, cx);
})
})
.ok();
}
}
fn set_status(&self, status: Option<&str>, cx: &mut AsyncApp) {
self.update_status(status, cx)
}
fn download_server_binary_locally(
&self,
platform: RemotePlatform,
release_channel: ReleaseChannel,
version: Option<Version>,
cx: &mut AsyncApp,
) -> Task<anyhow::Result<PathBuf>> {
let this = self.clone();
cx.spawn(async move |cx| {
AutoUpdater::download_remote_server_release(
release_channel,
version.clone(),
platform.os.as_str(),
platform.arch.as_str(),
move |status, cx| this.set_status(Some(status), cx),
cx,
)
.await
.with_context(|| {
format!(
"Downloading remote server binary (version: {}, os: {}, arch: {})",
version
.as_ref()
.map(|v| format!("{}", v))
.unwrap_or("unknown".to_string()),
platform.os,
platform.arch,
)
})
})
}
fn get_download_url(
&self,
platform: RemotePlatform,
release_channel: ReleaseChannel,
version: Option<Version>,
cx: &mut AsyncApp,
) -> Task<Result<Option<String>>> {
cx.spawn(async move |cx| {
AutoUpdater::get_remote_server_release_url(
release_channel,
version,
platform.os.as_str(),
platform.arch.as_str(),
cx,
)
.await
})
}
}
impl RemoteClientDelegate {
fn update_status(&self, status: Option<&str>, cx: &mut AsyncApp) {
cx.update(|cx| {
self.ui
.update(cx, |modal, cx| {
modal.set_status(status.map(|s| s.to_string()), cx);
})
.ok()
});
}
}
pub fn connect(
unique_identifier: ConnectionIdentifier,
connection_options: RemoteConnectionOptions,
ui: Entity<RemoteConnectionPrompt>,
window: &mut Window,
cx: &mut App,
) -> Task<Result<Option<Entity<RemoteClient>>>> {
let window = window.window_handle();
let known_password = match &connection_options {
RemoteConnectionOptions::Ssh(ssh_connection_options) => ssh_connection_options
.password
.as_deref()
.and_then(|pw| pw.try_into().ok()),
_ => None,
};
let (tx, mut rx) = oneshot::channel();
ui.update(cx, |ui, _cx| ui.set_cancellation_tx(tx));
let delegate = Arc::new(RemoteClientDelegate {
window,
ui: ui.downgrade(),
known_password,
});
cx.spawn(async move |cx| {
let connection = remote::connect(connection_options, delegate.clone(), cx);
let connection = select! {
_ = rx => return Ok(None),
result = connection.fuse() => result,
}?;
cx.update(|cx| remote::RemoteClient::new(unique_identifier, connection, rx, delegate, cx))
.await
})
}
pub async fn open_remote_project(
connection_options: RemoteConnectionOptions,
paths: Vec<PathBuf>,
@ -641,7 +135,6 @@ pub async fn open_remote_project(
} else {
let workspace_position = cx
.update(|cx| {
// todo: These paths are wrong they may have column and line information
workspace::remote_workspace_position_from_db(connection_options.clone(), &paths, cx)
})
.await
@ -695,12 +188,10 @@ pub async fn open_remote_project(
ui.set_cancellation_tx(cancel_tx);
});
Some(Arc::new(RemoteClientDelegate {
window: window.window_handle(),
ui: ui.downgrade(),
known_password: if let RemoteConnectionOptions::Ssh(options) =
&connection_options
{
Some(Arc::new(RemoteClientDelegate::new(
window.window_handle(),
ui.downgrade(),
if let RemoteConnectionOptions::Ssh(options) = &connection_options {
options
.password
.as_deref()
@ -708,7 +199,7 @@ pub async fn open_remote_project(
} else {
None
},
}))
)))
}
})?;
@ -884,7 +375,6 @@ pub async fn open_remote_project(
}
})
.ok();
// Already showed the error to the user
Ok(())
}
@ -935,7 +425,7 @@ mod tests {
use super::*;
use extension::ExtensionHostProxy;
use fs::FakeFs;
use gpui::TestAppContext;
use gpui::{AppContext, TestAppContext};
use http_client::BlockedHttpClient;
use node_runtime::NodeRuntime;
use remote::RemoteClient;

View file

@ -0,0 +1,34 @@
[package]
name = "remote_connection"
version = "0.1.0"
edition.workspace = true
publish.workspace = true
license = "GPL-3.0-or-later"
[lints]
workspace = true
[lib]
path = "src/remote_connection.rs"
[features]
default = []
test-support = ["remote/test-support"]
[dependencies]
anyhow.workspace = true
askpass.workspace = true
auto_update.workspace = true
futures.workspace = true
gpui.workspace = true
log.workspace = true
markdown.workspace = true
menu.workspace = true
release_channel.workspace = true
remote.workspace = true
semver.workspace = true
settings.workspace = true
theme.workspace = true
ui.workspace = true
ui_input.workspace = true
workspace.workspace = true

View file

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

View file

@ -0,0 +1,522 @@
use std::{path::PathBuf, sync::Arc};
use anyhow::Result;
use askpass::EncryptedPassword;
use auto_update::AutoUpdater;
use futures::{FutureExt as _, channel::oneshot, select};
use gpui::{
AnyWindowHandle, App, AsyncApp, DismissEvent, Entity, EventEmitter, Focusable, FontFeatures,
ParentElement as _, Render, SharedString, Task, TextStyleRefinement, WeakEntity,
};
use markdown::{Markdown, MarkdownElement, MarkdownStyle};
use release_channel::ReleaseChannel;
use remote::{ConnectionIdentifier, RemoteClient, RemoteConnectionOptions, RemotePlatform};
use semver::Version;
use settings::Settings;
use theme::ThemeSettings;
use ui::{
ActiveTheme, Color, CommonAnimationExt, Context, InteractiveElement, IntoElement, KeyBinding,
LabelCommon, ListItem, Styled, Window, prelude::*,
};
use ui_input::{ERASED_EDITOR_FACTORY, ErasedEditor};
use workspace::{DismissDecision, ModalView};
pub struct RemoteConnectionPrompt {
connection_string: SharedString,
nickname: Option<SharedString>,
is_wsl: bool,
is_devcontainer: bool,
status_message: Option<SharedString>,
prompt: Option<(Entity<Markdown>, oneshot::Sender<EncryptedPassword>)>,
cancellation: Option<oneshot::Sender<()>>,
editor: Arc<dyn ErasedEditor>,
}
impl Drop for RemoteConnectionPrompt {
fn drop(&mut self) {
if let Some(cancel) = self.cancellation.take() {
log::debug!("cancelling remote connection");
cancel.send(()).ok();
}
}
}
pub struct RemoteConnectionModal {
pub prompt: Entity<RemoteConnectionPrompt>,
paths: Vec<PathBuf>,
finished: bool,
}
impl RemoteConnectionPrompt {
pub fn new(
connection_string: String,
nickname: Option<String>,
is_wsl: bool,
is_devcontainer: bool,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let editor_factory = ERASED_EDITOR_FACTORY
.get()
.expect("ErasedEditorFactory to be initialized");
let editor = (editor_factory)(window, cx);
Self {
connection_string: connection_string.into(),
nickname: nickname.map(|nickname| nickname.into()),
is_wsl,
is_devcontainer,
editor,
status_message: None,
cancellation: None,
prompt: None,
}
}
pub fn set_cancellation_tx(&mut self, tx: oneshot::Sender<()>) {
self.cancellation = Some(tx);
}
pub fn set_prompt(
&mut self,
prompt: String,
tx: oneshot::Sender<EncryptedPassword>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let is_yes_no = prompt.contains("yes/no");
self.editor.set_masked(!is_yes_no, window, cx);
let markdown = cx.new(|cx| Markdown::new_text(prompt.into(), cx));
self.prompt = Some((markdown, tx));
self.status_message.take();
window.focus(&self.editor.focus_handle(cx), cx);
cx.notify();
}
pub fn set_status(&mut self, status: Option<String>, cx: &mut Context<Self>) {
self.status_message = status.map(|s| s.into());
cx.notify();
}
pub fn confirm(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if let Some((_, tx)) = self.prompt.take() {
self.status_message = Some("Connecting".into());
let pw = self.editor.text(cx);
if let Ok(secure) = EncryptedPassword::try_from(pw.as_ref()) {
tx.send(secure).ok();
}
self.editor.clear(window, cx);
}
}
}
impl Render for RemoteConnectionPrompt {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let theme = ThemeSettings::get_global(cx);
let mut text_style = window.text_style();
let refinement = TextStyleRefinement {
font_family: Some(theme.buffer_font.family.clone()),
font_features: Some(FontFeatures::disable_ligatures()),
font_size: Some(theme.buffer_font_size(cx).into()),
color: Some(cx.theme().colors().editor_foreground),
background_color: Some(gpui::transparent_black()),
..Default::default()
};
text_style.refine(&refinement);
let markdown_style = MarkdownStyle {
base_text_style: text_style,
selection_background_color: cx.theme().colors().element_selection_background,
..Default::default()
};
v_flex()
.key_context("PasswordPrompt")
.p_2()
.size_full()
.text_buffer(cx)
.when_some(self.status_message.clone(), |el, status_message| {
el.child(
h_flex()
.gap_2()
.child(
Icon::new(IconName::ArrowCircle)
.color(Color::Muted)
.with_rotate_animation(2),
)
.child(
div()
.text_ellipsis()
.overflow_x_hidden()
.child(format!("{}", status_message)),
),
)
})
.when_some(self.prompt.as_ref(), |el, prompt| {
el.child(
div()
.size_full()
.overflow_hidden()
.child(MarkdownElement::new(prompt.0.clone(), markdown_style))
.child(self.editor.render(window, cx)),
)
.when(window.capslock().on, |el| {
el.child(Label::new("⚠️ ⇪ is on"))
})
})
}
}
impl RemoteConnectionModal {
pub fn new(
connection_options: &RemoteConnectionOptions,
paths: Vec<PathBuf>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let (connection_string, nickname, is_wsl, is_devcontainer) = match connection_options {
RemoteConnectionOptions::Ssh(options) => (
options.connection_string(),
options.nickname.clone(),
false,
false,
),
RemoteConnectionOptions::Wsl(options) => {
(options.distro_name.clone(), None, true, false)
}
RemoteConnectionOptions::Docker(options) => (options.name.clone(), None, false, true),
#[cfg(any(test, feature = "test-support"))]
RemoteConnectionOptions::Mock(options) => {
(format!("mock-{}", options.id), None, false, false)
}
};
Self {
prompt: cx.new(|cx| {
RemoteConnectionPrompt::new(
connection_string,
nickname,
is_wsl,
is_devcontainer,
window,
cx,
)
}),
finished: false,
paths,
}
}
fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
self.prompt
.update(cx, |prompt, cx| prompt.confirm(window, cx))
}
pub fn finished(&mut self, cx: &mut Context<Self>) {
self.finished = true;
cx.emit(DismissEvent);
}
fn dismiss(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
if let Some(tx) = self
.prompt
.update(cx, |prompt, _cx| prompt.cancellation.take())
{
log::debug!("cancelling remote connection");
tx.send(()).ok();
}
self.finished(cx);
}
}
pub struct SshConnectionHeader {
pub connection_string: SharedString,
pub paths: Vec<PathBuf>,
pub nickname: Option<SharedString>,
pub is_wsl: bool,
pub is_devcontainer: bool,
}
impl RenderOnce for SshConnectionHeader {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let mut header_color = theme.colors().text;
header_color.fade_out(0.96);
let (main_label, meta_label) = if let Some(nickname) = self.nickname {
(nickname, Some(format!("({})", self.connection_string)))
} else {
(self.connection_string, None)
};
let icon = if self.is_wsl {
IconName::Linux
} else if self.is_devcontainer {
IconName::Box
} else {
IconName::Server
};
h_flex()
.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(icon).size(IconSize::Small))
.child(
h_flex()
.gap_1()
.overflow_x_hidden()
.child(
div()
.max_w_96()
.overflow_x_hidden()
.text_ellipsis()
.child(Headline::new(main_label).size(HeadlineSize::XSmall)),
)
.children(
meta_label.map(|label| {
Label::new(label).color(Color::Muted).size(LabelSize::Small)
}),
)
.child(div().overflow_x_hidden().text_ellipsis().children(
self.paths.into_iter().map(|path| {
Label::new(path.to_string_lossy().into_owned())
.size(LabelSize::Small)
.color(Color::Muted)
}),
)),
)
}
}
impl Render for RemoteConnectionModal {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl ui::IntoElement {
let nickname = self.prompt.read(cx).nickname.clone();
let connection_string = self.prompt.read(cx).connection_string.clone();
let is_wsl = self.prompt.read(cx).is_wsl;
let is_devcontainer = self.prompt.read(cx).is_devcontainer;
let theme = cx.theme().clone();
let body_color = theme.colors().editor_background;
v_flex()
.elevation_3(cx)
.w(rems(34.))
.border_1()
.border_color(theme.colors().border)
.key_context("SshConnectionModal")
.track_focus(&self.focus_handle(cx))
.on_action(cx.listener(Self::dismiss))
.on_action(cx.listener(Self::confirm))
.child(
SshConnectionHeader {
paths: self.paths.clone(),
connection_string,
nickname,
is_wsl,
is_devcontainer,
}
.render(window, cx),
)
.child(
div()
.w_full()
.bg(body_color)
.border_y_1()
.border_color(theme.colors().border_variant)
.child(self.prompt.clone()),
)
.child(
div().w_full().py_1().child(
ListItem::new("li-devcontainer-go-back")
.inset(true)
.spacing(ui::ListItemSpacing::Sparse)
.start_slot(Icon::new(IconName::Close).color(Color::Muted))
.child(Label::new("Cancel"))
.end_slot(
KeyBinding::for_action_in(&menu::Cancel, &self.focus_handle(cx), cx)
.size(rems_from_px(12.)),
)
.on_click(cx.listener(|this, _, window, cx| {
this.dismiss(&menu::Cancel, window, cx);
})),
),
)
}
}
impl Focusable for RemoteConnectionModal {
fn focus_handle(&self, cx: &gpui::App) -> gpui::FocusHandle {
self.prompt.read(cx).editor.focus_handle(cx)
}
}
impl EventEmitter<DismissEvent> for RemoteConnectionModal {}
impl ModalView for RemoteConnectionModal {
fn on_before_dismiss(
&mut self,
_window: &mut Window,
_: &mut Context<Self>,
) -> DismissDecision {
DismissDecision::Dismiss(self.finished)
}
fn fade_out_background(&self) -> bool {
true
}
}
#[derive(Clone)]
pub struct RemoteClientDelegate {
window: AnyWindowHandle,
ui: WeakEntity<RemoteConnectionPrompt>,
known_password: Option<EncryptedPassword>,
}
impl RemoteClientDelegate {
pub fn new(
window: AnyWindowHandle,
ui: WeakEntity<RemoteConnectionPrompt>,
known_password: Option<EncryptedPassword>,
) -> Self {
Self {
window,
ui,
known_password,
}
}
}
impl remote::RemoteClientDelegate for RemoteClientDelegate {
fn ask_password(
&self,
prompt: String,
tx: oneshot::Sender<EncryptedPassword>,
cx: &mut AsyncApp,
) {
let mut known_password = self.known_password.clone();
if let Some(password) = known_password.take() {
tx.send(password).ok();
} else {
self.window
.update(cx, |_, window, cx| {
self.ui.update(cx, |modal, cx| {
modal.set_prompt(prompt, tx, window, cx);
})
})
.ok();
}
}
fn set_status(&self, status: Option<&str>, cx: &mut AsyncApp) {
self.update_status(status, cx)
}
fn download_server_binary_locally(
&self,
platform: RemotePlatform,
release_channel: ReleaseChannel,
version: Option<Version>,
cx: &mut AsyncApp,
) -> Task<anyhow::Result<PathBuf>> {
let this = self.clone();
cx.spawn(async move |cx| {
AutoUpdater::download_remote_server_release(
release_channel,
version.clone(),
platform.os.as_str(),
platform.arch.as_str(),
move |status, cx| this.set_status(Some(status), cx),
cx,
)
.await
.with_context(|| {
format!(
"Downloading remote server binary (version: {}, os: {}, arch: {})",
version
.as_ref()
.map(|v| format!("{}", v))
.unwrap_or("unknown".to_string()),
platform.os,
platform.arch,
)
})
})
}
fn get_download_url(
&self,
platform: RemotePlatform,
release_channel: ReleaseChannel,
version: Option<Version>,
cx: &mut AsyncApp,
) -> Task<Result<Option<String>>> {
cx.spawn(async move |cx| {
AutoUpdater::get_remote_server_release_url(
release_channel,
version,
platform.os.as_str(),
platform.arch.as_str(),
cx,
)
.await
})
}
}
impl RemoteClientDelegate {
fn update_status(&self, status: Option<&str>, cx: &mut AsyncApp) {
cx.update(|cx| {
self.ui
.update(cx, |modal, cx| {
modal.set_status(status.map(|s| s.to_string()), cx);
})
.ok()
});
}
}
pub fn connect(
unique_identifier: ConnectionIdentifier,
connection_options: RemoteConnectionOptions,
ui: Entity<RemoteConnectionPrompt>,
window: &mut Window,
cx: &mut App,
) -> Task<Result<Option<Entity<RemoteClient>>>> {
let window = window.window_handle();
let known_password = match &connection_options {
RemoteConnectionOptions::Ssh(ssh_connection_options) => ssh_connection_options
.password
.as_deref()
.and_then(|pw| pw.try_into().ok()),
_ => None,
};
let (tx, mut rx) = oneshot::channel();
ui.update(cx, |ui, _cx| ui.set_cancellation_tx(tx));
let delegate = Arc::new(RemoteClientDelegate {
window,
ui: ui.downgrade(),
known_password,
});
cx.spawn(async move |cx| {
let connection = remote::connect(connection_options, delegate.clone(), cx);
let connection = select! {
_ = rx => return Ok(None),
result = connection.fuse() => result,
}?;
cx.update(|cx| remote::RemoteClient::new(unique_identifier, connection, rx, delegate, cx))
.await
})
}
use anyhow::Context as _;

View file

@ -19,6 +19,7 @@ pub trait ErasedEditor: 'static {
fn clear(&self, window: &mut Window, cx: &mut App);
fn set_placeholder_text(&self, text: &str, window: &mut Window, _: &mut App);
fn move_selection_to_end(&self, window: &mut Window, _: &mut App);
fn set_masked(&self, masked: bool, window: &mut Window, cx: &mut App);
fn focus_handle(&self, cx: &App) -> FocusHandle;