diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 5b4c13aef5..bd2ade4246 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -262,7 +262,8 @@ "alt-r": "search::ToggleRegex", "alt-ctrl-f": "project_search::ToggleFilters", "ctrl-alt-shift-r": "search::ToggleRegex", - "ctrl-alt-shift-x": "search::ToggleRegex" + "ctrl-alt-shift-x": "search::ToggleRegex", + "ctrl-k shift-enter": "pane::TogglePinTab" } }, // Bindings from VS Code diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 20d6b4687a..dec5cbd9f3 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -299,7 +299,8 @@ "alt-cmd-c": "search::ToggleCaseSensitive", "alt-cmd-w": "search::ToggleWholeWord", "alt-cmd-f": "project_search::ToggleFilters", - "alt-cmd-x": "search::ToggleRegex" + "alt-cmd-x": "search::ToggleRegex", + "cmd-k shift-enter": "pane::TogglePinTab" } }, // Bindings from VS Code diff --git a/crates/ui/src/components/stories/tab.rs b/crates/ui/src/components/stories/tab.rs index 541af75ba4..eb0dd084b9 100644 --- a/crates/ui/src/components/stories/tab.rs +++ b/crates/ui/src/components/stories/tab.rs @@ -28,6 +28,7 @@ impl Render for TabStory { Tab::new("tab_1") .end_slot( IconButton::new("close_button", IconName::Close) + .visible_on_hover("") .shape(IconButtonShape::Square) .icon_color(Color::Muted) .size(ButtonSize::None) diff --git a/crates/ui/src/components/tab.rs b/crates/ui/src/components/tab.rs index 06bf5a1843..1ec4cd4aec 100644 --- a/crates/ui/src/components/tab.rs +++ b/crates/ui/src/components/tab.rs @@ -120,11 +120,7 @@ impl RenderOnce for Tab { let (start_slot, end_slot) = { let start_slot = h_flex().size_3().justify_center().children(self.start_slot); - let end_slot = h_flex() - .size_3() - .justify_center() - .visible_on_hover("") - .children(self.end_slot); + let end_slot = h_flex().size_3().justify_center().children(self.end_slot); match self.close_side { TabCloseSide::End => (start_slot, end_slot), diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index d6f11d7e3f..578fdabad1 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -157,6 +157,7 @@ actions!( SplitHorizontal, SplitVertical, TogglePreviewTab, + TogglePinTab, ] ); @@ -272,6 +273,7 @@ pub struct Pane { save_modals_spawned: HashSet, pub new_item_context_menu_handle: PopoverMenuHandle, split_item_context_menu_handle: PopoverMenuHandle, + pinned_tab_count: usize, } pub struct ActivationHistoryEntry { @@ -470,6 +472,7 @@ impl Pane { save_modals_spawned: HashSet::default(), split_item_context_menu_handle: Default::default(), new_item_context_menu_handle: Default::default(), + pinned_tab_count: 0, } } @@ -948,9 +951,11 @@ impl Pane { } pub fn index_for_item(&self, item: &dyn ItemHandle) -> Option { - self.items - .iter() - .position(|i| i.item_id() == item.item_id()) + self.index_for_item_id(item.item_id()) + } + + fn index_for_item_id(&self, item_id: EntityId) -> Option { + self.items.iter().position(|i| i.item_id() == item_id) } pub fn item_for_index(&self, ix: usize) -> Option<&dyn ItemHandle> { @@ -1722,6 +1727,65 @@ impl Pane { } } + fn toggle_pin_tab(&mut self, _: &TogglePinTab, cx: &mut ViewContext<'_, Self>) { + if self.items.is_empty() { + return; + } + let active_tab_ix = self.active_item_index(); + if self.is_tab_pinned(active_tab_ix) { + self.unpin_tab_at(active_tab_ix, cx); + } else { + self.pin_tab_at(active_tab_ix, cx); + } + } + + fn pin_tab_at(&mut self, ix: usize, cx: &mut ViewContext<'_, Self>) { + maybe!({ + let pane = cx.view().clone(); + let destination_index = self.pinned_tab_count; + self.pinned_tab_count += 1; + let id = self.item_for_index(ix)?.item_id(); + + self.workspace + .update(cx, |_, cx| { + cx.defer(move |this, cx| { + this.move_item(pane.clone(), pane, id, destination_index, cx) + }); + }) + .ok()?; + + Some(()) + }); + } + + fn unpin_tab_at(&mut self, ix: usize, cx: &mut ViewContext<'_, Self>) { + maybe!({ + let pane = cx.view().clone(); + self.pinned_tab_count = self.pinned_tab_count.checked_sub(1).unwrap(); + let destination_index = self.pinned_tab_count; + + let id = self.item_for_index(ix)?.item_id(); + + self.workspace + .update(cx, |_, cx| { + cx.defer(move |this, cx| { + this.move_item(pane.clone(), pane, id, destination_index, cx) + }); + }) + .ok()?; + + Some(()) + }); + } + + fn is_tab_pinned(&self, ix: usize) -> bool { + self.pinned_tab_count > ix + } + + fn has_pinned_tabs(&self) -> bool { + self.pinned_tab_count != 0 + } + fn render_tab( &self, ix: usize, @@ -1764,6 +1828,7 @@ impl Pane { let item_id = item.item_id(); let is_first_item = ix == 0; let is_last_item = ix == self.items.len() - 1; + let is_pinned = self.is_tab_pinned(ix); let position_relative_to_active_item = ix.cmp(&self.active_item_index); let tab = Tab::new(ix) @@ -1835,17 +1900,31 @@ impl Pane { tab.tooltip(move |cx| Tooltip::text(text.clone(), cx)) }) .start_slot::(indicator) - .end_slot( - IconButton::new("close tab", IconName::Close) - .shape(IconButtonShape::Square) - .icon_color(Color::Muted) - .size(ButtonSize::None) - .icon_size(IconSize::XSmall) - .on_click(cx.listener(move |pane, _, cx| { - pane.close_item_by_id(item_id, SaveIntent::Close, cx) - .detach_and_log_err(cx); - })), - ) + .map(|this| { + let end_slot = if is_pinned { + IconButton::new("unpin tab", IconName::PinAlt) + .shape(IconButtonShape::Square) + .icon_color(Color::Muted) + .size(ButtonSize::None) + .icon_size(IconSize::XSmall) + .on_click(cx.listener(move |pane, _, cx| { + pane.unpin_tab_at(ix, cx); + })) + .tooltip(|cx| Tooltip::text("Unpin Tab", cx)) + } else { + IconButton::new("close tab", IconName::Close) + .visible_on_hover("") + .shape(IconButtonShape::Square) + .icon_color(Color::Muted) + .size(ButtonSize::None) + .icon_size(IconSize::XSmall) + .on_click(cx.listener(move |pane, _, cx| { + pane.close_item_by_id(item_id, SaveIntent::Close, cx) + .detach_and_log_err(cx); + })) + }; + this.end_slot(end_slot) + }) .child( h_flex() .gap_1() @@ -1862,6 +1941,7 @@ impl Pane { } }; + let is_pinned = self.is_tab_pinned(ix); let pane = cx.view().downgrade(); right_click_menu(ix).trigger(tab).menu(move |cx| { let pane = pane.clone(); @@ -1923,6 +2003,27 @@ impl Pane { }), ); + let pin_tab_entries = |menu: ContextMenu| { + menu.separator().map(|this| { + if is_pinned { + this.entry( + "Unpin Tab", + Some(TogglePinTab.boxed_clone()), + cx.handler_for(&pane, move |pane, cx| { + pane.unpin_tab_at(ix, cx); + }), + ) + } else { + this.entry( + "Pin Tab", + Some(TogglePinTab.boxed_clone()), + cx.handler_for(&pane, move |pane, cx| { + pane.pin_tab_at(ix, cx); + }), + ) + } + }) + }; if let Some(entry) = single_entry_to_resolve { let entry_abs_path = pane.read(cx).entry_abs_path(entry, cx); let parent_abs_path = entry_abs_path @@ -1950,6 +2051,7 @@ impl Pane { pane.copy_relative_path(&CopyRelativePath, cx); }), ) + .map(pin_tab_entries) .separator() .entry( "Reveal In Project Panel", @@ -1978,6 +2080,8 @@ impl Pane { }), ) }); + } else { + menu = menu.map(pin_tab_entries); } } @@ -2014,8 +2118,17 @@ impl Pane { move |cx| Tooltip::for_action_in("Go Forward", &GoForward, &focus_handle, cx) }); + let mut tab_items = self + .items + .iter() + .enumerate() + .zip(tab_details(&self.items, cx)) + .map(|((ix, item), detail)| self.render_tab(ix, &**item, detail, cx)) + .collect::>(); + + let unpinned_tabs = tab_items.split_off(self.pinned_tab_count); + let pinned_tabs = tab_items; TabBar::new("tab_bar") - .track_scroll(self.tab_bar_scroll_handle.clone()) .when( self.display_nav_history_buttons.unwrap_or_default(), |tab_bar| { @@ -2032,45 +2145,57 @@ impl Pane { .start_children(left_children) .end_children(right_children) }) - .children( - self.items - .iter() - .enumerate() - .zip(tab_details(&self.items, cx)) - .map(|((ix, item), detail)| self.render_tab(ix, &**item, detail, cx)), - ) + .children(pinned_tabs.len().ne(&0).then(|| { + h_flex() + .children(pinned_tabs) + .border_r_2() + .border_color(cx.theme().colors().border) + })) .child( - div() - .id("tab_bar_drop_target") - .min_w_6() - // HACK: This empty child is currently necessary to force the drop target to appear - // despite us setting a min width above. - .child("") - .h_full() - .flex_grow() - .drag_over::(|bar, _, cx| { - bar.bg(cx.theme().colors().drop_target_background) - }) - .drag_over::(|bar, _, cx| { - bar.bg(cx.theme().colors().drop_target_background) - }) - .on_drop(cx.listener(move |this, dragged_tab: &DraggedTab, cx| { - this.drag_split_direction = None; - this.handle_tab_drop(dragged_tab, this.items.len(), cx) - })) - .on_drop(cx.listener(move |this, selection: &DraggedSelection, cx| { - this.drag_split_direction = None; - this.handle_project_entry_drop(&selection.active_selection.entry_id, cx) - })) - .on_drop(cx.listener(move |this, paths, cx| { - this.drag_split_direction = None; - this.handle_external_paths_drop(paths, cx) - })) - .on_click(cx.listener(move |this, event: &ClickEvent, cx| { - if event.up.click_count == 2 { - cx.dispatch_action(this.double_click_dispatch_action.boxed_clone()) - } - })), + h_flex() + .id("unpinned tabs") + .overflow_x_scroll() + .w_full() + .track_scroll(&self.tab_bar_scroll_handle) + .children(unpinned_tabs) + .child( + div() + .id("tab_bar_drop_target") + .min_w_6() + // HACK: This empty child is currently necessary to force the drop target to appear + // despite us setting a min width above. + .child("") + .h_full() + .flex_grow() + .drag_over::(|bar, _, cx| { + bar.bg(cx.theme().colors().drop_target_background) + }) + .drag_over::(|bar, _, cx| { + bar.bg(cx.theme().colors().drop_target_background) + }) + .on_drop(cx.listener(move |this, dragged_tab: &DraggedTab, cx| { + this.drag_split_direction = None; + this.handle_tab_drop(dragged_tab, this.items.len(), cx) + })) + .on_drop(cx.listener(move |this, selection: &DraggedSelection, cx| { + this.drag_split_direction = None; + this.handle_project_entry_drop( + &selection.active_selection.entry_id, + cx, + ) + })) + .on_drop(cx.listener(move |this, paths, cx| { + this.drag_split_direction = None; + this.handle_external_paths_drop(paths, cx) + })) + .on_click(cx.listener(move |this, event: &ClickEvent, cx| { + if event.up.click_count == 2 { + cx.dispatch_action( + this.double_click_dispatch_action.boxed_clone(), + ) + } + })), + ), ) } @@ -2164,7 +2289,37 @@ impl Pane { if let Some(split_direction) = split_direction { to_pane = workspace.split_pane(to_pane, split_direction, cx); } - workspace.move_item(from_pane, to_pane, item_id, ix, cx); + let old_ix = from_pane.read(cx).index_for_item_id(item_id); + if to_pane == from_pane { + if let Some(old_index) = old_ix { + to_pane.update(cx, |this, _| { + if old_index < this.pinned_tab_count + && (ix == this.items.len() || ix > this.pinned_tab_count) + { + this.pinned_tab_count -= 1; + } else if this.has_pinned_tabs() + && old_index >= this.pinned_tab_count + && ix < this.pinned_tab_count + { + this.pinned_tab_count += 1; + } + }); + } + } else { + to_pane.update(cx, |this, _| { + if this.has_pinned_tabs() && ix < this.pinned_tab_count { + this.pinned_tab_count += 1; + } + }); + from_pane.update(cx, |this, _| { + if let Some(index) = old_ix { + if this.pinned_tab_count > index { + this.pinned_tab_count -= 1; + } + } + }) + } + workspace.move_item(from_pane.clone(), to_pane.clone(), item_id, ix, cx); }); }) .log_err(); @@ -2209,13 +2364,13 @@ impl Pane { if let Some((project_entry_id, build_item)) = load_path_task.await.notify_async_err(&mut cx) { - workspace + let (to_pane, new_item_handle) = workspace .update(&mut cx, |workspace, cx| { if let Some(split_direction) = split_direction { to_pane = workspace.split_pane(to_pane, split_direction, cx); } - to_pane.update(cx, |pane, cx| { + let new_item_handle = to_pane.update(cx, |pane, cx| { pane.open_item( project_entry_id, true, @@ -2223,10 +2378,23 @@ impl Pane { cx, build_item, ) - }) + }); + (to_pane, new_item_handle) }) - .log_err(); + .log_err()?; + to_pane + .update(&mut cx, |this, cx| { + let Some(index) = this.index_for_item(&*new_item_handle) + else { + return; + }; + if !this.is_tab_pinned(index) { + this.pin_tab_at(index, cx); + } + }) + .ok()? } + Some(()) }) .detach(); }; @@ -2374,6 +2542,9 @@ impl Render for Pane { .on_action(cx.listener(|pane: &mut Pane, _: &ActivateNextItem, cx| { pane.activate_next_item(true, cx); })) + .on_action(cx.listener(|pane, action, cx| { + pane.toggle_pin_tab(action, cx); + })) .when(PreviewTabsSettings::get_global(cx).enabled, |this| { this.on_action(cx.listener(|pane: &mut Pane, _: &TogglePreviewTab, cx| { if let Some(active_item_id) = pane.active_item().map(|i| i.item_id()) {