Merge branch 'master' into rescan

Nathan Sobo created

Change summary

.github/workflows/ci.yml             |   2 
Cargo.lock                           |  17 +
gpui/Cargo.toml                      |   4 
gpui/src/app.rs                      |  35 ++
gpui/src/clipboard.rs                |  42 +++
gpui/src/elements/align.rs           |  11 
gpui/src/elements/constrained_box.rs |  14 +
gpui/src/elements/container.rs       |  12 +
gpui/src/elements/flex.rs            |  26 +
gpui/src/elements/new.rs             |  25 ++
gpui/src/lib.rs                      |   2 
gpui/src/platform/mac/platform.rs    | 181 ++++++++++++++--
gpui/src/platform/mod.rs             |  11 
gpui/src/platform/test.rs            |  21 +
zed/Cargo.toml                       |   1 
zed/src/editor/buffer/anchor.rs      |  22 ++
zed/src/editor/buffer/mod.rs         |  21 +
zed/src/editor/buffer_view.rs        | 318 +++++++++++++++++++++++++++++
zed/src/file_finder.rs               |   2 
zed/src/workspace/mod.rs             |  26 +-
zed/src/workspace/pane.rs            |  93 +++++---
zed/src/workspace/workspace_view.rs  |   7 
22 files changed, 768 insertions(+), 125 deletions(-)

Detailed changes

.github/workflows/ci.yml 🔗

@@ -15,7 +15,7 @@ env:
 jobs:
   tests:
     name: Tests
-    runs-on: macos-latest
+    runs-on: self-hosted
     steps:
       - name: Checkout repo
         uses: actions/checkout@v2

Cargo.lock 🔗

@@ -968,6 +968,7 @@ dependencies = [
  "async-std",
  "async-task",
  "bindgen",
+ "block",
  "cc",
  "cocoa",
  "core-foundation",
@@ -990,6 +991,7 @@ dependencies = [
  "replace_with",
  "resvg",
  "scoped-pool",
+ "seahash",
  "serde",
  "serde_json",
  "simplelog",
@@ -1837,6 +1839,20 @@ name = "serde"
 version = "1.0.125"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "558dc50e1a5a5fa7112ca2ce4effcb321b0300c0d4ccf0776a9f60cd89031171"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.125"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b093b7a2bb58203b5da3056c05b4ec1fed827dcfdb37347a8841695263b3d06d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
 
 [[package]]
 name = "serde_json"
@@ -2429,6 +2445,7 @@ dependencies = [
  "rand 0.8.3",
  "rust-embed",
  "seahash",
+ "serde",
  "serde_json",
  "simplelog",
  "smallvec",

gpui/Cargo.toml 🔗

@@ -19,7 +19,8 @@ rand = "0.8.3"
 replace_with = "0.1.7"
 resvg = "0.14"
 scoped-pool = "1.0.0"
-serde = "1.0.125"
+seahash = "4.1"
+serde = {version = "1.0.125", features = ["derive"]}
 serde_json = "1.0.64"
 smallvec = "1.6.1"
 smol = "1.2"
@@ -37,6 +38,7 @@ simplelog = "0.9"
 
 [target.'cfg(target_os = "macos")'.dependencies]
 anyhow = "1"
+block = "0.1"
 cocoa = "0.24"
 core-foundation = "0.9"
 core-graphics = "0.22.2"

gpui/src/app.rs 🔗

@@ -5,7 +5,7 @@ use crate::{
     platform::{self, WindowOptions},
     presenter::Presenter,
     util::post_inc,
-    AssetCache, AssetSource, FontCache, TextLayoutCache,
+    AssetCache, AssetSource, ClipboardItem, FontCache, PathPromptOptions, TextLayoutCache,
 };
 use anyhow::{anyhow, Result};
 use async_std::sync::Condvar;
@@ -141,12 +141,13 @@ impl App {
                 {
                     let presenter = presenter.clone();
                     let path = presenter.borrow().dispatch_path(ctx.as_ref());
-                    if ctx.dispatch_action_any(key_window_id, &path, command, arg.unwrap_or(&())) {
-                        return;
-                    }
+                    ctx.dispatch_action_any(key_window_id, &path, command, arg.unwrap_or(&()));
+                } else {
+                    ctx.dispatch_global_action_any(command, arg.unwrap_or(&()));
                 }
+            } else {
+                ctx.dispatch_global_action_any(command, arg.unwrap_or(&()));
             }
-            ctx.dispatch_global_action_any(command, arg.unwrap_or(&()));
         }));
 
         app.0.borrow_mut().weak_self = Some(Rc::downgrade(&app.0));
@@ -570,6 +571,22 @@ impl MutableAppContext {
         self.platform.set_menus(menus);
     }
 
+    pub fn prompt_for_paths<F>(&self, options: PathPromptOptions, done_fn: F)
+    where
+        F: 'static + FnOnce(Option<Vec<PathBuf>>, &mut MutableAppContext),
+    {
+        let app = self.weak_self.as_ref().unwrap().upgrade().unwrap();
+        let foreground = self.foreground.clone();
+        self.platform().prompt_for_paths(
+            options,
+            Box::new(move |paths| {
+                foreground
+                    .spawn(async move { (done_fn)(paths, &mut *app.borrow_mut()) })
+                    .detach();
+            }),
+        );
+    }
+
     pub fn dispatch_action<T: 'static + Any>(
         &mut self,
         window_id: usize,
@@ -1213,8 +1230,12 @@ impl MutableAppContext {
         }
     }
 
-    pub fn copy(&self, text: &str) {
-        self.platform.copy(text);
+    pub fn write_to_clipboard(&self, item: ClipboardItem) {
+        self.platform.write_to_clipboard(item);
+    }
+
+    pub fn read_from_clipboard(&self) -> Option<ClipboardItem> {
+        self.platform.read_from_clipboard()
     }
 }
 

gpui/src/clipboard.rs 🔗

@@ -0,0 +1,42 @@
+use seahash::SeaHasher;
+use serde::{Deserialize, Serialize};
+use std::hash::{Hash, Hasher};
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct ClipboardItem {
+    pub(crate) text: String,
+    pub(crate) metadata: Option<String>,
+}
+
+impl ClipboardItem {
+    pub fn new(text: String) -> Self {
+        Self {
+            text,
+            metadata: None,
+        }
+    }
+
+    pub fn with_metadata<T: Serialize>(mut self, metadata: T) -> Self {
+        self.metadata = Some(serde_json::to_string(&metadata).unwrap());
+        self
+    }
+
+    pub fn text(&self) -> &String {
+        &self.text
+    }
+
+    pub fn metadata<T>(&self) -> Option<T>
+    where
+        T: for<'a> Deserialize<'a>,
+    {
+        self.metadata
+            .as_ref()
+            .and_then(|m| serde_json::from_str(m).ok())
+    }
+
+    pub(crate) fn text_hash(text: &str) -> u64 {
+        let mut hasher = SeaHasher::new();
+        text.hash(&mut hasher);
+        hasher.finish()
+    }
+}

