Improve the appearance of project panel filename editor

Max Brunsfeld and Antonio Scandurra created

* Always layout single-line editors with a fixed height
* Preserve directory chevron when editing folder names
* Allow theming the filename editor

Co-authored-by: Antonio Scandurra <me@as-cii.com>

Change summary

assets/keymaps/default.json               |   3 
assets/themes/cave-dark.json              |  12 
assets/themes/cave-light.json             |  12 
assets/themes/dark.json                   |  12 
assets/themes/light.json                  |  12 
assets/themes/solarized-dark.json         |  12 
assets/themes/solarized-light.json        |  12 
assets/themes/sulphurpool-dark.json       |  12 
assets/themes/sulphurpool-light.json      |  12 
crates/editor/src/element.rs              |   6 
crates/project/src/project.rs             |   2 
crates/project/src/worktree.rs            |   2 
crates/project_panel/src/project_panel.rs | 321 +++++++-----------------
crates/theme/src/theme.rs                 |   3 
styles/src/styleTree/projectPanel.ts      |   7 
15 files changed, 206 insertions(+), 234 deletions(-)

Detailed changes

assets/keymaps/default.json 🔗

@@ -331,7 +331,8 @@
         "context": "ProjectPanel",
         "bindings": {
             "left": "project_panel::CollapseSelectedEntry",
-            "right": "project_panel::ExpandSelectedEntry"
+            "right": "project_panel::ExpandSelectedEntry",
+            "f2": "project_panel::Rename"
         }
     }
 ]

assets/themes/cave-dark.json 🔗

@@ -972,6 +972,18 @@
           "size": 14
         }
       }
+    },
+    "filename_editor": {
+      "background": "#26232a5c",
+      "text": {
+        "family": "Zed Mono",
+        "color": "#e2dfe7",
+        "size": 14
+      },
+      "selection": {
+        "cursor": "#576ddb",
+        "selection": "#576ddb3d"
+      }
     }
   },
   "chat_panel": {

assets/themes/cave-light.json 🔗

@@ -972,6 +972,18 @@
           "size": 14
         }
       }
+    },
+    "filename_editor": {
+      "background": "#e2dfe72e",
+      "text": {
+        "family": "Zed Mono",
+        "color": "#26232a",
+        "size": 14
+      },
+      "selection": {
+        "cursor": "#576ddb",
+        "selection": "#576ddb3d"
+      }
     }
   },
   "chat_panel": {

assets/themes/dark.json 🔗

@@ -972,6 +972,18 @@
           "size": 14
         }
       }
+    },
+    "filename_editor": {
+      "background": "#ffffff1f",
+      "text": {
+        "family": "Zed Mono",
+        "color": "#f1f1f1",
+        "size": 14
+      },
+      "selection": {
+        "cursor": "#2472f2",
+        "selection": "#2472f23d"
+      }
     }
   },
   "chat_panel": {

assets/themes/light.json 🔗

@@ -972,6 +972,18 @@
           "size": 14
         }
       }
+    },
+    "filename_editor": {
+      "background": "#0000000f",
+      "text": {
+        "family": "Zed Mono",
+        "color": "#2b2b2b",
+        "size": 14
+      },
+      "selection": {
+        "cursor": "#2472f2",
+        "selection": "#2472f23d"
+      }
     }
   },
   "chat_panel": {

assets/themes/solarized-dark.json 🔗

@@ -972,6 +972,18 @@
           "size": 14
         }
       }
+    },
+    "filename_editor": {
+      "background": "#0736425c",
+      "text": {
+        "family": "Zed Mono",
+        "color": "#eee8d5",
+        "size": 14
+      },
+      "selection": {
+        "cursor": "#268bd2",
+        "selection": "#268bd23d"
+      }
     }
   },
   "chat_panel": {

assets/themes/solarized-light.json 🔗

@@ -972,6 +972,18 @@
           "size": 14
         }
       }
+    },
+    "filename_editor": {
+      "background": "#eee8d52e",
+      "text": {
+        "family": "Zed Mono",
+        "color": "#073642",
+        "size": 14
+      },
+      "selection": {
+        "cursor": "#268bd2",
+        "selection": "#268bd23d"
+      }
     }
   },
   "chat_panel": {

assets/themes/sulphurpool-dark.json 🔗

@@ -972,6 +972,18 @@
           "size": 14
         }
       }
+    },
+    "filename_editor": {
+      "background": "#2932565c",
+      "text": {
+        "family": "Zed Mono",
+        "color": "#dfe2f1",
+        "size": 14
+      },
+      "selection": {
+        "cursor": "#3d8fd1",
+        "selection": "#3d8fd13d"
+      }
     }
   },
   "chat_panel": {

assets/themes/sulphurpool-light.json 🔗

@@ -972,6 +972,18 @@
           "size": 14
         }
       }
