Pane: Add tab pinning (#17426)

Closes #5046

Release Notes:

- Added "Pin/Unpin Tab" action to the workspace, assistant and terminal
tabs.

---------

Co-authored-by: Danilo <danilo@zed.dev>
This commit is contained in:
Piotr Osiewicz 2024-09-05 18:53:55 +02:00 committed by GitHub
parent adc3e9fe1b
commit fef181a66f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 235 additions and 65 deletions

View file

@ -262,7 +262,8 @@
"alt-r": "search::ToggleRegex", "alt-r": "search::ToggleRegex",
"alt-ctrl-f": "project_search::ToggleFilters", "alt-ctrl-f": "project_search::ToggleFilters",
"ctrl-alt-shift-r": "search::ToggleRegex", "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 // Bindings from VS Code

View file

@ -299,7 +299,8 @@
"alt-cmd-c": "search::ToggleCaseSensitive", "alt-cmd-c": "search::ToggleCaseSensitive",
"alt-cmd-w": "search::ToggleWholeWord", "alt-cmd-w": "search::ToggleWholeWord",
"alt-cmd-f": "project_search::ToggleFilters", "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 // Bindings from VS Code

View file

@ -28,6 +28,7 @@ impl Render for TabStory {
Tab::new("tab_1") Tab::new("tab_1")
.end_slot( .end_slot(
IconButton::new("close_button", IconName::Close) IconButton::new("close_button", IconName::Close)
.visible_on_hover("")
.shape(IconButtonShape::Square) .shape(IconButtonShape::Square)
.icon_color(Color::Muted) .icon_color(Color::Muted)
.size(ButtonSize::None) .size(ButtonSize::None)

View file

@ -120,11 +120,7 @@ impl RenderOnce for Tab {
let (start_slot, end_slot) = { let (start_slot, end_slot) = {
let start_slot = h_flex().size_3().justify_center().children(self.start_slot); let start_slot = h_flex().size_3().justify_center().children(self.start_slot);
let end_slot = h_flex() let end_slot = h_flex().size_3().justify_center().children(self.end_slot);
.size_3()
.justify_center()
.visible_on_hover("")
.children(self.end_slot);
match self.close_side { match self.close_side {
TabCloseSide::End => (start_slot, end_slot), TabCloseSide::End => (start_slot, end_slot),

View file

@ -157,6 +157,7 @@ actions!(
SplitHorizontal, SplitHorizontal,
SplitVertical, SplitVertical,
TogglePreviewTab, TogglePreviewTab,
TogglePinTab,
] ]
); );
@ -272,6 +273,7 @@ pub struct Pane {
save_modals_spawned: HashSet<EntityId>, save_modals_spawned: HashSet<EntityId>,
pub new_item_context_menu_handle: PopoverMenuHandle<ContextMenu>, pub new_item_context_menu_handle: PopoverMenuHandle<ContextMenu>,
split_item_context_menu_handle: PopoverMenuHandle<ContextMenu>, split_item_context_menu_handle: PopoverMenuHandle<ContextMenu>,
pinned_tab_count: usize,
} }
pub struct ActivationHistoryEntry { pub struct ActivationHistoryEntry {
@ -470,6 +472,7 @@ impl Pane {
save_modals_spawned: HashSet::default(), save_modals_spawned: HashSet::default(),
split_item_context_menu_handle: Default::default(), split_item_context_menu_handle: Default::default(),
new_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<usize> { pub fn index_for_item(&self, item: &dyn ItemHandle) -> Option<usize> {
self.items self.index_for_item_id(item.item_id())
.iter() }
.position(|i| i.item_id() == item.item_id())
fn index_for_item_id(&self, item_id: EntityId) -> Option<usize> {
self.items.iter().position(|i| i.item_id() == item_id)
} }
pub fn item_for_index(&self, ix: usize) -> Option<&dyn ItemHandle> { 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( fn render_tab(
&self, &self,
ix: usize, ix: usize,
@ -1764,6 +1828,7 @@ impl Pane {
let item_id = item.item_id(); let item_id = item.item_id();
let is_first_item = ix == 0; let is_first_item = ix == 0;
let is_last_item = ix == self.items.len() - 1; 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 position_relative_to_active_item = ix.cmp(&self.active_item_index);
let tab = Tab::new(ix) let tab = Tab::new(ix)
@ -1835,8 +1900,20 @@ impl Pane {
tab.tooltip(move |cx| Tooltip::text(text.clone(), cx)) tab.tooltip(move |cx| Tooltip::text(text.clone(), cx))
}) })
.start_slot::<Indicator>(indicator) .start_slot::<Indicator>(indicator)
.end_slot( .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) IconButton::new("close tab", IconName::Close)
.visible_on_hover("")
.shape(IconButtonShape::Square) .shape(IconButtonShape::Square)
.icon_color(Color::Muted) .icon_color(Color::Muted)
.size(ButtonSize::None) .size(ButtonSize::None)
@ -1844,8 +1921,10 @@ impl Pane {
.on_click(cx.listener(move |pane, _, cx| { .on_click(cx.listener(move |pane, _, cx| {
pane.close_item_by_id(item_id, SaveIntent::Close, cx) pane.close_item_by_id(item_id, SaveIntent::Close, cx)
.detach_and_log_err(cx); .detach_and_log_err(cx);
})), }))
) };
this.end_slot(end_slot)
})
.child( .child(
h_flex() h_flex()
.gap_1() .gap_1()
@ -1862,6 +1941,7 @@ impl Pane {
} }
}; };
let is_pinned = self.is_tab_pinned(ix);
let pane = cx.view().downgrade(); let pane = cx.view().downgrade();
right_click_menu(ix).trigger(tab).menu(move |cx| { right_click_menu(ix).trigger(tab).menu(move |cx| {
let pane = pane.clone(); 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 { if let Some(entry) = single_entry_to_resolve {
let entry_abs_path = pane.read(cx).entry_abs_path(entry, cx); let entry_abs_path = pane.read(cx).entry_abs_path(entry, cx);
let parent_abs_path = entry_abs_path let parent_abs_path = entry_abs_path
@ -1950,6 +2051,7 @@ impl Pane {
pane.copy_relative_path(&CopyRelativePath, cx); pane.copy_relative_path(&CopyRelativePath, cx);
}), }),
) )
.map(pin_tab_entries)
.separator() .separator()
.entry( .entry(
"Reveal In Project Panel", "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) 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::<Vec<_>>();
let unpinned_tabs = tab_items.split_off(self.pinned_tab_count);
let pinned_tabs = tab_items;
TabBar::new("tab_bar") TabBar::new("tab_bar")
.track_scroll(self.tab_bar_scroll_handle.clone())
.when( .when(
self.display_nav_history_buttons.unwrap_or_default(), self.display_nav_history_buttons.unwrap_or_default(),
|tab_bar| { |tab_bar| {
@ -2032,13 +2145,19 @@ impl Pane {
.start_children(left_children) .start_children(left_children)
.end_children(right_children) .end_children(right_children)
}) })
.children( .children(pinned_tabs.len().ne(&0).then(|| {
self.items h_flex()
.iter() .children(pinned_tabs)
.enumerate() .border_r_2()
.zip(tab_details(&self.items, cx)) .border_color(cx.theme().colors().border)
.map(|((ix, item), detail)| self.render_tab(ix, &**item, detail, cx)), }))
) .child(
h_flex()
.id("unpinned tabs")
.overflow_x_scroll()
.w_full()
.track_scroll(&self.tab_bar_scroll_handle)
.children(unpinned_tabs)
.child( .child(
div() div()
.id("tab_bar_drop_target") .id("tab_bar_drop_target")
@ -2060,7 +2179,10 @@ impl Pane {
})) }))
.on_drop(cx.listener(move |this, selection: &DraggedSelection, cx| { .on_drop(cx.listener(move |this, selection: &DraggedSelection, cx| {
this.drag_split_direction = None; this.drag_split_direction = None;
this.handle_project_entry_drop(&selection.active_selection.entry_id, cx) this.handle_project_entry_drop(
&selection.active_selection.entry_id,
cx,
)
})) }))
.on_drop(cx.listener(move |this, paths, cx| { .on_drop(cx.listener(move |this, paths, cx| {
this.drag_split_direction = None; this.drag_split_direction = None;
@ -2068,9 +2190,12 @@ impl Pane {
})) }))
.on_click(cx.listener(move |this, event: &ClickEvent, cx| { .on_click(cx.listener(move |this, event: &ClickEvent, cx| {
if event.up.click_count == 2 { if event.up.click_count == 2 {
cx.dispatch_action(this.double_click_dispatch_action.boxed_clone()) cx.dispatch_action(
this.double_click_dispatch_action.boxed_clone(),
)
} }
})), })),
),
) )
} }
@ -2164,7 +2289,37 @@ impl Pane {
if let Some(split_direction) = split_direction { if let Some(split_direction) = split_direction {
to_pane = workspace.split_pane(to_pane, split_direction, cx); 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(); .log_err();
@ -2209,13 +2364,13 @@ impl Pane {
if let Some((project_entry_id, build_item)) = if let Some((project_entry_id, build_item)) =
load_path_task.await.notify_async_err(&mut cx) load_path_task.await.notify_async_err(&mut cx)
{ {
workspace let (to_pane, new_item_handle) = workspace
.update(&mut cx, |workspace, cx| { .update(&mut cx, |workspace, cx| {
if let Some(split_direction) = split_direction { if let Some(split_direction) = split_direction {
to_pane = to_pane =
workspace.split_pane(to_pane, split_direction, cx); 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( pane.open_item(
project_entry_id, project_entry_id,
true, true,
@ -2223,11 +2378,24 @@ impl Pane {
cx, cx,
build_item, 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(); .detach();
}; };
}); });
@ -2374,6 +2542,9 @@ impl Render for Pane {
.on_action(cx.listener(|pane: &mut Pane, _: &ActivateNextItem, cx| { .on_action(cx.listener(|pane: &mut Pane, _: &ActivateNextItem, cx| {
pane.activate_next_item(true, 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| { .when(PreviewTabsSettings::get_global(cx).enabled, |this| {
this.on_action(cx.listener(|pane: &mut Pane, _: &TogglePreviewTab, cx| { this.on_action(cx.listener(|pane: &mut Pane, _: &TogglePreviewTab, cx| {
if let Some(active_item_id) = pane.active_item().map(|i| i.item_id()) { if let Some(active_item_id) = pane.active_item().map(|i| i.item_id()) {