Allow copy-pasting dev-server-token (#11992)

Conrad Irwin created

Release Notes:

- N/A

Change summary

Cargo.lock                                |   3 
assets/keymaps/default-linux.json         |   6 +
assets/keymaps/default-macos.json         |   6 +
crates/assistant2/src/assistant2.rs       |   8 
crates/gpui/src/elements/text.rs          |  13 ++
crates/markdown/examples/markdown.rs      |   5 
crates/markdown/src/markdown.rs           | 124 ++++++++++++++++++++++--
crates/project_panel/src/project_panel.rs |   4 
crates/recent_projects/Cargo.toml         |   3 
crates/recent_projects/src/dev_servers.rs |  56 ++++++----
10 files changed, 184 insertions(+), 44 deletions(-)

Detailed changes

Cargo.lock πŸ”—

@@ -8044,6 +8044,7 @@ dependencies = [
  "fuzzy",
  "gpui",
  "language",
+ "markdown",
  "menu",
  "ordered-float 2.10.0",
  "picker",
@@ -8051,9 +8052,7 @@ dependencies = [
  "rpc",
  "serde",
  "serde_json",
- "settings",
  "smol",
- "theme",
  "ui",
  "ui_text_field",
  "util",

assets/keymaps/default-linux.json πŸ”—

@@ -191,6 +191,12 @@
       "ctrl-shift-enter": "editor::NewlineBelow"
     }
   },
+  {
+    "context": "Markdown",
+    "bindings": {
+      "ctrl-c": "markdown::Copy"
+    }
+  },
   {
     "context": "AssistantPanel",
     "bindings": {

assets/keymaps/default-macos.json πŸ”—

@@ -207,6 +207,12 @@
       "ctrl-shift-enter": "editor::NewlineBelow"
     }
   },
+  {
+    "context": "Markdown",
+    "bindings": {
+      "cmd-c": "markdown::Copy"
+    }
+  },
   {
     "context": "AssistantPanel", // Used in the assistant crate, which we're replacing
     "bindings": {

crates/assistant2/src/assistant2.rs πŸ”—

@@ -440,7 +440,7 @@ impl AssistantChat {
                     Markdown::new(
                         text,
                         self.markdown_style.clone(),
-                        self.language_registry.clone(),
+                        Some(self.language_registry.clone()),
                         cx,
                     )
                 });
@@ -573,7 +573,7 @@ impl AssistantChat {
                                         Markdown::new(
                                             "".into(),
                                             this.markdown_style.clone(),
-                                            this.language_registry.clone(),
+                                            Some(this.language_registry.clone()),
                                             cx,
                                         )
                                     }),
@@ -667,7 +667,7 @@ impl AssistantChat {
                     Markdown::new(
                         "".into(),
                         self.markdown_style.clone(),
-                        self.language_registry.clone(),
+                        Some(self.language_registry.clone()),
                         cx,
                     )
                 }),
@@ -683,7 +683,7 @@ impl AssistantChat {
                     Markdown::new(
                         "".into(),
                         self.markdown_style.clone(),
-                        self.language_registry.clone(),
+                        Some(self.language_registry.clone()),
                         cx,
                     )
                 }),

crates/gpui/src/elements/text.rs πŸ”—

@@ -432,6 +432,19 @@ impl TextLayout {
     pub fn line_height(&self) -> Pixels {
         self.0.lock().as_ref().unwrap().line_height
     }
+
+    /// todo!()
+    pub fn text(&self) -> String {
+        self.0
+            .lock()
+            .as_ref()
+            .unwrap()
+            .lines
+            .iter()
+            .map(|s| s.text.to_string())
+            .collect::<Vec<_>>()
+            .join("\n")
+    }
 }
 
 /// A text element that can be interacted with.

crates/markdown/examples/markdown.rs πŸ”—

@@ -1,5 +1,5 @@
 use assets::Assets;