+    },
+    "filename_editor": {
+      "background": "#dfe2f12e",
+      "text": {
+        "family": "Zed Mono",
+        "color": "#293256",
+        "size": 14
+      },
+      "selection": {
+        "cursor": "#3d8fd1",
+        "selection": "#3d8fd13d"
+      }
     }
   },
   "chat_panel": {

crates/editor/src/element.rs 🔗

@@ -875,6 +875,12 @@ impl Element for EditorElement {
                     .max(constraint.min_along(Axis::Vertical))
                     .min(line_height * max_lines as f32),
             )
+        } else if let EditorMode::SingleLine = snapshot.mode {
+            size.set_y(
+                line_height
+                    .min(constraint.max_along(Axis::Vertical))
+                    .max(constraint.min_along(Axis::Vertical)),
+            )
         } else if size.y().is_infinite() {
             size.set_y(scroll_height);
         }

crates/project/src/project.rs 🔗

@@ -225,6 +225,8 @@ impl DiagnosticSummary {
 pub struct ProjectEntryId(usize);
 
 impl ProjectEntryId {
+    pub const MAX: Self = Self(usize::MAX);
+
     pub fn new(counter: &AtomicUsize) -> Self {
         Self(counter.fetch_add(1, SeqCst))
     }

crates/project/src/worktree.rs 🔗

@@ -1568,7 +1568,7 @@ pub struct Entry {
     pub is_ignored: bool,
 }
 
-#[derive(Clone, Debug, PartialEq, Eq)]
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
 pub enum EntryKind {
     PendingDir,
     Dir,

crates/project_panel/src/project_panel.rs 🔗

@@ -11,7 +11,7 @@ use gpui::{
     AppContext, Element, ElementBox, Entity, ModelHandle, MutableAppContext, Task, View,
     ViewContext, ViewHandle, WeakViewHandle,
 };
-use project::{Entry, Project, ProjectEntryId, ProjectPath, Worktree, WorktreeId};
+use project::{Entry, EntryKind, Project, ProjectEntryId, ProjectPath, Worktree, WorktreeId};
 use settings::Settings;
 use std::{
     cmp::Ordering,
@@ -25,6 +25,8 @@ use workspace::{
     Workspace,
 };
 
+const NEW_FILE_ENTRY_ID: ProjectEntryId = ProjectEntryId::MAX;
+
 pub struct ProjectPanel {
     project: ModelHandle<Project>,
     list: UniformListState,
@@ -56,14 +58,7 @@ struct EntryDetails {
     kind: EntryKind,
     is_expanded: bool,
     is_selected: bool,
-}
-
-#[derive(Debug, PartialEq, Eq)]
-enum EntryKind {
-    File,
-    Dir,
-    FileRenameEditor,
-    NewFileEditor,
+    is_editing: bool,
 }
 
 #[derive(Clone)]
@@ -122,8 +117,17 @@ impl ProjectPanel {
             })
             .detach();
 
-            let editor = cx.add_view(|cx| Editor::single_line(None, cx));
-            cx.subscribe(&editor, |this, _, event, cx| {
+            let filename_editor = cx.add_view(|cx| {
+                Editor::single_line(
+                    Some(|theme| {
+                        let mut style = theme.project_panel.filename_editor.clone();
+                        style.container.background_color.take();
+                        style
+                    }),
+                    cx,
+                )
+            });
+            cx.subscribe(&filename_editor, |this, _, event, cx| {
                 if let editor::Event::Blurred = event {
                     this.editor_blurred(cx);
                 }
@@ -137,7 +141,7 @@ impl ProjectPanel {
                 expanded_dir_ids: Default::default(),
                 selection: None,
                 edit_state: None,
-                filename_editor: editor,
+                filename_editor,
                 handle: cx.weak_handle(),
             };
             this.update_visible_entries(None, cx);
@@ -399,8 +403,10 @@ impl ProjectPanel {
                         .path
                         .file_name()
                         .map_or(String::new(), |s| s.to_string_lossy().to_string());
-                    self.filename_editor
-                        .update(cx, |editor, cx| editor.set_text(filename, cx));
+                    self.filename_editor.update(cx, |editor, cx| {
+                        editor.set_text(filename, cx);
+                        editor.select_all(&Default::default(), cx);
+                    });
                     cx.focus(&self.filename_editor);
                     self.update_visible_entries(None, cx);
                     cx.notify();
@@ -542,7 +548,7 @@ impl ProjectPanel {
                 visible_worktree_entries.push(entry.clone());
                 if Some(entry.id) == new_file_parent_id {
                     visible_worktree_entries.push(Entry {
-                        id: entry.id,
+                        id: NEW_FILE_ENTRY_ID,
                         kind: project::EntryKind::File(Default::default()),
                         path: entry.path.join("\0").into(),
                         inode: 0,
@@ -662,30 +668,24 @@ impl ProjectPanel {
                             .to_string_lossy()
                             .to_string(),
                         depth: entry.path.components().count(),
-                        kind: if entry.is_dir() {
-                            EntryKind::Dir
-                        } else {
-                            EntryKind::File
-                        },
+                        kind: entry.kind,
                         is_expanded: expanded_entry_ids.binary_search(&entry.id).is_ok(),
                         is_selected: self.selection.map_or(false, |e| {
                             e.worktree_id == snapshot.id() && e.entry_id == entry.id
                         }),
+                        is_editing: false,
                     };
                     if let Some(edit_state) = self.edit_state {
-                        if edit_state.worktree_id == *worktree_id && edit_state.entry_id == entry.id
-                        {
-                            if edit_state.new_file {
-                                if entry.is_file() {
-                                    details.kind = EntryKind::NewFileEditor;
-                                    details.filename = Default::default();
-                                    details.is_expanded = false;
-                                    details.is_selected = false;
-                                }
-                            } else {
-                                details.kind = EntryKind::FileRenameEditor;
+                        if edit_state.new_file {
+                            if entry.id == NEW_FILE_ENTRY_ID {
+                                details.is_editing = true;
+                                details.filename.clear();
                             }
-                        }
+                        } else {
+                            if entry.id == edit_state.entry_id {
+                                details.is_editing = true;
+                            }
+                        };
                     }
                     callback(entry.id, details, cx);
                 }
@@ -702,21 +702,14 @@ impl ProjectPanel {
         cx: &mut ViewContext<Self>,
     ) -> ElementBox {
         let kind = details.kind;
-        let padding = theme.container.padding.left + details.depth as f32 * theme.indent_width;
-
-        if kind == EntryKind::FileRenameEditor || kind == EntryKind::NewFileEditor {
-            return ChildView::new(editor.clone())
-                .constrained()
-                .with_height(theme.entry.default.height)
-                .contained()
-                .with_margin_left(
-                    padding + theme.entry.default.icon_spacing + theme.entry.default.icon_size,
-                )
-                .boxed();
-        }
-
         MouseEventHandler::new::<Self, _, _>(entry_id.to_usize(), cx, |state, _| {
+            let padding = theme.container.padding.left + details.depth as f32 * theme.indent_width;
             let style = theme.entry.style_for(state, details.is_selected);
+            let row_container_style = if details.is_editing {
+                theme.filename_editor.container
+            } else {
+                style.container
+            };
             Flex::row()
                 .with_child(
                     ConstrainedBox::new(if kind == EntryKind::Dir {
@@ -739,18 +732,26 @@ impl ProjectPanel {
                     .with_width(style.icon_size)
                     .boxed(),
                 )
-                .with_child(
+                .with_child(if details.is_editing {
+                    ChildView::new(editor.clone())
+                        .contained()
+                        .with_margin_left(theme.entry.default.icon_spacing)
+                        .aligned()
+                        .left()
+                        .flex(1.0, true)
+                        .boxed()
+                } else {
                     Label::new(details.filename, style.text.clone())
                         .contained()
                         .with_margin_left(style.icon_spacing)
                         .aligned()
                         .left()
-                        .boxed(),
-                )
+                        .boxed()
+                })
                 .constrained()
                 .with_height(theme.entry.default.height)
                 .contained()
-                .with_style(style.container)
+                .with_style(row_container_style)
                 .with_padding_left(padding)
                 .boxed()
         })
@@ -871,168 +872,43 @@ mod tests {
         let (_, workspace) = cx.add_window(|cx| Workspace::new(&params, cx));
         let panel = workspace.update(cx, |_, cx| ProjectPanel::new(project, cx));
         assert_eq!(
-            visible_entry_details(&panel, 0..50, cx),
+            visible_entries_as_strings(&panel, 0..50, cx),
             &[
-                EntryDetails {
-                    filename: "root1".to_string(),
-                    depth: 0,
-                    kind: EntryKind::Dir,
-                    is_expanded: true,
-                    is_selected: false,
-                },
-                EntryDetails {
-                    filename: "a".to_string(),
-                    depth: 1,
-                    kind: EntryKind::Dir,
-                    is_expanded: false,
-                    is_selected: false,
-                },
-                EntryDetails {
-                    filename: "b".to_string(),
-                    depth: 1,
-                    kind: EntryKind::Dir,
-                    is_expanded: false,
-                    is_selected: false,
-                },
-                EntryDetails {
-                    filename: "C".to_string(),
-                    depth: 1,
-                    kind: EntryKind::Dir,
-                    is_expanded: false,
-                    is_selected: false,
-                },
-                EntryDetails {
-                    filename: ".dockerignore".to_string(),
-                    depth: 1,
-                    kind: EntryKind::File,
-                    is_expanded: false,
-                    is_selected: false,
-                },
-                EntryDetails {
-                    filename: "root2".to_string(),
-                    depth: 0,
-                    kind: EntryKind::Dir,
-                    is_expanded: true,
-                    is_selected: false
-                },
-                EntryDetails {
-                    filename: "d".to_string(),
-                    depth: 1,
-                    kind: EntryKind::Dir,
-                    is_expanded: false,
-                    is_selected: false
-                },
-                EntryDetails {
-                    filename: "e".to_string(),
-                    depth: 1,
-                    kind: EntryKind::Dir,
-                    is_expanded: false,
-                    is_selected: false
-                },
-            ],
+                "v root1",
+                "    > a",
+                "    > b",
+                "    > C",
+                "      .dockerignore",
+                "v root2",
+                "    > d",
+                "    > e",
+            ]
         );
 
         toggle_expand_dir(&panel, "root1/b", cx);
         assert_eq!(
-            visible_entry_details(&panel, 0..50, cx),
+            visible_entries_as_strings(&panel, 0..50, cx),
             &[
-                EntryDetails {
-                    filename: "root1".to_string(),
-                    depth: 0,
-                    kind: EntryKind::Dir,
-                    is_expanded: true,
-                    is_selected: false,
-                },
-                EntryDetails {
-                    filename: "a".to_string(),
-                    depth: 1,
-                    kind: EntryKind::Dir,
-                    is_expanded: false,
-                    is_selected: false,
-                },
-                EntryDetails {
-                    filename: "b".to_string(),
-                    depth: 1,
-                    kind: EntryKind::Dir,
-                    is_expanded: true,
-                    is_selected: true,
-                },
-                EntryDetails {
-                    filename: "3".to_string(),
-                    depth: 2,
-                    kind: EntryKind::Dir,
-                    is_expanded: false,
-                    is_selected: false,
-                },
-                EntryDetails {
-                    filename: "4".to_string(),
-                    depth: 2,
-                    kind: EntryKind::Dir,
-                    is_expanded: false,
-                    is_selected: false,
-                },
-                EntryDetails {
-                    filename: "C".to_string(),
-                    depth: 1,
-                    kind: EntryKind::Dir,
-                    is_expanded: false,
-                    is_selected: false,
-                },
-                EntryDetails {
-                    filename: ".dockerignore".to_string(),
-                    depth: 1,
-                    kind: EntryKind::File,
-                    is_expanded: false,
-                    is_selected: false,
-                },
-                EntryDetails {
-                    filename: "root2".to_string(),
-                    depth: 0,
-                    kind: EntryKind::Dir,
-                    is_expanded: true,
-                    is_selected: false
-                },
-                EntryDetails {
-                    filename: "d".to_string(),
-                    depth: 1,
-                    kind: EntryKind::Dir,
-                    is_expanded: false,
-                    is_selected: false
-                },
-                EntryDetails {
-                    filename: "e".to_string(),
-                    depth: 1,
-                    kind: EntryKind::Dir,
-                    is_expanded: false,
-                    is_selected: false
-                },
+                "v root1",
+                "    > a",
+                "    v b  <== selected",
+                "        > 3",
+                "        > 4",
+                "    > C",
+                "      .dockerignore",
+                "v root2",
+                "    > d",
+                "    > e",
             ]
         );
 
         assert_eq!(
-            visible_entry_details(&panel, 5..8, cx),
-            [
-                EntryDetails {
-                    filename: "C".to_string(),
-                    depth: 1,
-                    kind: EntryKind::Dir,
-                    is_expanded: false,
-                    is_selected: false
-                },
-                EntryDetails {
-                    filename: ".dockerignore".to_string(),
-                    depth: 1,
-                    kind: EntryKind::File,
-                    is_expanded: false,
-                    is_selected: false
-                },
-                EntryDetails {
-                    filename: "root2".to_string(),
-                    depth: 0,
-                    kind: EntryKind::Dir,
-                    is_expanded: true,
-                    is_selected: false
-                },
+            visible_entries_as_strings(&panel, 5..8, cx),
+            &[
+                //
+                "    > C",
+                "      .dockerignore",
+                "v root2",
             ]
         );
     }
@@ -1109,7 +985,7 @@ mod tests {
                 "    > a",
                 "    > b",
                 "    > C",
-                "      [NEW FILE EDITOR]",
+                "      [EDITOR: '']",
                 "      .dockerignore",
                 "v root2",
                 "    > d",
@@ -1151,7 +1027,7 @@ mod tests {
                 "    v b  <== selected",
                 "        > 3",
                 "        > 4",
-                "          [NEW FILE EDITOR]",
+                "          [EDITOR: '']",
                 "    > C",
                 "      .dockerignore",
                 "      the-new-filename",
@@ -1192,7 +1068,7 @@ mod tests {
                 "    v b",
                 "        > 3",
                 "        > 4",
-                "          [RENAME EDITOR]  <== selected",
+                "          [EDITOR: 'another-filename']  <== selected",
                 "    > C",
                 "      .dockerignore",
                 "      the-new-filename",
@@ -1265,19 +1141,17 @@ mod tests {
         });
     }
 
-    fn visible_entry_details(
+    fn visible_entries_as_strings(
         panel: &ViewHandle<ProjectPanel>,
         range: Range<usize>,
         cx: &mut TestAppContext,
-    ) -> Vec<EntryDetails> {
+    ) -> Vec<String> {
         let mut result = Vec::new();
         let mut project_entries = HashSet::new();
         let mut has_editor = false;
         panel.update(cx, |panel, cx| {
             panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
-                if details.kind == EntryKind::NewFileEditor
-                    || details.kind == EntryKind::FileRenameEditor
-                {
+                if details.is_editing {
                     assert!(!has_editor, "duplicate editor entry");
                     has_editor = true;
                 } else {
@@ -1288,21 +1162,7 @@ mod tests {
                         details
                     );
                 }
-                result.push(details)
-            });
-        });
-
-        result
-    }
 
-    fn visible_entries_as_strings(
-        panel: &ViewHandle<ProjectPanel>,
-        range: Range<usize>,
-        cx: &mut TestAppContext,
-    ) -> Vec<String> {
-        visible_entry_details(panel, range, cx)
-            .into_iter()
-            .map(|details| {
                 let indent = "    ".repeat(details.depth);
                 let icon = if details.kind == EntryKind::Dir {
                     if details.is_expanded {
@@ -1313,10 +1173,9 @@ mod tests {
                 } else {
                     "  "
                 };
-                let name = if details.kind == EntryKind::FileRenameEditor {
-                    "[RENAME EDITOR]"
-                } else if details.kind == EntryKind::NewFileEditor {
-                    "[NEW FILE EDITOR]"
+                let editor_text = format!("[EDITOR: '{}']", details.filename);
+                let name = if details.is_editing {
+                    &editor_text
                 } else {
                     &details.filename
                 };
@@ -1325,8 +1184,10 @@ mod tests {
                 } else {
                     ""
                 };
-                format!("{indent}{icon}{name}{selected}")
-            })
-            .collect()
+                result.push(format!("{indent}{icon}{name}{selected}"));
+            });
+        });
+
+        result
     }
 }

crates/theme/src/theme.rs 🔗

@@ -204,11 +204,12 @@ pub struct ChatPanel {
     pub hovered_sign_in_prompt: TextStyle,
 }
 
-#[derive(Debug, Deserialize, Default)]
+#[derive(Deserialize, Default)]
 pub struct ProjectPanel {
     #[serde(flatten)]
     pub container: ContainerStyle,
     pub entry: Interactive<ProjectPanelEntry>,
+    pub filename_editor: FieldEditor,
     pub indent_width: f32,
 }
 

styles/src/styleTree/projectPanel.ts 🔗

@@ -1,6 +1,6 @@
 import Theme from "../themes/theme";
 import { panel } from "./app";
-import { backgroundColor, iconColor, text } from "./components";
+import { backgroundColor, iconColor, player, text } from "./components";
 
 export default function projectPanel(theme: Theme) {
   return {
@@ -26,5 +26,10 @@ export default function projectPanel(theme: Theme) {
         text: text(theme, "mono", "active", { size: "sm" }),
       }
     },
+    filenameEditor: {
+      background: backgroundColor(theme, 500, "active"),
+      text: text(theme, "mono", "primary", { size: "sm" }),
+      selection: player(theme, 1).selection,
+    },
   };
 }