gpui/src/elements/align.rs 🔗

@@ -3,7 +3,7 @@ use crate::{
     LayoutContext, PaintContext, SizeConstraint,
 };
 use json::ToJson;
-use pathfinder_geometry::vector::{vec2f, Vector2F};
+use pathfinder_geometry::vector::Vector2F;
 use serde_json::json;
 
 pub struct Align {
@@ -19,8 +19,13 @@ impl Align {
         }
     }
 
-    pub fn top_center(mut self) -> Self {
-        self.alignment = vec2f(0.0, -1.0);
+    pub fn top(mut self) -> Self {
+        self.alignment.set_y(-1.0);
+        self
+    }
+
+    pub fn right(mut self) -> Self {
+        self.alignment.set_x(1.0);
         self
     }
 }

gpui/src/elements/constrained_box.rs 🔗

@@ -23,6 +23,11 @@ impl ConstrainedBox {
         }
     }
 
+    pub fn with_min_width(mut self, min_width: f32) -> Self {
+        self.constraint.min.set_x(min_width);
+        self
+    }
+
     pub fn with_max_width(mut self, max_width: f32) -> Self {
         self.constraint.max.set_x(max_width);
         self
@@ -33,6 +38,12 @@ impl ConstrainedBox {
         self
     }
 
+    pub fn with_width(mut self, width: f32) -> Self {
+        self.constraint.min.set_x(width);
+        self.constraint.max.set_x(width);
+        self
+    }
+
     pub fn with_height(mut self, height: f32) -> Self {
         self.constraint.min.set_y(height);
         self.constraint.max.set_y(height);
@@ -51,6 +62,7 @@ impl Element for ConstrainedBox {
     ) -> (Vector2F, Self::LayoutState) {
         constraint.min = constraint.min.max(self.constraint.min);
         constraint.max = constraint.max.min(self.constraint.max);
+        constraint.max = constraint.max.max(constraint.min);
         let size = self.child.layout(constraint, ctx);
         (size, ())
     }
@@ -91,6 +103,6 @@ impl Element for ConstrainedBox {
         _: &Self::PaintState,
         ctx: &DebugContext,
     ) -> json::Value {
-        json!({"type": "ConstrainedBox", "constraint": self.constraint.to_json(), "child": self.child.debug(ctx)})
+        json!({"type": "ConstrainedBox", "set_constraint": self.constraint.to_json(), "child": self.child.debug(ctx)})
     }
 }

gpui/src/elements/container.rs 🔗

@@ -43,6 +43,18 @@ impl Container {
         self
     }
 
+    pub fn with_horizontal_padding(mut self, padding: f32) -> Self {
+        self.padding.left = padding;
+        self.padding.right = padding;
+        self
+    }
+
+    pub fn with_vertical_padding(mut self, padding: f32) -> Self {
+        self.padding.top = padding;
+        self.padding.bottom = padding;
+        self
+    }
+
     pub fn with_uniform_padding(mut self, padding: f32) -> Self {
         self.padding = Padding {
             top: padding,

gpui/src/elements/flex.rs 🔗

@@ -1,4 +1,4 @@
-use std::any::Any;
+use std::{any::Any, f32::INFINITY};
 
 use crate::{
     json::{self, ToJson, Value},
@@ -64,8 +64,16 @@ impl Element for Flex {
             if let Some(flex) = Self::child_flex(&child) {
                 total_flex += flex;
             } else {
-                let child_constraint =
-                    SizeConstraint::strict_along(cross_axis, constraint.max_along(cross_axis));
+                let child_constraint = match self.axis {
+                    Axis::Horizontal => SizeConstraint::new(
+                        vec2f(0.0, constraint.min.y()),
+                        vec2f(INFINITY, constraint.max.y()),
+                    ),
+                    Axis::Vertical => SizeConstraint::new(
+                        vec2f(constraint.min.x(), 0.0),
+                        vec2f(constraint.max.x(), INFINITY),
+                    ),
+                };
                 let size = child.layout(child_constraint, ctx);
                 fixed_space += size.along(self.axis);
                 cross_axis_max = cross_axis_max.max(size.along(cross_axis));
@@ -80,16 +88,20 @@ impl Element for Flex {
             let mut remaining_space = constraint.max_along(self.axis) - fixed_space;
             let mut remaining_flex = total_flex;
             for child in &mut self.children {
-                let space_per_flex = remaining_space / remaining_flex;
                 if let Some(flex) = Self::child_flex(&child) {
-                    let child_max = space_per_flex * flex;
+                    let child_max = if remaining_flex == 0.0 {
+                        remaining_space
+                    } else {
+                        let space_per_flex = remaining_space / remaining_flex;
+                        space_per_flex * flex
+                    };
                     let child_constraint = match self.axis {
                         Axis::Horizontal => SizeConstraint::new(
-                            vec2f(0.0, constraint.max.y()),
+                            vec2f(0.0, constraint.min.y()),
                             vec2f(child_max, constraint.max.y()),
                         ),
                         Axis::Vertical => SizeConstraint::new(
-                            vec2f(constraint.max.x(), 0.0),
+                            vec2f(constraint.min.x(), 0.0),
                             vec2f(constraint.max.x(), child_max),
                         ),
                     };

gpui/src/elements/new.rs 🔗

@@ -4,6 +4,7 @@ use crate::{
     SizeConstraint,
 };
 use core::panic;
+use json::ToJson;
 use replace_with::replace_with_or_abort;
 use std::{any::Any, borrow::Cow};
 
@@ -90,11 +91,13 @@ pub enum Lifecycle<T: Element> {
     },
     PostLayout {
         element: T,
+        constraint: SizeConstraint,
         size: Vector2F,
         layout: T::LayoutState,
     },
     PostPaint {
         element: T,
+        constraint: SizeConstraint,
         bounds: RectF,
         layout: T::LayoutState,
         paint: T::PaintState,
@@ -119,6 +122,7 @@ impl<T: Element> AnyElement for Lifecycle<T> {
                 result = Some(size);
                 Lifecycle::PostLayout {
                     element,
+                    constraint,
                     size,
                     layout,
                 }
@@ -132,6 +136,7 @@ impl<T: Element> AnyElement for Lifecycle<T> {
             element,
             size,
             layout,
+            ..
         } = self
         {
             element.after_layout(*size, layout, ctx);
@@ -144,6 +149,7 @@ impl<T: Element> AnyElement for Lifecycle<T> {
         replace_with_or_abort(self, |me| {
             if let Lifecycle::PostLayout {
                 mut element,
+                constraint,
                 size,
                 mut layout,
             } = me
@@ -152,6 +158,7 @@ impl<T: Element> AnyElement for Lifecycle<T> {
                 let paint = element.paint(bounds, &mut layout, ctx);
                 Lifecycle::PostPaint {
                     element,
+                    constraint,
                     bounds,
                     layout,
                     paint,
@@ -168,6 +175,7 @@ impl<T: Element> AnyElement for Lifecycle<T> {
             bounds,
             layout,
             paint,
+            ..
         } = self
         {
             element.dispatch_event(event, *bounds, layout, paint, ctx)
@@ -196,10 +204,25 @@ impl<T: Element> AnyElement for Lifecycle<T> {
         match self {
             Lifecycle::PostPaint {
                 element,
+                constraint,
                 bounds,
                 layout,
                 paint,
-            } => element.debug(*bounds, layout, paint, ctx),
+            } => {
+                let mut value = element.debug(*bounds, layout, paint, ctx);
+                if let json::Value::Object(map) = &mut value {
+                    let mut new_map: crate::json::Map<String, serde_json::Value> =
+                        Default::default();
+                    if let Some(typ) = map.remove("type") {
+                        new_map.insert("type".into(), typ);
+                    }
+                    new_map.insert("constraint".into(), constraint.to_json());
+                    new_map.append(map);
+                    json::Value::Object(new_map)
+                } else {
+                    value
+                }
+            }
             _ => panic!("invalid element lifecycle state"),
         }
     }

gpui/src/lib.rs 🔗

@@ -7,6 +7,8 @@ pub use assets::*;
 pub mod elements;
 pub mod font_cache;
 pub use font_cache::FontCache;
+mod clipboard;
+pub use clipboard::ClipboardItem;
 pub mod fonts;
 pub mod geometry;
 mod presenter;

gpui/src/platform/mac/platform.rs 🔗

@@ -1,5 +1,6 @@
 use super::{BoolExt as _, Dispatcher, FontSystem, Window};
-use crate::{executor, keymap::Keystroke, platform, Event, Menu, MenuItem};
+use crate::{executor, keymap::Keystroke, platform, ClipboardItem, Event, Menu, MenuItem};
+use block::ConcreteBlock;
 use cocoa::{
     appkit::{
         NSApplication, NSApplicationActivationPolicy::NSApplicationActivationPolicyRegular,
@@ -20,12 +21,14 @@ use objc::{
 use ptr::null_mut;
 use std::{
     any::Any,
-    cell::RefCell,
+    cell::{Cell, RefCell},
+    convert::TryInto,
     ffi::{c_void, CStr},
     os::raw::c_char,
     path::PathBuf,
     ptr,
     rc::Rc,
+    slice, str,
     sync::Arc,
 };
 
@@ -77,6 +80,9 @@ pub struct MacPlatform {
     fonts: Arc<FontSystem>,
     callbacks: RefCell<Callbacks>,
     menu_item_actions: RefCell<Vec<(String, Option<Box<dyn Any>>)>>,
+    pasteboard: id,
+    text_hash_pasteboard_type: id,
+    metadata_pasteboard_type: id,
 }
 
 #[derive(Default)]
@@ -96,6 +102,9 @@ impl MacPlatform {
             fonts: Arc::new(FontSystem::new()),
             callbacks: Default::default(),
             menu_item_actions: Default::default(),
+            pasteboard: unsafe { NSPasteboard::generalPasteboard(nil) },
+            text_hash_pasteboard_type: unsafe { ns_string("zed-text-hash") },
+            metadata_pasteboard_type: unsafe { ns_string("zed-metadata") },
         }
     }
 
@@ -176,6 +185,18 @@ impl MacPlatform {
 
         menu_bar
     }
+
+    unsafe fn read_from_pasteboard(&self, kind: id) -> Option<&[u8]> {
+        let data = self.pasteboard.dataForType(kind);
+        if data == nil {
+            None
+        } else {
+            Some(slice::from_raw_parts(
+                data.bytes() as *mut u8,
+                data.length() as usize,
+            ))
+        }
+    }
 }
 
 impl platform::Platform for MacPlatform {
@@ -247,31 +268,40 @@ impl platform::Platform for MacPlatform {
     fn prompt_for_paths(
         &self,
         options: platform::PathPromptOptions,
-    ) -> Option<Vec<std::path::PathBuf>> {
+        done_fn: Box<dyn FnOnce(Option<Vec<std::path::PathBuf>>)>,
+    ) {
         unsafe {
             let panel = NSOpenPanel::openPanel(nil);
             panel.setCanChooseDirectories_(options.directories.to_objc());
             panel.setCanChooseFiles_(options.files.to_objc());
             panel.setAllowsMultipleSelection_(options.multiple.to_objc());
             panel.setResolvesAliases_(false.to_objc());
-            let response = panel.runModal();
-            if response == NSModalResponse::NSModalResponseOk {
-                let mut result = Vec::new();
-                let urls = panel.URLs();
-                for i in 0..urls.count() {
-                    let url = urls.objectAtIndex(i);
-                    let string = url.absoluteString();
-                    let string = std::ffi::CStr::from_ptr(string.UTF8String())
-                        .to_string_lossy()
-                        .to_string();
-                    if let Some(path) = string.strip_prefix("file://") {
-                        result.push(PathBuf::from(path));
+            let done_fn = Cell::new(Some(done_fn));
+            let block = ConcreteBlock::new(move |response: NSModalResponse| {
+                let result = if response == NSModalResponse::NSModalResponseOk {
+                    let mut result = Vec::new();
+                    let urls = panel.URLs();
+                    for i in 0..urls.count() {
+                        let url = urls.objectAtIndex(i);
+                        let string = url.absoluteString();
+                        let string = std::ffi::CStr::from_ptr(string.UTF8String())
+                            .to_string_lossy()
+                            .to_string();
+                        if let Some(path) = string.strip_prefix("file://") {
+                            result.push(PathBuf::from(path));
+                        }
                     }
+                    Some(result)
+                } else {
+                    None
+                };
+
+                if let Some(done_fn) = done_fn.take() {
+                    (done_fn)(result);
                 }
-                Some(result)
-            } else {
-                None
-            }
+            });
+            let block = block.copy();
+            let _: () = msg_send![panel, beginWithCompletionHandler: block];
         }
     }
 
@@ -286,16 +316,72 @@ impl platform::Platform for MacPlatform {
         }
     }
 
-    fn copy(&self, text: &str) {
+    fn write_to_clipboard(&self, item: ClipboardItem) {
         unsafe {
-            let data = NSData::dataWithBytes_length_(
+            self.pasteboard.clearContents();
+
+            let text_bytes = NSData::dataWithBytes_length_(
                 nil,
-                text.as_ptr() as *const c_void,
-                text.len() as u64,
+                item.text.as_ptr() as *const c_void,
+                item.text.len() as u64,
             );
-            let pasteboard = NSPasteboard::generalPasteboard(nil);
-            pasteboard.clearContents();
-            pasteboard.setData_forType(data, NSPasteboardTypeString);
+            self.pasteboard
+                .setData_forType(text_bytes, NSPasteboardTypeString);
+
+            if let Some(metadata) = item.metadata.as_ref() {
+                let hash_bytes = ClipboardItem::text_hash(&item.text).to_be_bytes();
+                let hash_bytes = NSData::dataWithBytes_length_(
+                    nil,
+                    hash_bytes.as_ptr() as *const c_void,
+                    hash_bytes.len() as u64,
+                );
+                self.pasteboard
+                    .setData_forType(hash_bytes, self.text_hash_pasteboard_type);
+
+                let metadata_bytes = NSData::dataWithBytes_length_(
+                    nil,
+                    metadata.as_ptr() as *const c_void,
+                    metadata.len() as u64,
+                );
+                self.pasteboard
+                    .setData_forType(metadata_bytes, self.metadata_pasteboard_type);
+            }
+        }
+    }
+
+    fn read_from_clipboard(&self) -> Option<ClipboardItem> {
+        unsafe {
+            if let Some(text_bytes) = self.read_from_pasteboard(NSPasteboardTypeString) {
+                let text = String::from_utf8_lossy(&text_bytes).to_string();
+                let hash_bytes = self
+                    .read_from_pasteboard(self.text_hash_pasteboard_type)
+                    .and_then(|bytes| bytes.try_into().ok())
+                    .map(u64::from_be_bytes);
+                let metadata_bytes = self
+                    .read_from_pasteboard(self.metadata_pasteboard_type)
+                    .and_then(|bytes| String::from_utf8(bytes.to_vec()).ok());
+
+                if let Some((hash, metadata)) = hash_bytes.zip(metadata_bytes) {
+                    if hash == ClipboardItem::text_hash(&text) {
+                        Some(ClipboardItem {
+                            text,
+                            metadata: Some(metadata),
+                        })
+                    } else {
+                        Some(ClipboardItem {
+                            text,
+                            metadata: None,
+                        })
+                    }
+                } else {
+                    Some(ClipboardItem {
+                        text,
+                        metadata: None,
+                    })
+                }
+            } else {
+                None
+            }
         }
     }
 
@@ -392,3 +478,46 @@ extern "C" fn handle_menu_item(this: &mut Object, _: Sel, item: id) {
 unsafe fn ns_string(string: &str) -> id {
     NSString::alloc(nil).init_str(string).autorelease()
 }
+
+#[cfg(test)]
+mod tests {
+    use crate::platform::Platform;
+
+    use super::*;
+
+    #[test]
+    fn test_clipboard() {
+        let platform = build_platform();
+        assert_eq!(platform.read_from_clipboard(), None);
+
+        let item = ClipboardItem::new("1".to_string());
+        platform.write_to_clipboard(item.clone());
+        assert_eq!(platform.read_from_clipboard(), Some(item));
+
+        let item = ClipboardItem::new("2".to_string()).with_metadata(vec![3, 4]);
+        platform.write_to_clipboard(item.clone());
+        assert_eq!(platform.read_from_clipboard(), Some(item));
+
+        let text_from_other_app = "text from other app";
+        unsafe {
+            let bytes = NSData::dataWithBytes_length_(
+                nil,
+                text_from_other_app.as_ptr() as *const c_void,
+                text_from_other_app.len() as u64,
+            );
+            platform
+                .pasteboard
+                .setData_forType(bytes, NSPasteboardTypeString);
+        }
+        assert_eq!(
+            platform.read_from_clipboard(),
+            Some(ClipboardItem::new(text_from_other_app.to_string()))
+        );
+    }
+
+    fn build_platform() -> MacPlatform {
+        let mut platform = MacPlatform::new();
+        platform.pasteboard = unsafe { NSPasteboard::pasteboardWithUniqueName(nil) };
+        platform
+    }
+}

gpui/src/platform/mod.rs 🔗

@@ -15,7 +15,7 @@ use crate::{
         vector::Vector2F,
     },
     text_layout::Line,
-    Menu, Scene,
+    ClipboardItem, Menu, Scene,
 };
 use async_task::Runnable;
 pub use event::Event;
@@ -40,9 +40,14 @@ pub trait Platform {
         executor: Rc<executor::Foreground>,
     ) -> Box<dyn Window>;
     fn key_window_id(&self) -> Option<usize>;
-    fn prompt_for_paths(&self, options: PathPromptOptions) -> Option<Vec<PathBuf>>;
+    fn prompt_for_paths(
+        &self,
+        options: PathPromptOptions,
+        done_fn: Box<dyn FnOnce(Option<Vec<std::path::PathBuf>>)>,
+    );
     fn quit(&self);
-    fn copy(&self, text: &str);
+    fn write_to_clipboard(&self, item: ClipboardItem);
+    fn read_from_clipboard(&self) -> Option<ClipboardItem>;
     fn set_menus(&self, menus: Vec<Menu>);
 }
 

gpui/src/platform/test.rs 🔗

@@ -1,10 +1,11 @@
+use crate::ClipboardItem;
 use pathfinder_geometry::vector::Vector2F;
-use std::sync::Arc;
-use std::{any::Any, rc::Rc};
+use std::{any::Any, cell::RefCell, rc::Rc, sync::Arc};
 
 struct Platform {
     dispatcher: Arc<dyn super::Dispatcher>,
     fonts: Arc<dyn super::FontSystem>,
+    current_clipboard_item: RefCell<Option<ClipboardItem>>,
 }
 
 struct Dispatcher;
@@ -22,6 +23,7 @@ impl Platform {
         Self {
             dispatcher: Arc::new(Dispatcher),
             fonts: Arc::new(super::current::FontSystem::new()),
+            current_clipboard_item: RefCell::new(None),
         }
     }
 }
@@ -68,11 +70,20 @@ impl super::Platform for Platform {
 
     fn quit(&self) {}
 
-    fn prompt_for_paths(&self, _: super::PathPromptOptions) -> Option<Vec<std::path::PathBuf>> {
-        None
+    fn prompt_for_paths(
+        &self,
+        _: super::PathPromptOptions,
+        _: Box<dyn FnOnce(Option<Vec<std::path::PathBuf>>)>,
+    ) {
     }
 
-    fn copy(&self, _: &str) {}
+    fn write_to_clipboard(&self, item: ClipboardItem) {
+        *self.current_clipboard_item.borrow_mut() = Some(item);
+    }
+
+    fn read_from_clipboard(&self) -> Option<ClipboardItem> {
+        self.current_clipboard_item.borrow().clone()
+    }
 }
 
 impl Window {

zed/Cargo.toml 🔗

@@ -31,6 +31,7 @@ rand = "0.8.3"
 rust-embed = "5.9.0"
 seahash = "4.1"
 simplelog = "0.9"
+serde = { version = "1", features = ["derive"] }
 smallvec = "1.6.1"
 smol = "1.2.5"
 

zed/src/editor/buffer/anchor.rs 🔗

@@ -69,6 +69,28 @@ impl Anchor {
                 .then_with(|| self_bias.cmp(other_bias)),
         })
     }
+
+    pub fn bias_left(&self, buffer: &Buffer) -> Result<Anchor> {
+        match self {
+            Anchor::Start
+            | Anchor::Middle {
+                bias: AnchorBias::Left,
+                ..
+            } => Ok(self.clone()),
+            _ => buffer.anchor_before(self),
+        }
+    }
+
+    pub fn bias_right(&self, buffer: &Buffer) -> Result<Anchor> {
+        match self {
+            Anchor::End
+            | Anchor::Middle {
+                bias: AnchorBias::Right,
+                ..
+            } => Ok(self.clone()),
+            _ => buffer.anchor_after(self),
+        }
+    }
 }
 
 pub trait AnchorRangeExt {

zed/src/editor/buffer/mod.rs 🔗

@@ -563,10 +563,13 @@ impl Buffer {
         self.chars().collect()
     }
 
-    pub fn text_for_range<T: ToOffset>(&self, range: Range<T>) -> Result<String> {
+    pub fn text_for_range<'a, T: ToOffset>(
+        &'a self,
+        range: Range<T>,
+    ) -> Result<impl 'a + Iterator<Item = char>> {
         let start = range.start.to_offset(self)?;
         let end = range.end.to_offset(self)?;
-        Ok(self.chars_at(start)?.take(end - start).collect())
+        Ok(self.chars_at(start)?.take(end - start))
     }
 
     pub fn chars(&self) -> CharIter {
@@ -2261,6 +2264,12 @@ impl ToOffset for Anchor {
     }
 }
 
+impl<'a> ToOffset for &'a Anchor {
+    fn to_offset(&self, buffer: &Buffer) -> Result<usize> {
+        Ok(buffer.summary_for_anchor(self)?.chars)
+    }
+}
+
 pub trait ToPoint {
     fn to_point(&self, buffer: &Buffer) -> Result<Point>;
 }
@@ -2470,13 +2479,9 @@ mod tests {
                     let old_len = old_range.end - old_range.start;
                     let new_len = new_range.end - new_range.start;
                     let old_start = (old_range.start as isize + delta) as usize;
-
+                    let new_text: String = buffer.text_for_range(new_range).unwrap().collect();
                     old_buffer
-                        .edit(
-                            Some(old_start..old_start + old_len),
-                            buffer.text_for_range(new_range).unwrap(),
-                            None,
-                        )
+                        .edit(Some(old_start..old_start + old_len), new_text, None)
                         .unwrap();
 
                     delta += new_len as isize - old_len as isize;

zed/src/editor/buffer_view.rs 🔗

@@ -1,22 +1,24 @@
 use super::{
     buffer, movement, Anchor, Bias, Buffer, BufferElement, DisplayMap, DisplayPoint, Point,
-    Selection, SelectionSetId, ToOffset,
+    Selection, SelectionSetId, ToOffset, ToPoint,
 };
 use crate::{settings::Settings, watch, workspace};
 use anyhow::Result;
 use futures_core::future::LocalBoxFuture;
 use gpui::{
-    fonts::Properties as FontProperties, keymap::Binding, text_layout, AppContext, Element,
-    ElementBox, Entity, FontCache, ModelHandle, MutableAppContext, View, ViewContext,
+    fonts::Properties as FontProperties, keymap::Binding, text_layout, AppContext, ClipboardItem,
+    Element, ElementBox, Entity, FontCache, ModelHandle, MutableAppContext, View, ViewContext,
     WeakViewHandle,
 };
 use gpui::{geometry::vector::Vector2F, TextLayoutCache};
 use parking_lot::Mutex;
+use serde::{Deserialize, Serialize};
 use smallvec::SmallVec;
 use smol::Timer;
 use std::{
     cmp::{self, Ordering},
     fmt::Write,
+    iter::FromIterator,
     ops::Range,
     sync::Arc,
     time::Duration,
@@ -28,6 +30,9 @@ pub fn init(app: &mut MutableAppContext) {
     app.add_bindings(vec![
         Binding::new("backspace", "buffer:backspace", Some("BufferView")),
         Binding::new("enter", "buffer:newline", Some("BufferView")),
+        Binding::new("cmd-x", "buffer:cut", Some("BufferView")),
+        Binding::new("cmd-c", "buffer:copy", Some("BufferView")),
+        Binding::new("cmd-v", "buffer:paste", Some("BufferView")),
         Binding::new("cmd-z", "buffer:undo", Some("BufferView")),
         Binding::new("cmd-shift-Z", "buffer:redo", Some("BufferView")),
         Binding::new("up", "buffer:move_up", Some("BufferView")),
@@ -54,6 +59,9 @@ pub fn init(app: &mut MutableAppContext) {
     app.add_action("buffer:insert", BufferView::insert);
     app.add_action("buffer:newline", BufferView::newline);
     app.add_action("buffer:backspace", BufferView::backspace);
+    app.add_action("buffer:cut", BufferView::cut);
+    app.add_action("buffer:copy", BufferView::copy);
+    app.add_action("buffer:paste", BufferView::paste);
     app.add_action("buffer:undo", BufferView::undo);
     app.add_action("buffer:redo", BufferView::redo);
     app.add_action("buffer:move_up", BufferView::move_up);
@@ -102,6 +110,12 @@ pub struct BufferView {
     single_line: bool,
 }
 
+#[derive(Serialize, Deserialize)]
+struct ClipboardSelection {
+    len: usize,
+    is_entire_line: bool,
+}
+
 impl BufferView {
     pub fn single_line(settings: watch::Receiver<Settings>, ctx: &mut ViewContext<Self>) -> Self {
         let buffer = ctx.add_model(|_| Buffer::new(0, String::new()));
@@ -354,6 +368,25 @@ impl BufferView {
 
     #[cfg(test)]
     fn select_ranges<'a, T>(&mut self, ranges: T, ctx: &mut ViewContext<Self>) -> Result<()>
+    where
+        T: IntoIterator<Item = &'a Range<usize>>,
+    {
+        let buffer = self.buffer.read(ctx);
+        let mut selections = Vec::new();
+        for range in ranges {
+            selections.push(Selection {
+                start: buffer.anchor_before(range.start)?,
+                end: buffer.anchor_before(range.end)?,
+                reversed: false,
+                goal_column: None,
+            });
+        }
+        self.update_selections(selections, ctx);
+        Ok(())
+    }
+
+    #[cfg(test)]
+    fn select_display_ranges<'a, T>(&mut self, ranges: T, ctx: &mut ViewContext<Self>) -> Result<()>
     where
         T: IntoIterator<Item = &'a Range<DisplayPoint>>,
     {
@@ -361,7 +394,7 @@ impl BufferView {
         let mut selections = Vec::new();
         for range in ranges {
             selections.push(Selection {
-                start: map.anchor_after(range.start, Bias::Left, ctx.as_ref())?,
+                start: map.anchor_before(range.start, Bias::Left, ctx.as_ref())?,
                 end: map.anchor_before(range.end, Bias::Left, ctx.as_ref())?,
                 reversed: false,
                 goal_column: None,
@@ -454,6 +487,133 @@ impl BufferView {
         self.end_transaction(ctx);
     }
 
+    pub fn cut(&mut self, _: &(), ctx: &mut ViewContext<Self>) {
+        self.start_transaction(ctx);
+        let mut text = String::new();
+        let mut selections = self.selections(ctx.as_ref()).to_vec();
+        let mut clipboard_selections = Vec::with_capacity(selections.len());
+        {
+            let buffer = self.buffer.read(ctx);
+            let max_point = buffer.max_point();
+            for selection in &mut selections {
+                let mut start = selection.start.to_point(buffer).expect("invalid start");
+                let mut end = selection.end.to_point(buffer).expect("invalid end");
+                let is_entire_line = start == end;
+                if is_entire_line {
+                    start = Point::new(start.row, 0);
+                    end = cmp::min(max_point, Point::new(start.row + 1, 0));
+                    selection.start = buffer.anchor_before(start).unwrap();
+                    selection.end = buffer.anchor_before(end).unwrap();
+                }
+                let mut len = 0;
+                for ch in buffer.text_for_range(start..end).unwrap() {
+                    text.push(ch);
+                    len += 1;
+                }
+                clipboard_selections.push(ClipboardSelection {
+                    len,
+                    is_entire_line,
+                });
+            }
+        }
+        self.update_selections(selections, ctx);
+        self.changed_selections(ctx);
+        self.insert(&String::new(), ctx);
+        self.end_transaction(ctx);
+
+        ctx.as_mut()
+            .write_to_clipboard(ClipboardItem::new(text).with_metadata(clipboard_selections));
+    }
+
+    pub fn copy(&mut self, _: &(), ctx: &mut ViewContext<Self>) {
+        let buffer = self.buffer.read(ctx);
+        let max_point = buffer.max_point();
+        let mut text = String::new();
+        let selections = self.selections(ctx.as_ref());
+        let mut clipboard_selections = Vec::with_capacity(selections.len());
+        for selection in selections {
+            let mut start = selection.start.to_point(buffer).expect("invalid start");
+            let mut end = selection.end.to_point(buffer).expect("invalid end");
+            let is_entire_line = start == end;
+            if is_entire_line {
+                start = Point::new(start.row, 0);
+                end = cmp::min(max_point, Point::new(start.row + 1, 0));
+            }
+            let mut len = 0;
+            for ch in buffer.text_for_range(start..end).unwrap() {
+                text.push(ch);
+                len += 1;
+            }
+            clipboard_selections.push(ClipboardSelection {
+                len,
+                is_entire_line,
+            });
+        }
+
+        ctx.as_mut()
+            .write_to_clipboard(ClipboardItem::new(text).with_metadata(clipboard_selections));
+    }
+
+    pub fn paste(&mut self, _: &(), ctx: &mut ViewContext<Self>) {
+        if let Some(item) = ctx.as_mut().read_from_clipboard() {
+            let clipboard_text = item.text();
+            if let Some(mut clipboard_selections) = item.metadata::<Vec<ClipboardSelection>>() {
+                let selections = self.selections(ctx.as_ref()).to_vec();
+                if clipboard_selections.len() != selections.len() {
+                    let merged_selection = ClipboardSelection {
+                        len: clipboard_selections.iter().map(|s| s.len).sum(),
+                        is_entire_line: clipboard_selections.iter().all(|s| s.is_entire_line),
+                    };
+                    clipboard_selections.clear();
+                    clipboard_selections.push(merged_selection);
+                }
+
+                self.start_transaction(ctx);
+                let mut new_selections = Vec::with_capacity(selections.len());
+                let mut clipboard_chars = clipboard_text.chars().cycle();
+                for (selection, clipboard_selection) in
+                    selections.iter().zip(clipboard_selections.iter().cycle())
+                {
+                    let to_insert =
+                        String::from_iter(clipboard_chars.by_ref().take(clipboard_selection.len));
+
+                    self.buffer.update(ctx, |buffer, ctx| {
+                        let selection_start = selection.start.to_point(buffer).unwrap();
+                        let selection_end = selection.end.to_point(buffer).unwrap();
+
+                        // If the corresponding selection was empty when this slice of the
+                        // clipboard text was written, then the entire line containing the
+                        // selection was copied. If this selection is also currently empty,
+                        // then paste the line before the current line of the buffer.
+                        let new_selection_start = selection.end.bias_right(buffer).unwrap();
+                        if selection_start == selection_end && clipboard_selection.is_entire_line {
+                            let line_start = Point::new(selection_start.row, 0);
+                            buffer
+                                .edit(Some(line_start..line_start), to_insert, Some(ctx))
+                                .unwrap();
+                        } else {
+                            buffer
+                                .edit(Some(&selection.start..&selection.end), to_insert, Some(ctx))
+                                .unwrap();
+                        };
+
+                        let new_selection_start = new_selection_start.bias_left(buffer).unwrap();
+                        new_selections.push(Selection {
+                            start: new_selection_start.clone(),
+                            end: new_selection_start,
+                            reversed: false,
+                            goal_column: None,
+                        });
+                    });
+                }
+                self.update_selections(new_selections, ctx);
+                self.end_transaction(ctx);
+            } else {
+                self.insert(clipboard_text, ctx);
+            }
+        }
+    }
+
     pub fn undo(&mut self, _: &(), ctx: &mut ViewContext<Self>) {
         self.buffer
             .update(ctx, |buffer, ctx| buffer.undo(Some(ctx)));
@@ -1417,8 +1577,11 @@ mod tests {
                 app.add_window(|ctx| BufferView::for_buffer(buffer.clone(), settings, ctx));
 
             view.update(app, |view, ctx| {
-                view.select_ranges(&[DisplayPoint::new(8, 0)..DisplayPoint::new(12, 0)], ctx)
-                    .unwrap();
+                view.select_display_ranges(
+                    &[DisplayPoint::new(8, 0)..DisplayPoint::new(12, 0)],
+                    ctx,
+                )
+                .unwrap();
                 view.fold(&(), ctx);
                 assert_eq!(
                     view.text(ctx.as_ref()),
@@ -1525,7 +1688,7 @@ mod tests {
                 app.add_window(|ctx| BufferView::for_buffer(buffer.clone(), settings, ctx));
 
             view.update(app, |view, ctx| {
-                view.select_ranges(
+                view.select_display_ranges(
                     &[
                         // an empty selection - the preceding character is deleted
                         DisplayPoint::new(0, 2)..DisplayPoint::new(0, 2),
@@ -1547,6 +1710,147 @@ mod tests {
         })
     }
 
+    #[test]
+    fn test_clipboard() {
+        App::test((), |app| {
+            let buffer = app.add_model(|_| Buffer::new(0, "one two three four five six "));
+            let settings = settings::channel(&app.font_cache()).unwrap().1;
+            let view = app
+                .add_window(|ctx| BufferView::for_buffer(buffer.clone(), settings, ctx))
+                .1;
+
+            // Cut with three selections. Clipboard text is divided into three slices.
+            view.update(app, |view, ctx| {
+                view.select_ranges(&[0..4, 8..14, 19..24], ctx).unwrap();
+                view.cut(&(), ctx);
+            });
+            assert_eq!(view.read(app).text(app.as_ref()), "two four six ");
+
+            // Paste with three cursors. Each cursor pastes one slice of the clipboard text.
+            view.update(app, |view, ctx| {
+                view.select_ranges(&[4..4, 9..9, 13..13], ctx).unwrap();
+                view.paste(&(), ctx);
+            });
+            assert_eq!(
+                view.read(app).text(app.as_ref()),
+                "two one four three six five "
+            );
+            assert_eq!(
+                view.read(app).selection_ranges(app.as_ref()),
+                &[
+                    DisplayPoint::new(0, 8)..DisplayPoint::new(0, 8),
+                    DisplayPoint::new(0, 19)..DisplayPoint::new(0, 19),
+                    DisplayPoint::new(0, 28)..DisplayPoint::new(0, 28)
+                ]
+            );
+
+            // Paste again but with only two cursors. Since the number of cursors doesn't
+            // match the number of slices in the clipboard, the entire clipboard text
+            // is pasted at each cursor.
+            view.update(app, |view, ctx| {
+                view.select_ranges(&[0..0, 28..28], ctx).unwrap();
+                view.insert(&"( ".to_string(), ctx);
+                view.paste(&(), ctx);
+                view.insert(&") ".to_string(), ctx);
+            });
+            assert_eq!(
+                view.read(app).text(app.as_ref()),
+                "( one three five ) two one four three six five ( one three five ) "
+            );
+
+            view.update(app, |view, ctx| {
+                view.select_ranges(&[0..0], ctx).unwrap();
+                view.insert(&"123\n4567\n89\n".to_string(), ctx);
+            });
+            assert_eq!(
+                view.read(app).text(app.as_ref()),
+                "123\n4567\n89\n( one three five ) two one four three six five ( one three five ) "
+            );
+
+            // Cut with three selections, one of which is full-line.
+            view.update(app, |view, ctx| {
+                view.select_display_ranges(
+                    &[
+                        DisplayPoint::new(0, 1)..DisplayPoint::new(0, 2),
+                        DisplayPoint::new(1, 1)..DisplayPoint::new(1, 1),
+                        DisplayPoint::new(2, 0)..DisplayPoint::new(2, 1),
+                    ],
+                    ctx,
+                )
+                .unwrap();
+                view.cut(&(), ctx);
+            });
+            assert_eq!(
+                view.read(app).text(app.as_ref()),
+                "13\n9\n( one three five ) two one four three six five ( one three five ) "
+            );
+
+            // Paste with three selections, noticing how the copied selection that was full-line
+            // gets inserted before the second cursor.
+            view.update(app, |view, ctx| {
+                view.select_display_ranges(
+                    &[
+                        DisplayPoint::new(0, 1)..DisplayPoint::new(0, 1),
+                        DisplayPoint::new(1, 1)..DisplayPoint::new(1, 1),
+                        DisplayPoint::new(2, 2)..DisplayPoint::new(2, 3),
+                    ],
+                    ctx,
+                )
+                .unwrap();
+                view.paste(&(), ctx);
+            });
+            assert_eq!(
+                view.read(app).text(app.as_ref()),
+                "123\n4567\n9\n( 8ne three five ) two one four three six five ( one three five ) "
+            );
+            assert_eq!(
+                view.read(app).selection_ranges(app.as_ref()),
+                &[
+                    DisplayPoint::new(0, 2)..DisplayPoint::new(0, 2),
+                    DisplayPoint::new(2, 1)..DisplayPoint::new(2, 1),
+                    DisplayPoint::new(3, 3)..DisplayPoint::new(3, 3),
+                ]
+            );
+
+            // Copy with a single cursor only, which writes the whole line into the clipboard.
+            view.update(app, |view, ctx| {
+                view.select_display_ranges(
+                    &[DisplayPoint::new(0, 1)..DisplayPoint::new(0, 1)],
+                    ctx,
+                )
+                .unwrap();
+                view.copy(&(), ctx);
+            });
+
+            // Paste with three selections, noticing how the copied full-line selection is inserted
+            // before the empty selections but replaces the selection that is non-empty.
+            view.update(app, |view, ctx| {
+                view.select_display_ranges(
+                    &[
+                        DisplayPoint::new(0, 1)..DisplayPoint::new(0, 1),
+                        DisplayPoint::new(1, 0)..DisplayPoint::new(1, 2),
+                        DisplayPoint::new(2, 1)..DisplayPoint::new(2, 1),
+                    ],
+                    ctx,
+                )
+                .unwrap();
+                view.paste(&(), ctx);
+            });
+            assert_eq!(
+                view.read(app).text(app.as_ref()),
+                "123\n123\n123\n67\n123\n9\n( 8ne three five ) two one four three six five ( one three five ) "
+            );
+            assert_eq!(
+                view.read(app).selection_ranges(app.as_ref()),
+                &[
+                    DisplayPoint::new(1, 1)..DisplayPoint::new(1, 1),
+                    DisplayPoint::new(3, 0)..DisplayPoint::new(3, 0),
+                    DisplayPoint::new(5, 1)..DisplayPoint::new(5, 1),
+                ]
+            );
+        });
+    }
+
     impl BufferView {
         fn selection_ranges(&self, app: &AppContext) -> Vec<Range<DisplayPoint>> {
             self.selections_in_range(DisplayPoint::zero()..self.max_point(app), app)

zed/src/file_finder.rs 🔗

@@ -77,7 +77,7 @@ impl View for FileFinder {
             .with_max_height(400.0)
             .boxed(),
         )
-        .top_center()
+        .top()
         .named("file finder")
     }
 

zed/src/workspace/mod.rs 🔗

@@ -29,19 +29,19 @@ pub struct OpenParams {
 }
 
 fn open(settings: &Receiver<Settings>, ctx: &mut MutableAppContext) {
-    if let Some(paths) = ctx.platform().prompt_for_paths(PathPromptOptions {
-        files: true,
-        directories: true,
-        multiple: true,
-    }) {
-        ctx.dispatch_global_action(
-            "workspace:open_paths",
-            OpenParams {
-                paths,
-                settings: settings.clone(),
-            },
-        );
-    }
+    let settings = settings.clone();
+    ctx.prompt_for_paths(
+        PathPromptOptions {
+            files: true,
+            directories: true,
+            multiple: true,
+        },
+        move |paths, ctx| {
+            if let Some(paths) = paths {
+                ctx.dispatch_global_action("workspace:open_paths", OpenParams { paths, settings });
+            }
+        },
+    );
 }
 
 fn open_paths(params: &OpenParams, app: &mut MutableAppContext) {

zed/src/workspace/pane.rs 🔗

@@ -189,33 +189,28 @@ impl Pane {
 
             let padding = 6.;
             let mut container = Container::new(
-                Align::new(
-                    Flex::row()
-                        .with_child(
+                Stack::new()
+                    .with_child(
+                        Align::new(
                             Label::new(title, settings.ui_font_family, settings.ui_font_size)
                                 .boxed(),
                         )
-                        .with_child(
-                            Container::new(
-                                LineBox::new(
-                                    settings.ui_font_family,
-                                    settings.ui_font_size,
-                                    ConstrainedBox::new(Self::render_modified_icon(
-                                        item.is_dirty(app),
-                                    ))
-                                    .with_max_width(12.)
-                                    .boxed(),
-                                )
+                        .boxed(),
+                    )
+                    .with_child(
+                        LineBox::new(
+                            settings.ui_font_family,
+                            settings.ui_font_size,
+                            Align::new(Self::render_modified_icon(item.is_dirty(app)))
+                                .right()
                                 .boxed(),
-                            )
-                            .with_margin_left(20.)
-                            .boxed(),
                         )
                         .boxed(),
-                )
-                .boxed(),
+                    )
+                    .boxed(),
             )
-            .with_uniform_padding(padding)
+            .with_vertical_padding(padding)
+            .with_horizontal_padding(10.)
             .with_border(border);
 
             if ix == self.active_item {
@@ -237,6 +232,7 @@ impl Pane {
                             })
                             .boxed(),
                     )
+                    .with_min_width(80.0)
                     .with_max_width(264.0)
                     .boxed(),
                 )
@@ -244,9 +240,29 @@ impl Pane {
             );
         }
 
+        // Ensure there's always a minimum amount of space after the last tab,
+        // so that the tab's border doesn't abut the window's border.
+        row.add_child(
+            ConstrainedBox::new(
+                Container::new(
+                    LineBox::new(
+                        settings.ui_font_family,
+                        settings.ui_font_size,
+                        Empty::new().boxed(),
+                    )
+                    .boxed(),
+                )
+                .with_uniform_padding(6.0)
+                .with_border(Border::bottom(1.0, border_color))
+                .boxed(),
+            )
+            .with_min_width(20.)
+            .named("fixed-filler"),
+        );
+
         row.add_child(
             Expanded::new(
-                1.0,
+                0.0,
                 Container::new(
                     LineBox::new(
                         settings.ui_font_family,
@@ -266,23 +282,24 @@ impl Pane {
     }
 
     fn render_modified_icon(is_modified: bool) -> ElementBox {
-        Canvas::new(move |bounds, ctx| {
-            if is_modified {
-                let padding = if bounds.height() < bounds.width() {
-                    vec2f(bounds.width() - bounds.height(), 0.0)
-                } else {
-                    vec2f(0.0, bounds.height() - bounds.width())
-                };
-                let square = RectF::new(bounds.origin() + padding / 2., bounds.size() - padding);
-                ctx.scene.push_quad(Quad {
-                    bounds: square,
-                    background: Some(ColorF::new(0.639, 0.839, 1.0, 1.0).to_u8()),
-                    border: Default::default(),
-                    corner_radius: square.width() / 2.,
-                });
-            }
-        })
-        .boxed()
+        let diameter = 8.;
+        ConstrainedBox::new(
+            Canvas::new(move |bounds, ctx| {
+                if is_modified {
+                    let square = RectF::new(bounds.origin(), vec2f(diameter, diameter));
+                    ctx.scene.push_quad(Quad {
+                        bounds: square,
+                        background: Some(ColorF::new(0.639, 0.839, 1.0, 1.0).to_u8()),
+                        border: Default::default(),
+                        corner_radius: diameter / 2.,
+                    });
+                }
+            })
+            .boxed(),
+        )
+        .with_width(diameter)
+        .with_height(diameter)
+        .named("tab-right-icon")
     }
 }
 

zed/src/workspace/workspace_view.rs 🔗

@@ -3,7 +3,7 @@ use crate::{settings::Settings, watch};
 use futures_core::future::LocalBoxFuture;
 use gpui::{
     color::rgbu, elements::*, json::to_string_pretty, keymap::Binding, AnyViewHandle, AppContext,
-    Entity, ModelHandle, MutableAppContext, View, ViewContext, ViewHandle,
+    ClipboardItem, Entity, ModelHandle, MutableAppContext, View, ViewContext, ViewHandle,
 };
 use log::{error, info};
 use std::{collections::HashSet, path::PathBuf};
@@ -258,10 +258,11 @@ impl WorkspaceView {
     pub fn debug_elements(&mut self, _: &(), ctx: &mut ViewContext<Self>) {
         match to_string_pretty(&ctx.debug_elements()) {
             Ok(json) => {
-                ctx.as_mut().copy(&json);
+                let kib = json.len() as f32 / 1024.;
+                ctx.as_mut().write_to_clipboard(ClipboardItem::new(json));
                 log::info!(
                     "copied {:.1} KiB of element debug JSON to the clipboard",
-                    json.len() as f32 / 1024.
+                    kib
                 );
             }
             Err(error) => {