-use gpui::{prelude::*, App, Task, View, WindowOptions};
+use gpui::{prelude::*, App, KeyBinding, Task, View, WindowOptions};
 use language::{language_settings::AllLanguageSettings, LanguageRegistry};
 use markdown::{Markdown, MarkdownStyle};
 use node_runtime::FakeNodeRuntime;
@@ -91,6 +91,7 @@ pub fn main() {
         SettingsStore::update(cx, |store, cx| {
             store.update_user_settings::<AllLanguageSettings>(cx, |_| {});
         });
+        cx.bind_keys([KeyBinding::new("cmd-c", markdown::Copy, None)]);
 
         let node_runtime = FakeNodeRuntime::new();
         let language_registry = Arc::new(LanguageRegistry::new(
@@ -161,7 +162,7 @@ impl MarkdownExample {
         language_registry: Arc<LanguageRegistry>,
         cx: &mut WindowContext,
     ) -> Self {
-        let markdown = cx.new_view(|cx| Markdown::new(text, style, language_registry, cx));
+        let markdown = cx.new_view(|cx| Markdown::new(text, style, Some(language_registry), cx));
         Self { markdown }
     }
 }

crates/markdown/src/markdown.rs πŸ”—

@@ -3,10 +3,11 @@ mod parser;
 use crate::parser::CodeBlockKind;
 use futures::FutureExt;
 use gpui::{
-    point, quad, AnyElement, AppContext, Bounds, CursorStyle, DispatchPhase, Edges, FocusHandle,
-    FocusableView, FontStyle, FontWeight, GlobalElementId, Hitbox, Hsla, KeyContext,
-    MouseDownEvent, MouseEvent, MouseMoveEvent, MouseUpEvent, Point, Render, StrikethroughStyle,
-    Style, StyledText, Task, TextLayout, TextRun, TextStyle, TextStyleRefinement, View,
+    actions, point, quad, AnyElement, AppContext, Bounds, ClipboardItem, CursorStyle,
+    DispatchPhase, Edges, FocusHandle, FocusableView, FontStyle, FontWeight, GlobalElementId,
+    Hitbox, Hsla, KeyContext, MouseDownEvent, MouseEvent, MouseMoveEvent, MouseUpEvent, Point,
+    Render, StrikethroughStyle, Style, StyledText, Task, TextLayout, TextRun, TextStyle,
+    TextStyleRefinement, View,
 };
 use language::{Language, LanguageRegistry, Rope};
 use parser::{parse_markdown, MarkdownEvent, MarkdownTag, MarkdownTagEnd};
@@ -37,14 +38,16 @@ pub struct Markdown {
     should_reparse: bool,
     pending_parse: Option<Task<Option<()>>>,
     focus_handle: FocusHandle,
-    language_registry: Arc<LanguageRegistry>,
+    language_registry: Option<Arc<LanguageRegistry>>,
 }
 
+actions!(markdown, [Copy]);
+
 impl Markdown {
     pub fn new(
         source: String,
         style: MarkdownStyle,
-        language_registry: Arc<LanguageRegistry>,
+        language_registry: Option<Arc<LanguageRegistry>>,
         cx: &mut ViewContext<Self>,
     ) -> Self {
         let focus_handle = cx.focus_handle();
@@ -83,6 +86,11 @@ impl Markdown {
         &self.source
     }
 
+    fn copy(&self, text: &RenderedText, cx: &mut ViewContext<Self>) {
+        let text = text.text_for_range(self.selection.start..self.selection.end);
+        cx.write_to_clipboard(ClipboardItem::new(text));
+    }
+
     fn parse(&mut self, cx: &mut ViewContext<Self>) {
         if self.source.is_empty() {
             return;
@@ -191,14 +199,14 @@ impl Default for ParsedMarkdown {
 pub struct MarkdownElement {
     markdown: View<Markdown>,
     style: MarkdownStyle,
-    language_registry: Arc<LanguageRegistry>,
+    language_registry: Option<Arc<LanguageRegistry>>,
 }
 
 impl MarkdownElement {
     fn new(
         markdown: View<Markdown>,
         style: MarkdownStyle,
-        language_registry: Arc<LanguageRegistry>,
+        language_registry: Option<Arc<LanguageRegistry>>,
     ) -> Self {
         Self {
             markdown,
@@ -210,6 +218,7 @@ impl MarkdownElement {
     fn load_language(&self, name: &str, cx: &mut WindowContext) -> Option<Arc<Language>> {
         let language = self
             .language_registry
+            .as_ref()?
             .language_for_name(name)
             .map(|language| language.ok())
             .shared();
@@ -322,13 +331,21 @@ impl MarkdownElement {
                                 match rendered_text.source_index_for_position(event.position) {
                                     Ok(ix) | Err(ix) => ix,
                                 };
+                            let range = if event.click_count == 2 {
+                                rendered_text.surrounding_word_range(source_index)
+                            } else if event.click_count == 3 {
+                                rendered_text.surrounding_line_range(source_index)
+                            } else {
+                                source_index..source_index
+                            };
                             markdown.selection = Selection {
-                                start: source_index,
-                                end: source_index,
+                                start: range.start,
+                                end: range.end,
                                 reversed: false,
                                 pending: true,
                             };
                             cx.focus(&markdown.focus_handle);
+                            cx.prevent_default()
                         }
 
                         cx.notify();
@@ -378,6 +395,12 @@ impl MarkdownElement {
                 } else {
                     if markdown.selection.pending {
                         markdown.selection.pending = false;
+                        #[cfg(target_os = "linux")]
+                        {
+                            let text = rendered_text
+                                .text_for_range(markdown.selection.start..markdown.selection.end);
+                            cx.write_to_primary(ClipboardItem::new(text))
+                        }
                         cx.notify();
                     }
                 }
@@ -619,6 +642,16 @@ impl Element for MarkdownElement {
         let mut context = KeyContext::default();
         context.add("Markdown");
         cx.set_key_context(context);
+        let view = self.markdown.clone();
+        cx.on_action(std::any::TypeId::of::<crate::Copy>(), {
+            let text = rendered_markdown.text.clone();
+            move |_, phase, cx| {
+                let text = text.clone();
+                if phase == DispatchPhase::Bubble {
+                    view.update(cx, move |this, cx| this.copy(&text, cx))
+                }
+            }
+        });
 
         self.paint_mouse_listeners(hitbox, &rendered_markdown.text, cx);
         rendered_markdown.element.paint(cx);
@@ -920,6 +953,77 @@ impl RenderedText {
         None
     }
 
+    fn surrounding_word_range(&self, source_index: usize) -> Range<usize> {
+        for line in self.lines.iter() {
+            if source_index > line.source_end {
+                continue;
+            }
+
+            let line_rendered_start = line.source_mappings.first().unwrap().rendered_index;
+            let rendered_index_in_line =
+                line.rendered_index_for_source_index(source_index) - line_rendered_start;
+            let text = line.layout.text();
+            let previous_space = if let Some(idx) = text[0..rendered_index_in_line].rfind(' ') {
+                idx + ' '.len_utf8()
+            } else {
+                0
+            };
+            let next_space = if let Some(idx) = text[rendered_index_in_line..].find(' ') {
+                rendered_index_in_line + idx
+            } else {
+                text.len()
+            };
+
+            return line.source_index_for_rendered_index(line_rendered_start + previous_space)
+                ..line.source_index_for_rendered_index(line_rendered_start + next_space);
+        }
+
+        source_index..source_index
+    }
+
+    fn surrounding_line_range(&self, source_index: usize) -> Range<usize> {
+        for line in self.lines.iter() {
+            if source_index > line.source_end {
+                continue;
+            }
+            let line_source_start = line.source_mappings.first().unwrap().source_index;
+            return line_source_start..line.source_end;
+        }
+
+        source_index..source_index
+    }
+
+    fn text_for_range(&self, range: Range<usize>) -> String {
+        let mut ret = vec![];
+
+        for line in self.lines.iter() {
+            if range.start > line.source_end {
+                continue;
+            }
+            let line_source_start = line.source_mappings.first().unwrap().source_index;
+            if range.end < line_source_start {
+                break;
+            }
+
+            let text = line.layout.text();
+
+            let start = if range.start < line_source_start {
+                0
+            } else {
+                line.rendered_index_for_source_index(range.start)
+            };
+            let end = if range.end > line.source_end {
+                line.rendered_index_for_source_index(line.source_end)
+            } else {
+                line.rendered_index_for_source_index(range.end)
+            }
+            .min(text.len());
+
+            ret.push(text[start..end].to_string());
+        }
+        ret.join("\n")
+    }
+
     fn link_for_position(&self, position: Point<Pixels>) -> Option<&RenderedLink> {
         let source_index = self.source_index_for_position(position).ok()?;
         self.links

crates/project_panel/src/project_panel.rs πŸ”—

@@ -102,7 +102,7 @@ pub struct EntryDetails {
     is_processing: bool,
     is_cut: bool,
     git_status: Option<GitFileStatus>,
-    is_dotenv: bool,
+    is_private: bool,
 }
 
 #[derive(PartialEq, Clone, Default, Debug, Deserialize)]
@@ -1592,7 +1592,7 @@ impl ProjectPanel {
                             .clipboard_entry
                             .map_or(false, |e| e.is_cut() && e.entry_id() == entry.id),
                         git_status: status,
-                        is_dotenv: entry.is_private,
+                        is_private: entry.is_private,
                     };
 
                     if let Some(edit_state) = &self.edit_state {

crates/recent_projects/Cargo.toml πŸ”—

@@ -18,15 +18,14 @@ editor.workspace = true
 feature_flags.workspace = true
 fuzzy.workspace = true
 gpui.workspace = true
+markdown.workspace = true
 menu.workspace = true
 ordered-float.workspace = true
 picker.workspace = true
 dev_server_projects.workspace = true
 rpc.workspace = true
 serde.workspace = true
-settings.workspace = true
 smol.workspace = true
-theme.workspace = true
 ui.workspace = true
 ui_text_field.workspace = true
 util.workspace = true

crates/recent_projects/src/dev_servers.rs πŸ”—

@@ -10,12 +10,12 @@ use gpui::{
     DismissEvent, EventEmitter, FocusHandle, FocusableView, Model, ScrollHandle, Transformation,
     View, ViewContext,
 };
+use markdown::Markdown;
+use markdown::MarkdownStyle;
 use rpc::{
     proto::{CreateDevServerResponse, DevServerStatus, RegenerateDevServerTokenResponse},
     ErrorCode, ErrorExt,
 };
-use settings::Settings;
-use theme::ThemeSettings;
 use ui::CheckboxWithLabel;
 use ui::{prelude::*, Indicator, List, ListHeader, ListItem, ModalContent, ModalHeader, Tooltip};
 use ui_text_field::{FieldLabelLayout, TextField};
@@ -33,6 +33,7 @@ pub struct DevServerProjects {
     dev_server_name_input: View<TextField>,
     use_server_name_in_ssh: Selection,
     rename_dev_server_input: View<TextField>,
+    markdown: View<Markdown>,
     _dev_server_subscription: Subscription,
 }
 
@@ -113,6 +114,23 @@ impl DevServerProjects {
             cx.notify();
         });
 
+        let markdown_style = MarkdownStyle {
+            code_block: gpui::TextStyleRefinement {
+                font_family: Some("Zed Mono".into()),
+                color: Some(cx.theme().colors().editor_foreground),
+                background_color: Some(cx.theme().colors().editor_background),
+                ..Default::default()
+            },
+            inline_code: Default::default(),
+            block_quote: Default::default(),
+            link: Default::default(),
+            rule_color: Default::default(),
+            block_quote_border_color: Default::default(),
+            syntax: cx.theme().syntax().clone(),
+            selection_background_color: cx.theme().players().local().selection,
+        };
+        let markdown = cx.new_view(|cx| Markdown::new("".to_string(), markdown_style, None, cx));
+
         Self {
             mode: Mode::Default(None),
             focus_handle,
@@ -121,6 +139,7 @@ impl DevServerProjects {
             project_path_input,
             dev_server_name_input,
             rename_dev_server_input,
+            markdown,
             use_server_name_in_ssh: Selection::Unselected,
             _dev_server_subscription: subscription,
         }
@@ -726,7 +745,7 @@ impl DevServerProjects {
                                                 .child(
                                                     CheckboxWithLabel::new(
                                                         "use-server-name-in-ssh",
-                                                        Label::new("Use name as ssh connection string"),
+                                                        Label::new("Use SSH for terminals"),
                                                         self.use_server_name_in_ssh,
                                                         |&_, _| {}
                                                     )
@@ -748,7 +767,7 @@ impl DevServerProjects {
                             };
                             div.px_2().child(Label::new(format!(
                                 "Once you have created a dev server, you will be given a command to run on the server to register it.\n\n\
-                                Ssh connection string enables remote terminals, which runs `ssh {ssh_host_name}` when creating terminal tabs."
+                                If you enable SSH, then the terminal will automatically `ssh {ssh_host_name}` on open."
                             )))
                         })
                         .when_some(dev_server.clone(), |div, dev_server| {
@@ -758,7 +777,7 @@ impl DevServerProjects {
                                 .dev_server_status(DevServerId(dev_server.dev_server_id));
 
                             div.child(
-                                 Self::render_dev_server_token_instructions(&dev_server.access_token, &dev_server.name, status, cx)
+                                 self.render_dev_server_token_instructions(&dev_server.access_token, &dev_server.name, status, cx)
                             )
                         }),
                 )
@@ -766,12 +785,18 @@ impl DevServerProjects {
     }
 
     fn render_dev_server_token_instructions(
+        &self,
         access_token: &str,
         dev_server_name: &str,
         status: DevServerStatus,
         cx: &mut ViewContext<Self>,
     ) -> Div {
         let instructions = SharedString::from(format!("zed --dev-server-token {}", access_token));
+        self.markdown.update(cx, |markdown, cx| {
+            if !markdown.source().contains(access_token) {
+                markdown.reset(format!("```\n{}\n```", instructions), cx);
+            }
+        });
 
         v_flex()
             .pl_2()
@@ -799,19 +824,7 @@ impl DevServerProjects {
                             }),
                     ),
             )
-            .child(
-                v_flex()
-                    .w_full()
-                    .bg(cx.theme().colors().title_bar_background) // todo: this should be distinct
-                    .border_1()
-                    .border_color(cx.theme().colors().border_variant)
-                    .rounded_md()
-                    .my_1()
-                    .py_0p5()
-                    .px_3()
-                    .font_family(ThemeSettings::get_global(cx).buffer_font.family.clone())
-                    .child(Label::new(instructions)),
-            )
+            .child(v_flex().w_full().child(self.markdown.clone()))
             .when(status == DevServerStatus::Offline, |this| {
                 this.child(Self::render_loading_spinner("Waiting for connection…"))
             })
@@ -926,14 +939,13 @@ impl DevServerProjects {
             EditDevServerState::RegeneratingToken => {
                 Self::render_loading_spinner("Generating token...")
             }
-            EditDevServerState::RegeneratedToken(response) => {
-                Self::render_dev_server_token_instructions(
+            EditDevServerState::RegeneratedToken(response) => self
+                .render_dev_server_token_instructions(
                     &response.access_token,
                     &dev_server_name,
                     dev_server_status,
                     cx,
-                )
-            }
+                ),
             _ => h_flex().items_end().w_full().child(
                 Button::new("regenerate-dev-server-token", "Generate new access token")
                     .icon(IconName::Update)