diff --git a/Cargo.lock b/Cargo.lock index 673a931308accae7931fdd52272bf49067b95ef0..b9cb8ad4d1170ae8c33677d41fe5e94a6144567f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1688,12 +1688,11 @@ dependencies = [ "settings", "smol", "theme", - "ui", "util", ] [[package]] -name = "copilot_button" +name = "copilot_ui" version = "0.1.0" dependencies = [ "anyhow", @@ -1706,6 +1705,7 @@ dependencies = [ "settings", "smol", "theme", + "ui", "util", "workspace", "zed_actions", @@ -9547,7 +9547,7 @@ dependencies = [ "collections", "command_palette", "copilot", - "copilot_button", + "copilot_ui", "ctor", "db", "diagnostics", diff --git a/Cargo.toml b/Cargo.toml index 9390bbb265806187a5f29170adfa5c499fc8ce99..008b8406ecbb9c655b1d64406f697053cb5714c2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,7 @@ members = [ "crates/collections", "crates/command_palette", "crates/copilot", - "crates/copilot_button", + "crates/copilot_ui", "crates/db", "crates/refineable", "crates/refineable/derive_refineable", diff --git a/crates/copilot/Cargo.toml b/crates/copilot/Cargo.toml index 588c747696c3ed58696bd12fbe97f3cbf3f9ef8a..fefd49090fd2020f4dc6da8aa1c2a1c5d119ea96 100644 --- a/crates/copilot/Cargo.toml +++ b/crates/copilot/Cargo.toml @@ -28,7 +28,6 @@ theme = { path = "../theme" } lsp = { path = "../lsp" } node_runtime = { path = "../node_runtime"} util = { path = "../util" } -ui = { path = "../ui" } async-compression.workspace = true async-tar = "0.4.2" anyhow.workspace = true diff --git a/crates/copilot/src/copilot.rs b/crates/copilot/src/copilot.rs index 658eb3451f473d4a9af26053dae20f21479c339e..89d1086c8e4b897c3527964289ff11810078a57c 100644 --- a/crates/copilot/src/copilot.rs +++ b/crates/copilot/src/copilot.rs @@ -1,6 +1,4 @@ pub mod request; -mod sign_in; - use anyhow::{anyhow, Context as _, Result}; use async_compression::futures::bufread::GzipDecoder; use async_tar::Archive; @@ -98,7 +96,6 @@ pub fn init( }) .detach(); - sign_in::init(cx); cx.on_action(|_: &SignIn, cx| { if let Some(copilot) = Copilot::global(cx) { copilot diff --git a/crates/copilot/src/sign_in.rs b/crates/copilot/src/sign_in.rs deleted file mode 100644 index ba5dbe0e315828ac1761cf1d76ac37bb8211ea4c..0000000000000000000000000000000000000000 --- a/crates/copilot/src/sign_in.rs +++ /dev/null @@ -1,211 +0,0 @@ -use crate::{request::PromptUserDeviceFlow, Copilot, Status}; -use gpui::{ - div, size, AppContext, Bounds, ClipboardItem, Element, GlobalPixels, InteractiveElement, - IntoElement, ParentElement, Point, Render, Styled, ViewContext, VisualContext, WindowBounds, - WindowHandle, WindowKind, WindowOptions, -}; -use theme::ActiveTheme; -use ui::{prelude::*, Button, Icon, IconElement, Label}; - -const COPILOT_SIGN_UP_URL: &'static str = "https://github.com/features/copilot"; - -pub fn init(cx: &mut AppContext) { - if let Some(copilot) = Copilot::global(cx) { - let mut verification_window: Option> = None; - cx.observe(&copilot, move |copilot, cx| { - let status = copilot.read(cx).status(); - - match &status { - crate::Status::SigningIn { prompt } => { - if let Some(window) = verification_window.as_mut() { - let updated = window - .update(cx, |verification, cx| { - verification.set_status(status.clone(), cx); - cx.activate_window(); - }) - .is_ok(); - if !updated { - verification_window = Some(create_copilot_auth_window(cx, &status)); - } - } else if let Some(_prompt) = prompt { - verification_window = Some(create_copilot_auth_window(cx, &status)); - } - } - Status::Authorized | Status::Unauthorized => { - if let Some(window) = verification_window.as_ref() { - window - .update(cx, |verification, cx| { - verification.set_status(status, cx); - cx.activate(true); - cx.activate_window(); - }) - .ok(); - } - } - _ => { - if let Some(code_verification) = verification_window.take() { - code_verification - .update(cx, |_, cx| cx.remove_window()) - .ok(); - } - } - } - }) - .detach(); - } -} - -fn create_copilot_auth_window( - cx: &mut AppContext, - status: &Status, -) -> WindowHandle { - let window_size = size(GlobalPixels::from(280.), GlobalPixels::from(280.)); - let window_options = WindowOptions { - bounds: WindowBounds::Fixed(Bounds::new(Point::default(), window_size)), - titlebar: None, - center: true, - focus: true, - show: true, - kind: WindowKind::PopUp, - is_movable: true, - display_id: None, - }; - let window = cx.open_window(window_options, |cx| { - cx.new_view(|_| CopilotCodeVerification::new(status.clone())) - }); - window -} - -pub struct CopilotCodeVerification { - status: Status, - connect_clicked: bool, -} - -impl CopilotCodeVerification { - pub fn new(status: Status) -> Self { - Self { - status, - connect_clicked: false, - } - } - - pub fn set_status(&mut self, status: Status, cx: &mut ViewContext) { - self.status = status; - cx.notify(); - } - - fn render_device_code( - data: &PromptUserDeviceFlow, - cx: &mut ViewContext, - ) -> impl IntoElement { - let copied = cx - .read_from_clipboard() - .map(|item| item.text() == &data.user_code) - .unwrap_or(false); - h_stack() - .cursor_pointer() - .justify_between() - .on_mouse_down(gpui::MouseButton::Left, { - let user_code = data.user_code.clone(); - move |_, cx| { - cx.write_to_clipboard(ClipboardItem::new(user_code.clone())); - cx.notify(); - } - }) - .child(Label::new(data.user_code.clone())) - .child(div()) - .child(Label::new(if copied { "Copied!" } else { "Copy" })) - } - - fn render_prompting_modal( - connect_clicked: bool, - data: &PromptUserDeviceFlow, - cx: &mut ViewContext, - ) -> impl Element { - let connect_button_label = if connect_clicked { - "Waiting for connection..." - } else { - "Connect to Github" - }; - v_stack() - .flex_1() - .items_center() - .justify_between() - .w_full() - .child(Label::new( - "Enable Copilot by connecting your existing license", - )) - .child(Self::render_device_code(data, cx)) - .child( - Label::new("Paste this code into GitHub after clicking the button below.") - .size(ui::LabelSize::Small), - ) - .child( - Button::new("connect-button", connect_button_label).on_click({ - let verification_uri = data.verification_uri.clone(); - cx.listener(move |this, _, cx| { - cx.open_url(&verification_uri); - this.connect_clicked = true; - }) - }), - ) - } - fn render_enabled_modal() -> impl Element { - v_stack() - .child(Label::new("Copilot Enabled!")) - .child(Label::new( - "You can update your settings or sign out from the Copilot menu in the status bar.", - )) - .child( - Button::new("copilot-enabled-done-button", "Done") - .on_click(|_, cx| cx.remove_window()), - ) - } - - fn render_unauthorized_modal() -> impl Element { - v_stack() - .child(Label::new( - "Enable Copilot by connecting your existing license.", - )) - .child( - Label::new("You must have an active Copilot license to use it in Zed.") - .color(Color::Warning), - ) - .child( - Button::new("copilot-subscribe-button", "Subscibe on Github").on_click(|_, cx| { - cx.remove_window(); - cx.open_url(COPILOT_SIGN_UP_URL) - }), - ) - } -} - -impl Render for CopilotCodeVerification { - fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { - let prompt = match &self.status { - Status::SigningIn { - prompt: Some(prompt), - } => Self::render_prompting_modal(self.connect_clicked, &prompt, cx).into_any_element(), - Status::Unauthorized => { - self.connect_clicked = false; - Self::render_unauthorized_modal().into_any_element() - } - Status::Authorized => { - self.connect_clicked = false; - Self::render_enabled_modal().into_any_element() - } - _ => div().into_any_element(), - }; - div() - .id("copilot code verification") - .flex() - .flex_col() - .size_full() - .items_center() - .p_10() - .bg(cx.theme().colors().element_background) - .child(ui::Label::new("Connect Copilot to Zed")) - .child(IconElement::new(Icon::ZedXCopilot)) - .child(prompt) - } -} diff --git a/crates/copilot_button/Cargo.toml b/crates/copilot_ui/Cargo.toml similarity index 89% rename from crates/copilot_button/Cargo.toml rename to crates/copilot_ui/Cargo.toml index 63788f9d28a5097bab1f02ab340c253b704cc599..491f4f3cdec3d2ebd20fe1d6a2536471f862b90b 100644 --- a/crates/copilot_button/Cargo.toml +++ b/crates/copilot_ui/Cargo.toml @@ -1,11 +1,11 @@ [package] -name = "copilot_button" +name = "copilot_ui" version = "0.1.0" edition = "2021" publish = false [lib] -path = "src/copilot_button.rs" +path = "src/copilot_ui.rs" doctest = false [dependencies] @@ -17,6 +17,7 @@ gpui = { path = "../gpui" } language = { path = "../language" } settings = { path = "../settings" } theme = { path = "../theme" } +ui = { path = "../ui" } util = { path = "../util" } workspace = {path = "../workspace" } anyhow.workspace = true diff --git a/crates/copilot_button/src/copilot_button.rs b/crates/copilot_ui/src/copilot_button.rs similarity index 97% rename from crates/copilot_button/src/copilot_button.rs rename to crates/copilot_ui/src/copilot_button.rs index 60b25fee12ab8c32ca6894fb2363f271f27246a4..e55f45c29333edbecc22742fccf0d4cd10a4a0df 100644 --- a/crates/copilot_button/src/copilot_button.rs +++ b/crates/copilot_ui/src/copilot_button.rs @@ -1,3 +1,4 @@ +use crate::sign_in::CopilotCodeVerification; use anyhow::Result; use copilot::{Copilot, SignOut, Status}; use editor::{scroll::autoscroll::Autoscroll, Editor}; @@ -331,7 +332,9 @@ fn initiate_sign_in(cx: &mut WindowContext) { return; }; let status = copilot.read(cx).status(); - + let Some(workspace) = cx.window_handle().downcast::() else { + return; + }; match status { Status::Starting { task } => { let Some(workspace) = cx.window_handle().downcast::() else { @@ -370,9 +373,12 @@ fn initiate_sign_in(cx: &mut WindowContext) { .detach(); } _ => { - copilot - .update(cx, |copilot, cx| copilot.sign_in(cx)) - .detach_and_log_err(cx); + copilot.update(cx, |this, cx| this.sign_in(cx)).detach(); + workspace + .update(cx, |this, cx| { + this.toggle_modal(cx, |cx| CopilotCodeVerification::new(&copilot, cx)); + }) + .ok(); } } } diff --git a/crates/copilot_ui/src/copilot_ui.rs b/crates/copilot_ui/src/copilot_ui.rs new file mode 100644 index 0000000000000000000000000000000000000000..64dd068d5aff5f0910e0ed78ea2746f0f6540189 --- /dev/null +++ b/crates/copilot_ui/src/copilot_ui.rs @@ -0,0 +1,5 @@ +mod copilot_button; +mod sign_in; + +pub use copilot_button::*; +pub use sign_in::*; diff --git a/crates/copilot_ui/src/sign_in.rs b/crates/copilot_ui/src/sign_in.rs new file mode 100644 index 0000000000000000000000000000000000000000..aeaa35784bfabe5ab75fc2b26a59dada83ddb61a --- /dev/null +++ b/crates/copilot_ui/src/sign_in.rs @@ -0,0 +1,183 @@ +use copilot::{request::PromptUserDeviceFlow, Copilot, Status}; +use gpui::{ + div, svg, AppContext, ClipboardItem, DismissEvent, Element, EventEmitter, FocusHandle, + FocusableView, InteractiveElement, IntoElement, Model, ParentElement, Render, Styled, + Subscription, ViewContext, +}; +use ui::{prelude::*, Button, Icon, Label}; +use workspace::ModalView; + +const COPILOT_SIGN_UP_URL: &'static str = "https://github.com/features/copilot"; + +pub struct CopilotCodeVerification { + status: Status, + connect_clicked: bool, + focus_handle: FocusHandle, + _subscription: Subscription, +} + +impl FocusableView for CopilotCodeVerification { + fn focus_handle(&self, _: &AppContext) -> gpui::FocusHandle { + self.focus_handle.clone() + } +} + +impl EventEmitter for CopilotCodeVerification {} +impl ModalView for CopilotCodeVerification {} + +impl CopilotCodeVerification { + pub(crate) fn new(copilot: &Model, cx: &mut ViewContext) -> Self { + let status = copilot.read(cx).status(); + Self { + status, + connect_clicked: false, + focus_handle: cx.focus_handle(), + _subscription: cx.observe(copilot, |this, copilot, cx| { + let status = copilot.read(cx).status(); + match status { + Status::Authorized | Status::Unauthorized | Status::SigningIn { .. } => { + this.set_status(status, cx) + } + _ => cx.emit(DismissEvent), + } + }), + } + } + + pub fn set_status(&mut self, status: Status, cx: &mut ViewContext) { + self.status = status; + cx.notify(); + } + + fn render_device_code( + data: &PromptUserDeviceFlow, + cx: &mut ViewContext, + ) -> impl IntoElement { + let copied = cx + .read_from_clipboard() + .map(|item| item.text() == &data.user_code) + .unwrap_or(false); + h_stack() + .w_full() + .p_1() + .border() + .border_muted(cx) + .rounded_md() + .cursor_pointer() + .justify_between() + .on_mouse_down(gpui::MouseButton::Left, { + let user_code = data.user_code.clone(); + move |_, cx| { + cx.write_to_clipboard(ClipboardItem::new(user_code.clone())); + cx.notify(); + } + }) + .child(div().flex_1().child(Label::new(data.user_code.clone()))) + .child(div().flex_none().px_1().child(Label::new(if copied { + "Copied!" + } else { + "Copy" + }))) + } + + fn render_prompting_modal( + connect_clicked: bool, + data: &PromptUserDeviceFlow, + cx: &mut ViewContext, + ) -> impl Element { + let connect_button_label = if connect_clicked { + "Waiting for connection..." + } else { + "Connect to Github" + }; + v_stack() + .flex_1() + .gap_2() + .items_center() + .child(Headline::new("Use Github Copilot in Zed.").size(HeadlineSize::Large)) + .child( + Label::new("Using Copilot requres an active subscription on Github.") + .color(Color::Muted), + ) + .child(Self::render_device_code(data, cx)) + .child( + Label::new("Paste this code into GitHub after clicking the button below.") + .size(ui::LabelSize::Small), + ) + .child( + Button::new("connect-button", connect_button_label) + .on_click({ + let verification_uri = data.verification_uri.clone(); + cx.listener(move |this, _, cx| { + cx.open_url(&verification_uri); + this.connect_clicked = true; + }) + }) + .full_width() + .style(ButtonStyle::Filled), + ) + } + fn render_enabled_modal(cx: &mut ViewContext) -> impl Element { + v_stack() + .gap_2() + .child(Headline::new("Copilot Enabled!").size(HeadlineSize::Large)) + .child(Label::new( + "You can update your settings or sign out from the Copilot menu in the status bar.", + )) + .child( + Button::new("copilot-enabled-done-button", "Done") + .full_width() + .on_click(cx.listener(|_, _, cx| cx.emit(DismissEvent))), + ) + } + + fn render_unauthorized_modal() -> impl Element { + v_stack() + .child(Headline::new("You must have an active GitHub Copilot subscription.").size(HeadlineSize::Large)) + + .child(Label::new( + "You can enable Copilot by connecting your existing license once you have subscribed or renewed your subscription.", + ).color(Color::Warning)) + .child( + Button::new("copilot-subscribe-button", "Subscibe on Github") + .full_width() + .on_click(|_, cx| cx.open_url(COPILOT_SIGN_UP_URL)), + ) + } +} + +impl Render for CopilotCodeVerification { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + let prompt = match &self.status { + Status::SigningIn { + prompt: Some(prompt), + } => Self::render_prompting_modal(self.connect_clicked, &prompt, cx).into_any_element(), + Status::Unauthorized => { + self.connect_clicked = false; + Self::render_unauthorized_modal().into_any_element() + } + Status::Authorized => { + self.connect_clicked = false; + Self::render_enabled_modal(cx).into_any_element() + } + _ => div().into_any_element(), + }; + + v_stack() + .id("copilot code verification") + .elevation_3(cx) + .w_96() + .items_center() + .p_4() + .gap_2() + .child( + svg() + .w_32() + .h_16() + .flex_none() + .path(Icon::ZedXCopilot.path()) + .text_color(cx.theme().colors().icon), + ) + .child(prompt) + } +} diff --git a/crates/ui/src/prelude.rs b/crates/ui/src/prelude.rs index 63d6c4b46a498bbce2573814cd645ec1d1b7bccd..48536f59b312b5389e9892b6593919ed0adc7bd0 100644 --- a/crates/ui/src/prelude.rs +++ b/crates/ui/src/prelude.rs @@ -14,6 +14,7 @@ pub use crate::visible_on_hover::*; pub use crate::{h_stack, v_stack}; pub use crate::{Button, ButtonSize, ButtonStyle, IconButton, SelectableButton}; pub use crate::{ButtonCommon, Color, StyledExt}; +pub use crate::{Headline, HeadlineSize}; pub use crate::{Icon, IconElement, IconPosition, IconSize}; pub use crate::{Label, LabelCommon, LabelSize, LineHeightStyle}; pub use theme::ActiveTheme; diff --git a/crates/ui/src/styles/typography.rs b/crates/ui/src/styles/typography.rs index 4819791b02c988bfccc1c59484b373e5d8249bfe..39937ebff1701f2195c4112235c919368b97b364 100644 --- a/crates/ui/src/styles/typography.rs +++ b/crates/ui/src/styles/typography.rs @@ -1,4 +1,8 @@ -use gpui::{rems, Rems}; +use gpui::{ + div, rems, IntoElement, ParentElement, Rems, RenderOnce, SharedString, Styled, WindowContext, +}; +use settings::Settings; +use theme::{ActiveTheme, ThemeSettings}; #[derive(Debug, Default, Clone)] pub enum UiTextSize { @@ -33,3 +37,69 @@ impl UiTextSize { } } } + +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, Default)] +pub enum HeadlineSize { + XSmall, + Small, + #[default] + Medium, + Large, + XLarge, +} + +impl HeadlineSize { + pub fn size(self) -> Rems { + match self { + // Based on the Major Second scale + Self::XSmall => rems(0.88), + Self::Small => rems(1.0), + Self::Medium => rems(1.125), + Self::Large => rems(1.27), + Self::XLarge => rems(1.43), + } + } + + pub fn line_height(self) -> Rems { + match self { + Self::XSmall => rems(1.6), + Self::Small => rems(1.6), + Self::Medium => rems(1.6), + Self::Large => rems(1.6), + Self::XLarge => rems(1.6), + } + } +} + +#[derive(IntoElement)] +pub struct Headline { + size: HeadlineSize, + text: SharedString, +} + +impl RenderOnce for Headline { + fn render(self, cx: &mut WindowContext) -> impl IntoElement { + let ui_font = ThemeSettings::get_global(cx).ui_font.family.clone(); + + div() + .font(ui_font) + .line_height(self.size.line_height()) + .text_size(self.size.size()) + .text_color(cx.theme().colors().text) + .child(self.text) + } +} + +impl Headline { + pub fn new(text: impl Into) -> Self { + Self { + size: HeadlineSize::default(), + text: text.into(), + } + } + + pub fn size(mut self, size: HeadlineSize) -> Self { + self.size = size; + self + } +} diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index c17d9c781c217641330847b6000c9c4676fb5bd4..ae2c3701d6ece648f58204ac38dabc5f668a0def 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -30,7 +30,7 @@ command_palette = { path = "../command_palette" } client = { path = "../client" } # clock = { path = "../clock" } copilot = { path = "../copilot" } -copilot_button = { path = "../copilot_button" } +copilot_ui = { path = "../copilot_ui" } diagnostics = { path = "../diagnostics" } db = { path = "../db" } editor = { path = "../editor" } diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 97ef50cabdacd7ab4aebaed9bcedba3236f2c42f..b77ba1a23945000bd7f8d6b9edf6c4c486fef008 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -120,8 +120,7 @@ pub fn initialize_workspace(app_state: Arc, cx: &mut AppContext) { // cx.add_view(|cx| CollabTitlebarItem::new(workspace, &workspace_handle, cx)); // workspace.set_titlebar_item(collab_titlebar_item.into_any(), cx); - let copilot = - cx.new_view(|cx| copilot_button::CopilotButton::new(app_state.fs.clone(), cx)); + let copilot = cx.new_view(|cx| copilot_ui::CopilotButton::new(app_state.fs.clone(), cx)); let diagnostic_summary = cx.new_view(|cx| diagnostics::items::DiagnosticIndicator::new(workspace, cx)); let activity_indicator =