Implement 3+ file switcher (#40214)

Mikayla Maki created

Release Notes:

- N/A

Change summary

crates/settings_ui/src/settings_ui.rs     | 124 ++++++++++++++++++++----
crates/ui/src/components/dropdown_menu.rs |  28 ++++-
2 files changed, 120 insertions(+), 32 deletions(-)

Detailed changes

crates/settings_ui/src/settings_ui.rs 🔗

@@ -506,6 +506,7 @@ pub struct SettingsWindow {
     title_bar: Option<Entity<PlatformTitleBar>>,
     original_window: Option<WindowHandle<Workspace>>,
     files: Vec<(SettingsUiFile, FocusHandle)>,
+    drop_down_file: Option<usize>,
     worktree_root_dirs: HashMap<WorktreeId, String>,
     current_file: SettingsUiFile,
     pages: Vec<SettingsPage>,
@@ -971,6 +972,7 @@ impl SettingsWindow {
             original_window,
             worktree_root_dirs: HashMap::default(),
             files: vec![],
+            drop_down_file: None,
             current_file: current_file,
             pages: vec![],
             navbar_entries: vec![],
@@ -1440,7 +1442,7 @@ impl SettingsWindow {
             .iter()
             .any(|(file, _)| file == &self.current_file);
         if !current_file_still_exists {
-            self.change_file(0, window, cx);
+            self.change_file(0, window, false, cx);
         }
     }
 
@@ -1460,12 +1462,22 @@ impl SettingsWindow {
         self.open_navbar_entry_page(first_navbar_entry_index);
     }
 
-    fn change_file(&mut self, ix: usize, window: &mut Window, cx: &mut Context<SettingsWindow>) {
+    fn change_file(
+        &mut self,
+        ix: usize,
+        window: &mut Window,
+        drop_down_file: bool,
+        cx: &mut Context<SettingsWindow>,
+    ) {
         if ix >= self.files.len() {
             self.current_file = SettingsUiFile::User;
             self.build_ui(window, cx);
             return;
         }
+        if drop_down_file {
+            self.drop_down_file = Some(ix);
+        }
+
         if self.files[ix].0 == self.current_file {
             return;
         }
@@ -1485,9 +1497,30 @@ impl SettingsWindow {
 
     fn render_files_header(
         &self,
-        _window: &mut Window,
+        window: &mut Window,
         cx: &mut Context<SettingsWindow>,
     ) -> impl IntoElement {
+        const OVERFLOW_LIMIT: usize = 1;
+
+        let file_button =
+            |ix, file: &SettingsUiFile, focus_handle, cx: &mut Context<SettingsWindow>| {
+                Button::new(
+                    ix,
+                    self.display_name(&file)
+                        .expect("Files should always have a name"),
+                )
+                .toggle_state(file == &self.current_file)
+                .selected_style(ButtonStyle::Tinted(ui::TintColor::Accent))
+                .track_focus(focus_handle)
+                .on_click(cx.listener({
+                    let focus_handle = focus_handle.clone();
+                    move |this, _: &gpui::ClickEvent, window, cx| {
+                        this.change_file(ix, window, false, cx);
+                        focus_handle.focus(window);
+                    }
+                }))
+            };
+        let this = cx.entity();
         h_flex()
             .w_full()
             .pb_4()
@@ -1499,31 +1532,73 @@ impl SettingsWindow {
             .child(
                 h_flex()
                     .id("file_buttons_container")
-                    .w_64() // Temporary fix until long-term solution is a fixed set of buttons representing a file location (User, Project, and Remote)
                     .gap_1()
                     .overflow_x_scroll()
                     .children(
-                        self.files
-                            .iter()
-                            .enumerate()
-                            .map(|(ix, (file, focus_handle))| {
-                                Button::new(
-                                    ix,
-                                    self.display_name(&file)
-                                        .expect("Files should always have a name"),
+                        self.files.iter().enumerate().take(OVERFLOW_LIMIT).map(
+                            |(ix, (file, focus_handle))| file_button(ix, file, focus_handle, cx),
+                        ),
+                    )
+                    .when(self.files.len() > OVERFLOW_LIMIT, |div| {
+                        div.children(
+                            self.files
+                                .iter()
+                                .enumerate()
+                                .skip(OVERFLOW_LIMIT)
+                                .find(|(_, (file, _))| file == &self.current_file)
+                                .map(|(ix, (file, focus_handle))| {
+                                    file_button(ix, file, focus_handle, cx)
+                                })
+                                .or_else(|| {
+                                    let ix = self.drop_down_file.unwrap_or(OVERFLOW_LIMIT);
+                                    self.files.get(ix).map(|(file, focus_handle)| {
+                                        file_button(ix, file, focus_handle, cx)
+                                    })
+                                }),
+                        )
+                        .when(
+                            self.files.len() > OVERFLOW_LIMIT + 1,
+                            |div| {
+                                div.child(
+                                    DropdownMenu::new(
+                                        "more-files",
+                                        format!("+{}", self.files.len() - (OVERFLOW_LIMIT + 1)),
+                                        ContextMenu::build(window, cx, move |mut menu, _, _| {
+                                            for (ix, (file, focus_handle)) in self
+                                                .files
+                                                .iter()
+                                                .enumerate()
+                                                .skip(OVERFLOW_LIMIT + 1)
+                                            {
+                                                menu = menu.entry(
+                                                    self.display_name(file)
+                                                        .expect("Files should always have a name"),
+                                                    None,
+                                                    {
+                                                        let this = this.clone();
+                                                        let focus_handle = focus_handle.clone();
+                                                        move |window, cx| {
+                                                            this.update(cx, |this, cx| {
+                                                                this.change_file(
+                                                                    ix, window, true, cx,
+                                                                );
+                                                            });
+                                                            focus_handle.focus(window);
+                                                        }
+                                                    },
+                                                );
+                                            }
+
+                                            menu
+                                        }),
+                                    )
+                                    .style(DropdownStyle::Ghost)
+                                    .tab_index(0)
+                                    .no_chevron(),
                                 )
-                                .toggle_state(file == &self.current_file)
-                                .selected_style(ButtonStyle::Tinted(ui::TintColor::Accent))
-                                .track_focus(focus_handle)
-                                .on_click(cx.listener({
-                                    let focus_handle = focus_handle.clone();
-                                    move |this, _: &gpui::ClickEvent, window, cx| {
-                                        this.change_file(ix, window, cx);
-                                        focus_handle.focus(window);
-                                    }
-                                }))
-                            }),
-                    ),
+                            },
+                        )
+                    }),
             )
             .child(
                 Button::new("edit-in-json", "Edit in settings.json")
@@ -2675,6 +2750,7 @@ mod test {
             worktree_root_dirs: HashMap::default(),
             files: Vec::default(),
             current_file: crate::SettingsUiFile::User,
+            drop_down_file: None,
             pages,
             search_bar: cx.new(|cx| Editor::single_line(window, cx)),
             navbar_entry: selected_idx.expect("Must have a selected navbar entry"),

crates/ui/src/components/dropdown_menu.rs 🔗

@@ -30,6 +30,7 @@ pub struct DropdownMenu {
     attach: Option<Corner>,
     offset: Option<Point<Pixels>>,
     tab_index: Option<isize>,
+    chevron: bool,
 }
 
 impl DropdownMenu {
@@ -50,6 +51,7 @@ impl DropdownMenu {
             attach: None,
             offset: None,
             tab_index: None,
+            chevron: true,
         }
     }
 
@@ -70,6 +72,7 @@ impl DropdownMenu {
             attach: None,
             offset: None,
             tab_index: None,
+            chevron: true,
         }
     }
 
@@ -109,6 +112,11 @@ impl DropdownMenu {
         self.tab_index = Some(arg);
         self
     }
+
+    pub fn no_chevron(mut self) -> Self {
+        self.chevron = false;
+        self
+    }
 }
 
 impl Disableable for DropdownMenu {
@@ -132,19 +140,23 @@ impl RenderOnce for DropdownMenu {
         let button = match self.label {
             LabelKind::Text(text) => Button::new(self.id.clone(), text)
                 .style(button_style)
-                .icon(IconName::ChevronUpDown)
-                .icon_position(IconPosition::End)
-                .icon_size(IconSize::XSmall)
-                .icon_color(Color::Muted)
+                .when(self.chevron, |this| {
+                    this.icon(IconName::ChevronUpDown)
+                        .icon_position(IconPosition::End)
+                        .icon_size(IconSize::XSmall)
+                        .icon_color(Color::Muted)
+                })
                 .when(full_width, |this| this.full_width())
                 .size(trigger_size)
                 .disabled(self.disabled),
             LabelKind::Element(_element) => Button::new(self.id.clone(), "")
                 .style(button_style)
-                .icon(IconName::ChevronUpDown)
-                .icon_position(IconPosition::End)
-                .icon_size(IconSize::XSmall)
-                .icon_color(Color::Muted)
+                .when(self.chevron, |this| {
+                    this.icon(IconName::ChevronUpDown)
+                        .icon_position(IconPosition::End)
+                        .icon_size(IconSize::XSmall)
+                        .icon_color(Color::Muted)
+                })
                 .when(full_width, |this| this.full_width())
                 .size(trigger_size)
                 .disabled(self.disabled),