ZIm/crates/workspace/src/pane.rs
Kirill Bulatov 42ae3301d0
Show file open error view instead of the modal (#36764)
Closes https://github.com/zed-industries/zed/issues/36672

Before:
either 
<img width="966" height="642" alt="image"
src="https://github.com/user-attachments/assets/7263ea3c-3d48-4f4d-be9e-16b24ca6f60b"
/>
(when opening from the project panel)

or

<img width="959" height="1019" alt="image"
src="https://github.com/user-attachments/assets/834041d4-f4d6-46db-b333-803169ec4803"
/>

(for the rest of the cases)

After:

<img width="2032" height="1167" alt="Screenshot 2025-08-22 at 19 34 10"
src="https://github.com/user-attachments/assets/1aa4530b-69f6-4c3a-8ea1-d4035dbb28da"
/>

(the unified error view)

Release Notes:

- Improved unsupported file opening in Zed

---------

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>
2025-08-22 17:04:39 +00:00

6552 lines
240 KiB
Rust

use crate::{
CloseWindow, NewFile, NewTerminal, OpenInTerminal, OpenOptions, OpenTerminal, OpenVisible,
SplitDirection, ToggleFileFinder, ToggleProjectSymbols, ToggleZoom, Workspace,
WorkspaceItemBuilder,
invalid_buffer_view::InvalidBufferView,
item::{
ActivateOnClose, ClosePosition, Item, ItemHandle, ItemSettings, PreviewTabsSettings,
ProjectItemKind, SaveOptions, ShowCloseButton, ShowDiagnostics, TabContentParams,
TabTooltipContent, WeakItemHandle,
},
move_item,
notifications::NotifyResultExt,
toolbar::Toolbar,
workspace_settings::{AutosaveSetting, TabBarSettings, WorkspaceSettings},
};
use anyhow::Result;
use collections::{BTreeSet, HashMap, HashSet, VecDeque};
use futures::{StreamExt, stream::FuturesUnordered};
use gpui::{
Action, AnyElement, App, AsyncWindowContext, ClickEvent, ClipboardItem, Context, Corner, Div,
DragMoveEvent, Entity, EntityId, EventEmitter, ExternalPaths, FocusHandle, FocusOutEvent,
Focusable, IsZero, KeyContext, MouseButton, MouseDownEvent, NavigationDirection, Pixels, Point,
PromptLevel, Render, ScrollHandle, Subscription, Task, WeakEntity, WeakFocusHandle, Window,
actions, anchored, deferred, prelude::*,
};
use itertools::Itertools;
use language::DiagnosticSeverity;
use parking_lot::Mutex;
use project::{DirectoryLister, Project, ProjectEntryId, ProjectPath, WorktreeId};
use schemars::JsonSchema;
use serde::Deserialize;
use settings::{Settings, SettingsStore};
use std::{
any::Any,
cmp, fmt, mem,
num::NonZeroUsize,
ops::ControlFlow,
path::PathBuf,
rc::Rc,
sync::{
Arc,
atomic::{AtomicUsize, Ordering},
},
time::Duration,
};
use theme::ThemeSettings;
use ui::{
ButtonSize, Color, ContextMenu, ContextMenuEntry, ContextMenuItem, DecoratedIcon, IconButton,
IconButtonShape, IconDecoration, IconDecorationKind, IconName, IconSize, Indicator, Label,
PopoverMenu, PopoverMenuHandle, Tab, TabBar, TabPosition, Tooltip, prelude::*,
right_click_menu,
};
use util::{ResultExt, debug_panic, maybe, truncate_and_remove_front};
/// A selected entry in e.g. project panel.
#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct SelectedEntry {
pub worktree_id: WorktreeId,
pub entry_id: ProjectEntryId,
}
/// A group of selected entries from project panel.
#[derive(Debug)]
pub struct DraggedSelection {
pub active_selection: SelectedEntry,
pub marked_selections: Arc<[SelectedEntry]>,
}
impl DraggedSelection {
pub fn items<'a>(&'a self) -> Box<dyn Iterator<Item = &'a SelectedEntry> + 'a> {
if self.marked_selections.contains(&self.active_selection) {
Box::new(self.marked_selections.iter())
} else {
Box::new(std::iter::once(&self.active_selection))
}
}
}
#[derive(Clone, Copy, PartialEq, Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum SaveIntent {
/// write all files (even if unchanged)
/// prompt before overwriting on-disk changes
Save,
/// same as Save, but without auto formatting
SaveWithoutFormat,
/// write any files that have local changes
/// prompt before overwriting on-disk changes
SaveAll,
/// always prompt for a new path
SaveAs,
/// prompt "you have unsaved changes" before writing
Close,
/// write all dirty files, don't prompt on conflict
Overwrite,
/// skip all save-related behavior
Skip,
}
/// Activates a specific item in the pane by its index.
#[derive(Clone, PartialEq, Debug, Deserialize, JsonSchema, Default, Action)]
#[action(namespace = pane)]
pub struct ActivateItem(pub usize);
/// Closes the currently active item in the pane.
#[derive(Clone, PartialEq, Debug, Deserialize, JsonSchema, Default, Action)]
#[action(namespace = pane)]
#[serde(deny_unknown_fields)]
pub struct CloseActiveItem {
#[serde(default)]
pub save_intent: Option<SaveIntent>,
#[serde(default)]
pub close_pinned: bool,
}
/// Closes all inactive items in the pane.
#[derive(Clone, PartialEq, Debug, Deserialize, JsonSchema, Default, Action)]
#[action(namespace = pane)]
#[serde(deny_unknown_fields)]
#[action(deprecated_aliases = ["pane::CloseInactiveItems"])]
pub struct CloseOtherItems {
#[serde(default)]
pub save_intent: Option<SaveIntent>,
#[serde(default)]
pub close_pinned: bool,
}
/// Closes all items in the pane.
#[derive(Clone, PartialEq, Debug, Deserialize, JsonSchema, Default, Action)]
#[action(namespace = pane)]
#[serde(deny_unknown_fields)]
pub struct CloseAllItems {
#[serde(default)]
pub save_intent: Option<SaveIntent>,
#[serde(default)]
pub close_pinned: bool,
}
/// Closes all items that have no unsaved changes.
#[derive(Clone, PartialEq, Debug, Deserialize, JsonSchema, Default, Action)]
#[action(namespace = pane)]
#[serde(deny_unknown_fields)]
pub struct CloseCleanItems {
#[serde(default)]
pub close_pinned: bool,
}
/// Closes all items to the right of the current item.
#[derive(Clone, PartialEq, Debug, Deserialize, JsonSchema, Default, Action)]
#[action(namespace = pane)]
#[serde(deny_unknown_fields)]
pub struct CloseItemsToTheRight {
#[serde(default)]
pub close_pinned: bool,
}
/// Closes all items to the left of the current item.
#[derive(Clone, PartialEq, Debug, Deserialize, JsonSchema, Default, Action)]
#[action(namespace = pane)]
#[serde(deny_unknown_fields)]
pub struct CloseItemsToTheLeft {
#[serde(default)]
pub close_pinned: bool,
}
/// Reveals the current item in the project panel.
#[derive(Clone, PartialEq, Debug, Deserialize, JsonSchema, Default, Action)]
#[action(namespace = pane)]
#[serde(deny_unknown_fields)]
pub struct RevealInProjectPanel {
#[serde(skip)]
pub entry_id: Option<u64>,
}
/// Opens the search interface with the specified configuration.
#[derive(Clone, PartialEq, Debug, Deserialize, JsonSchema, Default, Action)]
#[action(namespace = pane)]
#[serde(deny_unknown_fields)]
pub struct DeploySearch {
#[serde(default)]
pub replace_enabled: bool,
#[serde(default)]
pub included_files: Option<String>,
#[serde(default)]
pub excluded_files: Option<String>,
}
actions!(
pane,
[
/// Activates the previous item in the pane.
ActivatePreviousItem,
/// Activates the next item in the pane.
ActivateNextItem,
/// Activates the last item in the pane.
ActivateLastItem,
/// Switches to the alternate file.
AlternateFile,
/// Navigates back in history.
GoBack,
/// Navigates forward in history.
GoForward,
/// Joins this pane into the next pane.
JoinIntoNext,
/// Joins all panes into one.
JoinAll,
/// Reopens the most recently closed item.
ReopenClosedItem,
/// Splits the pane to the left.
SplitLeft,
/// Splits the pane upward.
SplitUp,
/// Splits the pane to the right.
SplitRight,
/// Splits the pane downward.
SplitDown,
/// Splits the pane horizontally.
SplitHorizontal,
/// Splits the pane vertically.
SplitVertical,
/// Swaps the current item with the one to the left.
SwapItemLeft,
/// Swaps the current item with the one to the right.
SwapItemRight,
/// Toggles preview mode for the current tab.
TogglePreviewTab,
/// Toggles pin status for the current tab.
TogglePinTab,
/// Unpins all tabs in the pane.
UnpinAllTabs,
]
);
impl DeploySearch {
pub fn find() -> Self {
Self {
replace_enabled: false,
included_files: None,
excluded_files: None,
}
}
}
const MAX_NAVIGATION_HISTORY_LEN: usize = 1024;
pub enum Event {
AddItem {
item: Box<dyn ItemHandle>,
},
ActivateItem {
local: bool,
focus_changed: bool,
},
Remove {
focus_on_pane: Option<Entity<Pane>>,
},
RemoveItem {
idx: usize,
},
RemovedItem {
item: Box<dyn ItemHandle>,
},
Split(SplitDirection),
ItemPinned,
ItemUnpinned,
JoinAll,
JoinIntoNext,
ChangeItemTitle,
Focus,
ZoomIn,
ZoomOut,
UserSavedItem {
item: Box<dyn WeakItemHandle>,
save_intent: SaveIntent,
},
}
impl fmt::Debug for Event {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Event::AddItem { item } => f
.debug_struct("AddItem")
.field("item", &item.item_id())
.finish(),
Event::ActivateItem { local, .. } => f
.debug_struct("ActivateItem")
.field("local", local)
.finish(),
Event::Remove { .. } => f.write_str("Remove"),
Event::RemoveItem { idx } => f.debug_struct("RemoveItem").field("idx", idx).finish(),
Event::RemovedItem { item } => f
.debug_struct("RemovedItem")
.field("item", &item.item_id())
.finish(),
Event::Split(direction) => f
.debug_struct("Split")
.field("direction", direction)
.finish(),
Event::JoinAll => f.write_str("JoinAll"),
Event::JoinIntoNext => f.write_str("JoinIntoNext"),
Event::ChangeItemTitle => f.write_str("ChangeItemTitle"),
Event::Focus => f.write_str("Focus"),
Event::ZoomIn => f.write_str("ZoomIn"),
Event::ZoomOut => f.write_str("ZoomOut"),
Event::UserSavedItem { item, save_intent } => f
.debug_struct("UserSavedItem")
.field("item", &item.id())
.field("save_intent", save_intent)
.finish(),
Event::ItemPinned => f.write_str("ItemPinned"),
Event::ItemUnpinned => f.write_str("ItemUnpinned"),
}
}
}
/// A container for 0 to many items that are open in the workspace.
/// Treats all items uniformly via the [`ItemHandle`] trait, whether it's an editor, search results multibuffer, terminal or something else,
/// responsible for managing item tabs, focus and zoom states and drag and drop features.
/// Can be split, see `PaneGroup` for more details.
pub struct Pane {
alternate_file_items: (
Option<Box<dyn WeakItemHandle>>,
Option<Box<dyn WeakItemHandle>>,
),
focus_handle: FocusHandle,
items: Vec<Box<dyn ItemHandle>>,
activation_history: Vec<ActivationHistoryEntry>,
next_activation_timestamp: Arc<AtomicUsize>,
zoomed: bool,
was_focused: bool,
active_item_index: usize,
preview_item_id: Option<EntityId>,
last_focus_handle_by_item: HashMap<EntityId, WeakFocusHandle>,
nav_history: NavHistory,
toolbar: Entity<Toolbar>,
pub(crate) workspace: WeakEntity<Workspace>,
project: WeakEntity<Project>,
pub drag_split_direction: Option<SplitDirection>,
can_drop_predicate: Option<Arc<dyn Fn(&dyn Any, &mut Window, &mut App) -> bool>>,
custom_drop_handle: Option<
Arc<dyn Fn(&mut Pane, &dyn Any, &mut Window, &mut Context<Pane>) -> ControlFlow<(), ()>>,
>,
can_split_predicate:
Option<Arc<dyn Fn(&mut Self, &dyn Any, &mut Window, &mut Context<Self>) -> bool>>,
can_toggle_zoom: bool,
should_display_tab_bar: Rc<dyn Fn(&Window, &mut Context<Pane>) -> bool>,
render_tab_bar_buttons: Rc<
dyn Fn(
&mut Pane,
&mut Window,
&mut Context<Pane>,
) -> (Option<AnyElement>, Option<AnyElement>),
>,
render_tab_bar: Rc<dyn Fn(&mut Pane, &mut Window, &mut Context<Pane>) -> AnyElement>,
show_tab_bar_buttons: bool,
max_tabs: Option<NonZeroUsize>,
_subscriptions: Vec<Subscription>,
tab_bar_scroll_handle: ScrollHandle,
/// Is None if navigation buttons are permanently turned off (and should not react to setting changes).
/// Otherwise, when `display_nav_history_buttons` is Some, it determines whether nav buttons should be displayed.
display_nav_history_buttons: Option<bool>,
double_click_dispatch_action: Box<dyn Action>,
save_modals_spawned: HashSet<EntityId>,
close_pane_if_empty: bool,
pub new_item_context_menu_handle: PopoverMenuHandle<ContextMenu>,
pub split_item_context_menu_handle: PopoverMenuHandle<ContextMenu>,
pinned_tab_count: usize,
diagnostics: HashMap<ProjectPath, DiagnosticSeverity>,
zoom_out_on_close: bool,
diagnostic_summary_update: Task<()>,
/// If a certain project item wants to get recreated with specific data, it can persist its data before the recreation here.
pub project_item_restoration_data: HashMap<ProjectItemKind, Box<dyn Any + Send>>,
}
pub struct ActivationHistoryEntry {
pub entity_id: EntityId,
pub timestamp: usize,
}
pub struct ItemNavHistory {
history: NavHistory,
item: Arc<dyn WeakItemHandle>,
is_preview: bool,
}
#[derive(Clone)]
pub struct NavHistory(Arc<Mutex<NavHistoryState>>);
struct NavHistoryState {
mode: NavigationMode,
backward_stack: VecDeque<NavigationEntry>,
forward_stack: VecDeque<NavigationEntry>,
closed_stack: VecDeque<NavigationEntry>,
paths_by_item: HashMap<EntityId, (ProjectPath, Option<PathBuf>)>,
pane: WeakEntity<Pane>,
next_timestamp: Arc<AtomicUsize>,
}
#[derive(Debug, Copy, Clone)]
pub enum NavigationMode {
Normal,
GoingBack,
GoingForward,
ClosingItem,
ReopeningClosedItem,
Disabled,
}
impl Default for NavigationMode {
fn default() -> Self {
Self::Normal
}
}
pub struct NavigationEntry {
pub item: Arc<dyn WeakItemHandle>,
pub data: Option<Box<dyn Any + Send>>,
pub timestamp: usize,
pub is_preview: bool,
}
#[derive(Clone)]
pub struct DraggedTab {
pub pane: Entity<Pane>,
pub item: Box<dyn ItemHandle>,
pub ix: usize,
pub detail: usize,
pub is_active: bool,
}
impl EventEmitter<Event> for Pane {}
pub enum Side {
Left,
Right,
}
#[derive(Copy, Clone)]
enum PinOperation {
Pin,
Unpin,
}
impl Pane {
pub fn new(
workspace: WeakEntity<Workspace>,
project: Entity<Project>,
next_timestamp: Arc<AtomicUsize>,
can_drop_predicate: Option<Arc<dyn Fn(&dyn Any, &mut Window, &mut App) -> bool + 'static>>,
double_click_dispatch_action: Box<dyn Action>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let focus_handle = cx.focus_handle();
let subscriptions = vec![
cx.on_focus(&focus_handle, window, Pane::focus_in),
cx.on_focus_in(&focus_handle, window, Pane::focus_in),
cx.on_focus_out(&focus_handle, window, Pane::focus_out),
cx.observe_global_in::<SettingsStore>(window, Self::settings_changed),
cx.subscribe(&project, Self::project_events),
];
let handle = cx.entity().downgrade();
Self {
alternate_file_items: (None, None),
focus_handle,
items: Vec::new(),
activation_history: Vec::new(),
next_activation_timestamp: next_timestamp.clone(),
was_focused: false,
zoomed: false,
active_item_index: 0,
preview_item_id: None,
max_tabs: WorkspaceSettings::get_global(cx).max_tabs,
last_focus_handle_by_item: Default::default(),
nav_history: NavHistory(Arc::new(Mutex::new(NavHistoryState {
mode: NavigationMode::Normal,
backward_stack: Default::default(),
forward_stack: Default::default(),
closed_stack: Default::default(),
paths_by_item: Default::default(),
pane: handle,
next_timestamp,
}))),
toolbar: cx.new(|_| Toolbar::new()),
tab_bar_scroll_handle: ScrollHandle::new(),
drag_split_direction: None,
workspace,
project: project.downgrade(),
can_drop_predicate,
custom_drop_handle: None,
can_split_predicate: None,
can_toggle_zoom: true,
should_display_tab_bar: Rc::new(|_, cx| TabBarSettings::get_global(cx).show),
render_tab_bar_buttons: Rc::new(default_render_tab_bar_buttons),
render_tab_bar: Rc::new(Self::render_tab_bar),
show_tab_bar_buttons: TabBarSettings::get_global(cx).show_tab_bar_buttons,
display_nav_history_buttons: Some(
TabBarSettings::get_global(cx).show_nav_history_buttons,
),
_subscriptions: subscriptions,
double_click_dispatch_action,
save_modals_spawned: HashSet::default(),
close_pane_if_empty: true,
split_item_context_menu_handle: Default::default(),
new_item_context_menu_handle: Default::default(),
pinned_tab_count: 0,
diagnostics: Default::default(),
zoom_out_on_close: true,
diagnostic_summary_update: Task::ready(()),
project_item_restoration_data: HashMap::default(),
}
}
fn alternate_file(&mut self, window: &mut Window, cx: &mut Context<Pane>) {
let (_, alternative) = &self.alternate_file_items;
if let Some(alternative) = alternative {
let existing = self
.items()
.find_position(|item| item.item_id() == alternative.id());
if let Some((ix, _)) = existing {
self.activate_item(ix, true, true, window, cx);
} else if let Some(upgraded) = alternative.upgrade() {
self.add_item(upgraded, true, true, None, window, cx);
}
}
}
pub fn track_alternate_file_items(&mut self) {
if let Some(item) = self.active_item().map(|item| item.downgrade_item()) {
let (current, _) = &self.alternate_file_items;
match current {
Some(current) => {
if current.id() != item.id() {
self.alternate_file_items =
(Some(item), self.alternate_file_items.0.take());
}
}
None => {
self.alternate_file_items = (Some(item), None);
}
}
}
}
pub fn has_focus(&self, window: &Window, cx: &App) -> bool {
// We not only check whether our focus handle contains focus, but also
// whether the active item might have focus, because we might have just activated an item
// that hasn't rendered yet.
// Before the next render, we might transfer focus
// to the item, and `focus_handle.contains_focus` returns false because the `active_item`
// is not hooked up to us in the dispatch tree.
self.focus_handle.contains_focused(window, cx)
|| self
.active_item()
.is_some_and(|item| item.item_focus_handle(cx).contains_focused(window, cx))
}
fn focus_in(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if !self.was_focused {
self.was_focused = true;
self.update_history(self.active_item_index);
cx.emit(Event::Focus);
cx.notify();
}
self.toolbar.update(cx, |toolbar, cx| {
toolbar.focus_changed(true, window, cx);
});
if let Some(active_item) = self.active_item() {
if self.focus_handle.is_focused(window) {
// Schedule a redraw next frame, so that the focus changes below take effect
cx.on_next_frame(window, |_, _, cx| {
cx.notify();
});
// Pane was focused directly. We need to either focus a view inside the active item,
// or focus the active item itself
if let Some(weak_last_focus_handle) =
self.last_focus_handle_by_item.get(&active_item.item_id())
&& let Some(focus_handle) = weak_last_focus_handle.upgrade()
{
focus_handle.focus(window);
return;
}
active_item.item_focus_handle(cx).focus(window);
} else if let Some(focused) = window.focused(cx)
&& !self.context_menu_focused(window, cx)
{
self.last_focus_handle_by_item
.insert(active_item.item_id(), focused.downgrade());
}
}
}
pub fn context_menu_focused(&self, window: &mut Window, cx: &mut Context<Self>) -> bool {
self.new_item_context_menu_handle.is_focused(window, cx)
|| self.split_item_context_menu_handle.is_focused(window, cx)
}
fn focus_out(&mut self, _event: FocusOutEvent, window: &mut Window, cx: &mut Context<Self>) {
self.was_focused = false;
self.toolbar.update(cx, |toolbar, cx| {
toolbar.focus_changed(false, window, cx);
});
cx.notify();
}
fn project_events(
&mut self,
_project: Entity<Project>,
event: &project::Event,
cx: &mut Context<Self>,
) {
match event {
project::Event::DiskBasedDiagnosticsFinished { .. }
| project::Event::DiagnosticsUpdated { .. } => {
if ItemSettings::get_global(cx).show_diagnostics != ShowDiagnostics::Off {
self.diagnostic_summary_update = cx.spawn(async move |this, cx| {
cx.background_executor()
.timer(Duration::from_millis(30))
.await;
this.update(cx, |this, cx| {
this.update_diagnostics(cx);
cx.notify();
})
.log_err();
});
}
}
_ => {}
}
}
fn update_diagnostics(&mut self, cx: &mut Context<Self>) {
let Some(project) = self.project.upgrade() else {
return;
};
let show_diagnostics = ItemSettings::get_global(cx).show_diagnostics;
self.diagnostics = if show_diagnostics != ShowDiagnostics::Off {
project
.read(cx)
.diagnostic_summaries(false, cx)
.filter_map(|(project_path, _, diagnostic_summary)| {
if diagnostic_summary.error_count > 0 {
Some((project_path, DiagnosticSeverity::ERROR))
} else if diagnostic_summary.warning_count > 0
&& show_diagnostics != ShowDiagnostics::Errors
{
Some((project_path, DiagnosticSeverity::WARNING))
} else {
None
}
})
.collect()
} else {
HashMap::default()
}
}
fn settings_changed(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let tab_bar_settings = TabBarSettings::get_global(cx);
let new_max_tabs = WorkspaceSettings::get_global(cx).max_tabs;
if let Some(display_nav_history_buttons) = self.display_nav_history_buttons.as_mut() {
*display_nav_history_buttons = tab_bar_settings.show_nav_history_buttons;
}
self.show_tab_bar_buttons = tab_bar_settings.show_tab_bar_buttons;
if !PreviewTabsSettings::get_global(cx).enabled {
self.preview_item_id = None;
}
if new_max_tabs != self.max_tabs {
self.max_tabs = new_max_tabs;
self.close_items_on_settings_change(window, cx);
}
self.update_diagnostics(cx);
cx.notify();
}
pub fn active_item_index(&self) -> usize {
self.active_item_index
}
pub fn activation_history(&self) -> &[ActivationHistoryEntry] {
&self.activation_history
}
pub fn set_should_display_tab_bar<F>(&mut self, should_display_tab_bar: F)
where
F: 'static + Fn(&Window, &mut Context<Pane>) -> bool,
{
self.should_display_tab_bar = Rc::new(should_display_tab_bar);
}
pub fn set_can_split(
&mut self,
can_split_predicate: Option<
Arc<dyn Fn(&mut Self, &dyn Any, &mut Window, &mut Context<Self>) -> bool + 'static>,
>,
) {
self.can_split_predicate = can_split_predicate;
}
pub fn set_can_toggle_zoom(&mut self, can_toggle_zoom: bool, cx: &mut Context<Self>) {
self.can_toggle_zoom = can_toggle_zoom;
cx.notify();
}
pub fn set_close_pane_if_empty(&mut self, close_pane_if_empty: bool, cx: &mut Context<Self>) {
self.close_pane_if_empty = close_pane_if_empty;
cx.notify();
}
pub fn set_can_navigate(&mut self, can_navigate: bool, cx: &mut Context<Self>) {
self.toolbar.update(cx, |toolbar, cx| {
toolbar.set_can_navigate(can_navigate, cx);
});
cx.notify();
}
pub fn set_render_tab_bar<F>(&mut self, cx: &mut Context<Self>, render: F)
where
F: 'static + Fn(&mut Pane, &mut Window, &mut Context<Pane>) -> AnyElement,
{
self.render_tab_bar = Rc::new(render);
cx.notify();
}
pub fn set_render_tab_bar_buttons<F>(&mut self, cx: &mut Context<Self>, render: F)
where
F: 'static
+ Fn(
&mut Pane,
&mut Window,
&mut Context<Pane>,
) -> (Option<AnyElement>, Option<AnyElement>),
{
self.render_tab_bar_buttons = Rc::new(render);
cx.notify();
}
pub fn set_custom_drop_handle<F>(&mut self, cx: &mut Context<Self>, handle: F)
where
F: 'static
+ Fn(&mut Pane, &dyn Any, &mut Window, &mut Context<Pane>) -> ControlFlow<(), ()>,
{
self.custom_drop_handle = Some(Arc::new(handle));
cx.notify();
}
pub fn nav_history_for_item<T: Item>(&self, item: &Entity<T>) -> ItemNavHistory {
ItemNavHistory {
history: self.nav_history.clone(),
item: Arc::new(item.downgrade()),
is_preview: self.preview_item_id == Some(item.item_id()),
}
}
pub fn nav_history(&self) -> &NavHistory {
&self.nav_history
}
pub fn nav_history_mut(&mut self) -> &mut NavHistory {
&mut self.nav_history
}
pub fn disable_history(&mut self) {
self.nav_history.disable();
}
pub fn enable_history(&mut self) {
self.nav_history.enable();
}
pub fn can_navigate_backward(&self) -> bool {
!self.nav_history.0.lock().backward_stack.is_empty()
}
pub fn can_navigate_forward(&self) -> bool {
!self.nav_history.0.lock().forward_stack.is_empty()
}
pub fn navigate_backward(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if let Some(workspace) = self.workspace.upgrade() {
let pane = cx.entity().downgrade();
window.defer(cx, move |window, cx| {
workspace.update(cx, |workspace, cx| {
workspace.go_back(pane, window, cx).detach_and_log_err(cx)
})
})
}
}
fn navigate_forward(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if let Some(workspace) = self.workspace.upgrade() {
let pane = cx.entity().downgrade();
window.defer(cx, move |window, cx| {
workspace.update(cx, |workspace, cx| {
workspace
.go_forward(pane, window, cx)
.detach_and_log_err(cx)
})
})
}
}
fn history_updated(&mut self, cx: &mut Context<Self>) {
self.toolbar.update(cx, |_, cx| cx.notify());
}
pub fn preview_item_id(&self) -> Option<EntityId> {
self.preview_item_id
}
pub fn preview_item(&self) -> Option<Box<dyn ItemHandle>> {
self.preview_item_id
.and_then(|id| self.items.iter().find(|item| item.item_id() == id))
.cloned()
}
pub fn preview_item_idx(&self) -> Option<usize> {
if let Some(preview_item_id) = self.preview_item_id {
self.items
.iter()
.position(|item| item.item_id() == preview_item_id)
} else {
None
}
}
pub fn is_active_preview_item(&self, item_id: EntityId) -> bool {
self.preview_item_id == Some(item_id)
}
/// Marks the item with the given ID as the preview item.
/// This will be ignored if the global setting `preview_tabs` is disabled.
pub fn set_preview_item_id(&mut self, item_id: Option<EntityId>, cx: &App) {
if PreviewTabsSettings::get_global(cx).enabled {
self.preview_item_id = item_id;
}
}
/// Should only be used when deserializing a pane.
pub fn set_pinned_count(&mut self, count: usize) {
self.pinned_tab_count = count;
}
pub fn pinned_count(&self) -> usize {
self.pinned_tab_count
}
pub fn handle_item_edit(&mut self, item_id: EntityId, cx: &App) {
if let Some(preview_item) = self.preview_item()
&& preview_item.item_id() == item_id
&& !preview_item.preserve_preview(cx)
{
self.set_preview_item_id(None, cx);
}
}
pub(crate) fn open_item(
&mut self,
project_entry_id: Option<ProjectEntryId>,
project_path: ProjectPath,
focus_item: bool,
allow_preview: bool,
activate: bool,
suggested_position: Option<usize>,
window: &mut Window,
cx: &mut Context<Self>,
build_item: WorkspaceItemBuilder,
) -> Box<dyn ItemHandle> {
let mut existing_item = None;
if let Some(project_entry_id) = project_entry_id {
for (index, item) in self.items.iter().enumerate() {
if item.is_singleton(cx)
&& item.project_entry_ids(cx).as_slice() == [project_entry_id]
{
let item = item.boxed_clone();
existing_item = Some((index, item));
break;
}
}
} else {
for (index, item) in self.items.iter().enumerate() {
if item.is_singleton(cx) && item.project_path(cx).as_ref() == Some(&project_path) {
let item = item.boxed_clone();
existing_item = Some((index, item));
break;
}
}
}
let set_up_existing_item =
|index: usize, pane: &mut Self, window: &mut Window, cx: &mut Context<Self>| {
// If the item is already open, and the item is a preview item
// and we are not allowing items to open as preview, mark the item as persistent.
if let Some(preview_item_id) = pane.preview_item_id
&& let Some(tab) = pane.items.get(index)
&& tab.item_id() == preview_item_id
&& !allow_preview
{
pane.set_preview_item_id(None, cx);
}
if activate {
pane.activate_item(index, focus_item, focus_item, window, cx);
}
};
let set_up_new_item = |new_item: Box<dyn ItemHandle>,
destination_index: Option<usize>,
pane: &mut Self,
window: &mut Window,
cx: &mut Context<Self>| {
if allow_preview {
pane.set_preview_item_id(Some(new_item.item_id()), cx);
}
pane.add_item_inner(
new_item,
true,
focus_item,
activate,
destination_index,
window,
cx,
);
};
if let Some((index, existing_item)) = existing_item {
set_up_existing_item(index, self, window, cx);
existing_item
} else {
// If the item is being opened as preview and we have an existing preview tab,
// open the new item in the position of the existing preview tab.
let destination_index = if allow_preview {
self.close_current_preview_item(window, cx)
} else {
suggested_position
};
let new_item = build_item(self, window, cx);
// A special case that won't ever get a `project_entry_id` but has to be deduplicated nonetheless.
if let Some(invalid_buffer_view) = new_item.downcast::<InvalidBufferView>() {
let mut already_open_view = None;
let mut views_to_close = HashSet::default();
for existing_error_view in self
.items_of_type::<InvalidBufferView>()
.filter(|item| item.read(cx).abs_path == invalid_buffer_view.read(cx).abs_path)
{
if already_open_view.is_none()
&& existing_error_view.read(cx).error == invalid_buffer_view.read(cx).error
{
already_open_view = Some(existing_error_view);
} else {
views_to_close.insert(existing_error_view.item_id());
}
}
let resulting_item = match already_open_view {
Some(already_open_view) => {
if let Some(index) = self.index_for_item_id(already_open_view.item_id()) {
set_up_existing_item(index, self, window, cx);
}
Box::new(already_open_view) as Box<_>
}
None => {
set_up_new_item(new_item.clone(), destination_index, self, window, cx);
new_item
}
};
self.close_items(window, cx, SaveIntent::Skip, |existing_item| {
views_to_close.contains(&existing_item)
})
.detach();
resulting_item
} else {
set_up_new_item(new_item.clone(), destination_index, self, window, cx);
new_item
}
}
}
pub fn close_current_preview_item(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<usize> {
let item_idx = self.preview_item_idx()?;
let id = self.preview_item_id()?;
let prev_active_item_index = self.active_item_index;
self.remove_item(id, false, false, window, cx);
self.active_item_index = prev_active_item_index;
if item_idx < self.items.len() {
Some(item_idx)
} else {
None
}
}
pub fn add_item_inner(
&mut self,
item: Box<dyn ItemHandle>,
activate_pane: bool,
focus_item: bool,
activate: bool,
destination_index: Option<usize>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let item_already_exists = self
.items
.iter()
.any(|existing_item| existing_item.item_id() == item.item_id());
if !item_already_exists {
self.close_items_on_item_open(window, cx);
}
if item.is_singleton(cx)
&& let Some(&entry_id) = item.project_entry_ids(cx).first()
{
let Some(project) = self.project.upgrade() else {
return;
};
let project = project.read(cx);
if let Some(project_path) = project.path_for_entry(entry_id, cx) {
let abs_path = project.absolute_path(&project_path, cx);
self.nav_history
.0
.lock()
.paths_by_item
.insert(item.item_id(), (project_path, abs_path));
}
}
// If no destination index is specified, add or move the item after the
// active item (or at the start of tab bar, if the active item is pinned)
let mut insertion_index = {
cmp::min(
if let Some(destination_index) = destination_index {
destination_index
} else {
cmp::max(self.active_item_index + 1, self.pinned_count())
},
self.items.len(),
)
};
// Does the item already exist?
let project_entry_id = if item.is_singleton(cx) {
item.project_entry_ids(cx).first().copied()
} else {
None
};
let existing_item_index = self.items.iter().position(|existing_item| {
if existing_item.item_id() == item.item_id() {
true
} else if existing_item.is_singleton(cx) {
existing_item
.project_entry_ids(cx)
.first()
.is_some_and(|existing_entry_id| {
Some(existing_entry_id) == project_entry_id.as_ref()
})
} else {
false
}
});
if let Some(existing_item_index) = existing_item_index {
// If the item already exists, move it to the desired destination and activate it
if existing_item_index != insertion_index {
let existing_item_is_active = existing_item_index == self.active_item_index;
// If the caller didn't specify a destination and the added item is already
// the active one, don't move it
if existing_item_is_active && destination_index.is_none() {
insertion_index = existing_item_index;
} else {
self.items.remove(existing_item_index);
if existing_item_index < self.active_item_index {
self.active_item_index -= 1;
}
insertion_index = insertion_index.min(self.items.len());
self.items.insert(insertion_index, item.clone());
if existing_item_is_active {
self.active_item_index = insertion_index;
} else if insertion_index <= self.active_item_index {
self.active_item_index += 1;
}
}
cx.notify();
}
if activate {
self.activate_item(insertion_index, activate_pane, focus_item, window, cx);
}
} else {
self.items.insert(insertion_index, item.clone());
if activate {
if insertion_index <= self.active_item_index
&& self.preview_item_idx() != Some(self.active_item_index)
{
self.active_item_index += 1;
}
self.activate_item(insertion_index, activate_pane, focus_item, window, cx);
}
cx.notify();
}
cx.emit(Event::AddItem { item });
}
pub fn add_item(
&mut self,
item: Box<dyn ItemHandle>,
activate_pane: bool,
focus_item: bool,
destination_index: Option<usize>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.add_item_inner(
item,
activate_pane,
focus_item,
true,
destination_index,
window,
cx,
)
}
pub fn items_len(&self) -> usize {
self.items.len()
}
pub fn items(&self) -> impl DoubleEndedIterator<Item = &Box<dyn ItemHandle>> {
self.items.iter()
}
pub fn items_of_type<T: Render>(&self) -> impl '_ + Iterator<Item = Entity<T>> {
self.items
.iter()
.filter_map(|item| item.to_any().downcast().ok())
}
pub fn active_item(&self) -> Option<Box<dyn ItemHandle>> {
self.items.get(self.active_item_index).cloned()
}
fn active_item_id(&self) -> EntityId {
self.items[self.active_item_index].item_id()
}
pub fn pixel_position_of_cursor(&self, cx: &App) -> Option<Point<Pixels>> {
self.items
.get(self.active_item_index)?
.pixel_position_of_cursor(cx)
}
pub fn item_for_entry(
&self,
entry_id: ProjectEntryId,
cx: &App,
) -> Option<Box<dyn ItemHandle>> {
self.items.iter().find_map(|item| {
if item.is_singleton(cx) && (item.project_entry_ids(cx).as_slice() == [entry_id]) {
Some(item.boxed_clone())
} else {
None
}
})
}
pub fn item_for_path(
&self,
project_path: ProjectPath,
cx: &App,
) -> Option<Box<dyn ItemHandle>> {
self.items.iter().find_map(move |item| {
if item.is_singleton(cx) && (item.project_path(cx).as_slice() == [project_path.clone()])
{
Some(item.boxed_clone())
} else {
None
}
})
}
pub fn index_for_item(&self, item: &dyn ItemHandle) -> Option<usize> {
self.index_for_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> {
self.items.get(ix).map(|i| i.as_ref())
}
pub fn toggle_zoom(&mut self, _: &ToggleZoom, window: &mut Window, cx: &mut Context<Self>) {
if !self.can_toggle_zoom {
cx.propagate();
} else if self.zoomed {
cx.emit(Event::ZoomOut);
} else if !self.items.is_empty() {
if !self.focus_handle.contains_focused(window, cx) {
cx.focus_self(window);
}
cx.emit(Event::ZoomIn);
}
}
pub fn activate_item(
&mut self,
index: usize,
activate_pane: bool,
focus_item: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
use NavigationMode::{GoingBack, GoingForward};
if index < self.items.len() {
let prev_active_item_ix = mem::replace(&mut self.active_item_index, index);
if (prev_active_item_ix != self.active_item_index
|| matches!(self.nav_history.mode(), GoingBack | GoingForward))
&& let Some(prev_item) = self.items.get(prev_active_item_ix)
{
prev_item.deactivated(window, cx);
}
self.update_history(index);
self.update_toolbar(window, cx);
self.update_status_bar(window, cx);
if focus_item {
self.focus_active_item(window, cx);
}
cx.emit(Event::ActivateItem {
local: activate_pane,
focus_changed: focus_item,
});
if !self.is_tab_pinned(index) {
self.tab_bar_scroll_handle
.scroll_to_item(index - self.pinned_tab_count);
}
cx.notify();
}
}
fn update_history(&mut self, index: usize) {
if let Some(newly_active_item) = self.items.get(index) {
self.activation_history
.retain(|entry| entry.entity_id != newly_active_item.item_id());
self.activation_history.push(ActivationHistoryEntry {
entity_id: newly_active_item.item_id(),
timestamp: self
.next_activation_timestamp
.fetch_add(1, Ordering::SeqCst),
});
}
}
pub fn activate_prev_item(
&mut self,
activate_pane: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
let mut index = self.active_item_index;
if index > 0 {
index -= 1;
} else if !self.items.is_empty() {
index = self.items.len() - 1;
}
self.activate_item(index, activate_pane, activate_pane, window, cx);
}
pub fn activate_next_item(
&mut self,
activate_pane: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
let mut index = self.active_item_index;
if index + 1 < self.items.len() {
index += 1;
} else {
index = 0;
}
self.activate_item(index, activate_pane, activate_pane, window, cx);
}
pub fn swap_item_left(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let index = self.active_item_index;
if index == 0 {
return;
}
self.items.swap(index, index - 1);
self.activate_item(index - 1, true, true, window, cx);
}
pub fn swap_item_right(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let index = self.active_item_index;
if index + 1 == self.items.len() {
return;
}
self.items.swap(index, index + 1);
self.activate_item(index + 1, true, true, window, cx);
}
pub fn close_active_item(
&mut self,
action: &CloseActiveItem,
window: &mut Window,
cx: &mut Context<Self>,
) -> Task<Result<()>> {
if self.items.is_empty() {
// Close the window when there's no active items to close, if configured
if WorkspaceSettings::get_global(cx)
.when_closing_with_no_tabs
.should_close()
{
window.dispatch_action(Box::new(CloseWindow), cx);
}
return Task::ready(Ok(()));
}
if self.is_tab_pinned(self.active_item_index) && !action.close_pinned {
// Activate any non-pinned tab in same pane
let non_pinned_tab_index = self
.items()
.enumerate()
.find(|(index, _item)| !self.is_tab_pinned(*index))
.map(|(index, _item)| index);
if let Some(index) = non_pinned_tab_index {
self.activate_item(index, false, false, window, cx);
return Task::ready(Ok(()));
}
// Activate any non-pinned tab in different pane
let current_pane = cx.entity();
self.workspace
.update(cx, |workspace, cx| {
let panes = workspace.center.panes();
let pane_with_unpinned_tab = panes.iter().find(|pane| {
if **pane == &current_pane {
return false;
}
pane.read(cx).has_unpinned_tabs()
});
if let Some(pane) = pane_with_unpinned_tab {
pane.update(cx, |pane, cx| pane.activate_unpinned_tab(window, cx));
}
})
.ok();
return Task::ready(Ok(()));
};
let active_item_id = self.active_item_id();
self.close_item_by_id(
active_item_id,
action.save_intent.unwrap_or(SaveIntent::Close),
window,
cx,
)
}
pub fn close_item_by_id(
&mut self,
item_id_to_close: EntityId,
save_intent: SaveIntent,
window: &mut Window,
cx: &mut Context<Self>,
) -> Task<Result<()>> {
self.close_items(window, cx, save_intent, move |view_id| {
view_id == item_id_to_close
})
}
pub fn close_other_items(
&mut self,
action: &CloseOtherItems,
target_item_id: Option<EntityId>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Task<Result<()>> {
if self.items.is_empty() {
return Task::ready(Ok(()));
}
let active_item_id = match target_item_id {
Some(result) => result,
None => self.active_item_id(),
};
let pinned_item_ids = self.pinned_item_ids();
self.close_items(
window,
cx,
action.save_intent.unwrap_or(SaveIntent::Close),
move |item_id| {
item_id != active_item_id
&& (action.close_pinned || !pinned_item_ids.contains(&item_id))
},
)
}
pub fn close_clean_items(
&mut self,
action: &CloseCleanItems,
window: &mut Window,
cx: &mut Context<Self>,
) -> Task<Result<()>> {
if self.items.is_empty() {
return Task::ready(Ok(()));
}
let clean_item_ids = self.clean_item_ids(cx);
let pinned_item_ids = self.pinned_item_ids();
self.close_items(window, cx, SaveIntent::Close, move |item_id| {
clean_item_ids.contains(&item_id)
&& (action.close_pinned || !pinned_item_ids.contains(&item_id))
})
}
pub fn close_items_to_the_left_by_id(
&mut self,
item_id: Option<EntityId>,
action: &CloseItemsToTheLeft,
window: &mut Window,
cx: &mut Context<Self>,
) -> Task<Result<()>> {
self.close_items_to_the_side_by_id(item_id, Side::Left, action.close_pinned, window, cx)
}
pub fn close_items_to_the_right_by_id(
&mut self,
item_id: Option<EntityId>,
action: &CloseItemsToTheRight,
window: &mut Window,
cx: &mut Context<Self>,
) -> Task<Result<()>> {
self.close_items_to_the_side_by_id(item_id, Side::Right, action.close_pinned, window, cx)
}
pub fn close_items_to_the_side_by_id(
&mut self,
item_id: Option<EntityId>,
side: Side,
close_pinned: bool,
window: &mut Window,
cx: &mut Context<Self>,
) -> Task<Result<()>> {
if self.items.is_empty() {
return Task::ready(Ok(()));
}
let item_id = item_id.unwrap_or_else(|| self.active_item_id());
let to_the_side_item_ids = self.to_the_side_item_ids(item_id, side);
let pinned_item_ids = self.pinned_item_ids();
self.close_items(window, cx, SaveIntent::Close, move |item_id| {
to_the_side_item_ids.contains(&item_id)
&& (close_pinned || !pinned_item_ids.contains(&item_id))
})
}
pub fn close_all_items(
&mut self,
action: &CloseAllItems,
window: &mut Window,
cx: &mut Context<Self>,
) -> Task<Result<()>> {
if self.items.is_empty() {
return Task::ready(Ok(()));
}
let pinned_item_ids = self.pinned_item_ids();
self.close_items(
window,
cx,
action.save_intent.unwrap_or(SaveIntent::Close),
|item_id| action.close_pinned || !pinned_item_ids.contains(&item_id),
)
}
fn close_items_on_item_open(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let target = self.max_tabs.map(|m| m.get());
let protect_active_item = false;
self.close_items_to_target_count(target, protect_active_item, window, cx);
}
fn close_items_on_settings_change(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let target = self.max_tabs.map(|m| m.get() + 1);
// The active item in this case is the settings.json file, which should be protected from being closed
let protect_active_item = true;
self.close_items_to_target_count(target, protect_active_item, window, cx);
}
fn close_items_to_target_count(
&mut self,
target_count: Option<usize>,
protect_active_item: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(target_count) = target_count else {
return;
};
let mut index_list = Vec::new();
let mut items_len = self.items_len();
let mut indexes: HashMap<EntityId, usize> = HashMap::default();
let active_ix = self.active_item_index();
for (index, item) in self.items.iter().enumerate() {
indexes.insert(item.item_id(), index);
}
// Close least recently used items to reach target count.
// The target count is allowed to be exceeded, as we protect pinned
// items, dirty items, and sometimes, the active item.
for entry in self.activation_history.iter() {
if items_len < target_count {
break;
}
let Some(&index) = indexes.get(&entry.entity_id) else {
continue;
};
if protect_active_item && index == active_ix {
continue;
}
if let Some(true) = self.items.get(index).map(|item| item.is_dirty(cx)) {
continue;
}
if self.is_tab_pinned(index) {
continue;
}
index_list.push(index);
items_len -= 1;
}
// The sort and reverse is necessary since we remove items
// using their index position, hence removing from the end
// of the list first to avoid changing indexes.
index_list.sort_unstable();
index_list
.iter()
.rev()
.for_each(|&index| self._remove_item(index, false, false, None, window, cx));
}
// Usually when you close an item that has unsaved changes, we prompt you to
// save it. That said, if you still have the buffer open in a different pane
// we can close this one without fear of losing data.
pub fn skip_save_on_close(item: &dyn ItemHandle, workspace: &Workspace, cx: &App) -> bool {
let mut dirty_project_item_ids = Vec::new();
item.for_each_project_item(cx, &mut |project_item_id, project_item| {
if project_item.is_dirty() {
dirty_project_item_ids.push(project_item_id);
}
});
if dirty_project_item_ids.is_empty() {
return !(item.is_singleton(cx) && item.is_dirty(cx));
}
for open_item in workspace.items(cx) {
if open_item.item_id() == item.item_id() {
continue;
}
if !open_item.is_singleton(cx) {
continue;
}
let other_project_item_ids = open_item.project_item_model_ids(cx);
dirty_project_item_ids.retain(|id| !other_project_item_ids.contains(id));
}
dirty_project_item_ids.is_empty()
}
pub(super) fn file_names_for_prompt(
items: &mut dyn Iterator<Item = &Box<dyn ItemHandle>>,
cx: &App,
) -> String {
let mut file_names = BTreeSet::default();
for item in items {
item.for_each_project_item(cx, &mut |_, project_item| {
if !project_item.is_dirty() {
return;
}
let filename = project_item.project_path(cx).and_then(|path| {
path.path
.file_name()
.and_then(|name| name.to_str().map(ToOwned::to_owned))
});
file_names.insert(filename.unwrap_or("untitled".to_string()));
});
}
if file_names.len() > 6 {
format!(
"{}\n.. and {} more",
file_names.iter().take(5).join("\n"),
file_names.len() - 5
)
} else {
file_names.into_iter().join("\n")
}
}
pub fn close_items(
&self,
window: &mut Window,
cx: &mut Context<Pane>,
mut save_intent: SaveIntent,
should_close: impl Fn(EntityId) -> bool,
) -> Task<Result<()>> {
// Find the items to close.
let mut items_to_close = Vec::new();
for item in &self.items {
if should_close(item.item_id()) {
items_to_close.push(item.boxed_clone());
}
}
let active_item_id = self.active_item().map(|item| item.item_id());
items_to_close.sort_by_key(|item| {
let path = item.project_path(cx);
// Put the currently active item at the end, because if the currently active item is not closed last
// closing the currently active item will cause the focus to switch to another item
// This will cause Zed to expand the content of the currently active item
//
// Beyond that sort in order of project path, with untitled files and multibuffers coming last.
(active_item_id == Some(item.item_id()), path.is_none(), path)
});
let workspace = self.workspace.clone();
let Some(project) = self.project.upgrade() else {
return Task::ready(Ok(()));
};
cx.spawn_in(window, async move |pane, cx| {
let dirty_items = workspace.update(cx, |workspace, cx| {
items_to_close
.iter()
.filter(|item| {
item.is_dirty(cx) && !Self::skip_save_on_close(item.as_ref(), workspace, cx)
})
.map(|item| item.boxed_clone())
.collect::<Vec<_>>()
})?;
if save_intent == SaveIntent::Close && dirty_items.len() > 1 {
let answer = pane.update_in(cx, |_, window, cx| {
let detail = Self::file_names_for_prompt(&mut dirty_items.iter(), cx);
window.prompt(
PromptLevel::Warning,
"Do you want to save changes to the following files?",
Some(&detail),
&["Save all", "Discard all", "Cancel"],
cx,
)
})?;
match answer.await {
Ok(0) => save_intent = SaveIntent::SaveAll,
Ok(1) => save_intent = SaveIntent::Skip,
Ok(2) => return Ok(()),
_ => {}
}
}
for item_to_close in items_to_close {
let mut should_save = true;
if save_intent == SaveIntent::Close {
workspace.update(cx, |workspace, cx| {
if Self::skip_save_on_close(item_to_close.as_ref(), workspace, cx) {
should_save = false;
}
})?;
}
if should_save {
match Self::save_item(project.clone(), &pane, &*item_to_close, save_intent, cx)
.await
{
Ok(success) => {
if !success {
break;
}
}
Err(err) => {
let answer = pane.update_in(cx, |_, window, cx| {
let detail = Self::file_names_for_prompt(
&mut [&item_to_close].into_iter(),
cx,
);
window.prompt(
PromptLevel::Warning,
&format!("Unable to save file: {}", &err),
Some(&detail),
&["Close Without Saving", "Cancel"],
cx,
)
})?;
match answer.await {
Ok(0) => {}
Ok(1..) | Err(_) => break,
}
}
}
}
// Remove the item from the pane.
pane.update_in(cx, |pane, window, cx| {
pane.remove_item(
item_to_close.item_id(),
false,
pane.close_pane_if_empty,
window,
cx,
);
})
.ok();
}
pane.update(cx, |_, cx| cx.notify()).ok();
Ok(())
})
}
pub fn remove_item(
&mut self,
item_id: EntityId,
activate_pane: bool,
close_pane_if_empty: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(item_index) = self.index_for_item_id(item_id) else {
return;
};
self._remove_item(
item_index,
activate_pane,
close_pane_if_empty,
None,
window,
cx,
)
}
pub fn remove_item_and_focus_on_pane(
&mut self,
item_index: usize,
activate_pane: bool,
focus_on_pane_if_closed: Entity<Pane>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self._remove_item(
item_index,
activate_pane,
true,
Some(focus_on_pane_if_closed),
window,
cx,
)
}
fn _remove_item(
&mut self,
item_index: usize,
activate_pane: bool,
close_pane_if_empty: bool,
focus_on_pane_if_closed: Option<Entity<Pane>>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let activate_on_close = &ItemSettings::get_global(cx).activate_on_close;
self.activation_history
.retain(|entry| entry.entity_id != self.items[item_index].item_id());
if self.is_tab_pinned(item_index) {
self.pinned_tab_count -= 1;
}
if item_index == self.active_item_index {
let left_neighbour_index = || item_index.min(self.items.len()).saturating_sub(1);
let index_to_activate = match activate_on_close {
ActivateOnClose::History => self
.activation_history
.pop()
.and_then(|last_activated_item| {
self.items.iter().enumerate().find_map(|(index, item)| {
(item.item_id() == last_activated_item.entity_id).then_some(index)
})
})
// We didn't have a valid activation history entry, so fallback
// to activating the item to the left
.unwrap_or_else(left_neighbour_index),
ActivateOnClose::Neighbour => {
self.activation_history.pop();
if item_index + 1 < self.items.len() {
item_index + 1
} else {
item_index.saturating_sub(1)
}
}
ActivateOnClose::LeftNeighbour => {
self.activation_history.pop();
left_neighbour_index()
}
};
let should_activate = activate_pane || self.has_focus(window, cx);
if self.items.len() == 1 && should_activate {
self.focus_handle.focus(window);
} else {
self.activate_item(
index_to_activate,
should_activate,
should_activate,
window,
cx,
);
}
}
let item = self.items.remove(item_index);
cx.emit(Event::RemovedItem { item: item.clone() });
if self.items.is_empty() {
item.deactivated(window, cx);
if close_pane_if_empty {
self.update_toolbar(window, cx);
cx.emit(Event::Remove {
focus_on_pane: focus_on_pane_if_closed,
});
}
}
if item_index < self.active_item_index {
self.active_item_index -= 1;
}
let mode = self.nav_history.mode();
self.nav_history.set_mode(NavigationMode::ClosingItem);
item.deactivated(window, cx);
item.on_removed(cx);
self.nav_history.set_mode(mode);
if self.is_active_preview_item(item.item_id()) {
self.set_preview_item_id(None, cx);
}
if let Some(path) = item.project_path(cx) {
let abs_path = self
.nav_history
.0
.lock()
.paths_by_item
.get(&item.item_id())
.and_then(|(_, abs_path)| abs_path.clone());
self.nav_history
.0
.lock()
.paths_by_item
.insert(item.item_id(), (path, abs_path));
} else {
self.nav_history
.0
.lock()
.paths_by_item
.remove(&item.item_id());
}
if self.zoom_out_on_close && self.items.is_empty() && close_pane_if_empty && self.zoomed {
cx.emit(Event::ZoomOut);
}
cx.notify();
}
pub async fn save_item(
project: Entity<Project>,
pane: &WeakEntity<Pane>,
item: &dyn ItemHandle,
save_intent: SaveIntent,
cx: &mut AsyncWindowContext,
) -> Result<bool> {
const CONFLICT_MESSAGE: &str = "This file has changed on disk since you started editing it. Do you want to overwrite it?";
const DELETED_MESSAGE: &str = "This file has been deleted on disk since you started editing it. Do you want to recreate it?";
if save_intent == SaveIntent::Skip {
return Ok(true);
}
let Some(item_ix) = pane
.read_with(cx, |pane, _| pane.index_for_item(item))
.ok()
.flatten()
else {
return Ok(true);
};
let (
mut has_conflict,
mut is_dirty,
mut can_save,
can_save_as,
is_singleton,
has_deleted_file,
) = cx.update(|_window, cx| {
(
item.has_conflict(cx),
item.is_dirty(cx),
item.can_save(cx),
item.can_save_as(cx),
item.is_singleton(cx),
item.has_deleted_file(cx),
)
})?;
// when saving a single buffer, we ignore whether or not it's dirty.
if save_intent == SaveIntent::Save || save_intent == SaveIntent::SaveWithoutFormat {
is_dirty = true;
}
if save_intent == SaveIntent::SaveAs {
is_dirty = true;
has_conflict = false;
can_save = false;
}
if save_intent == SaveIntent::Overwrite {
has_conflict = false;
}
let should_format = save_intent != SaveIntent::SaveWithoutFormat;
if has_conflict && can_save {
if has_deleted_file && is_singleton {
let answer = pane.update_in(cx, |pane, window, cx| {
pane.activate_item(item_ix, true, true, window, cx);
window.prompt(
PromptLevel::Warning,
DELETED_MESSAGE,
None,
&["Save", "Close", "Cancel"],
cx,
)
})?;
match answer.await {
Ok(0) => {
pane.update_in(cx, |_, window, cx| {
item.save(
SaveOptions {
format: should_format,
autosave: false,
},
project,
window,
cx,
)
})?
.await?
}
Ok(1) => {
pane.update_in(cx, |pane, window, cx| {
pane.remove_item(item.item_id(), false, true, window, cx)
})?;
}
_ => return Ok(false),
}
return Ok(true);
} else {
let answer = pane.update_in(cx, |pane, window, cx| {
pane.activate_item(item_ix, true, true, window, cx);
window.prompt(
PromptLevel::Warning,
CONFLICT_MESSAGE,
None,
&["Overwrite", "Discard", "Cancel"],
cx,
)
})?;
match answer.await {
Ok(0) => {
pane.update_in(cx, |_, window, cx| {
item.save(
SaveOptions {
format: should_format,
autosave: false,
},
project,
window,
cx,
)
})?
.await?
}
Ok(1) => {
pane.update_in(cx, |_, window, cx| item.reload(project, window, cx))?
.await?
}
_ => return Ok(false),
}
}
} else if is_dirty && (can_save || can_save_as) {
if save_intent == SaveIntent::Close {
let will_autosave = cx.update(|_window, cx| {
matches!(
item.workspace_settings(cx).autosave,
AutosaveSetting::OnFocusChange | AutosaveSetting::OnWindowChange
) && item.can_autosave(cx)
})?;
if !will_autosave {
let item_id = item.item_id();
let answer_task = pane.update_in(cx, |pane, window, cx| {
if pane.save_modals_spawned.insert(item_id) {
pane.activate_item(item_ix, true, true, window, cx);
let prompt = dirty_message_for(item.project_path(cx));
Some(window.prompt(
PromptLevel::Warning,
&prompt,
None,
&["Save", "Don't Save", "Cancel"],
cx,
))
} else {
None
}
})?;
if let Some(answer_task) = answer_task {
let answer = answer_task.await;
pane.update(cx, |pane, _| {
if !pane.save_modals_spawned.remove(&item_id) {
debug_panic!(
"save modal was not present in spawned modals after awaiting for its answer"
)
}
})?;
match answer {
Ok(0) => {}
Ok(1) => {
// Don't save this file
pane.update_in(cx, |pane, window, cx| {
if pane.is_tab_pinned(item_ix) && !item.can_save(cx) {
pane.pinned_tab_count -= 1;
}
item.discarded(project, window, cx)
})
.log_err();
return Ok(true);
}
_ => return Ok(false), // Cancel
}
} else {
return Ok(false);
}
}
}
if can_save {
pane.update_in(cx, |pane, window, cx| {
if pane.is_active_preview_item(item.item_id()) {
pane.set_preview_item_id(None, cx);
}
item.save(
SaveOptions {
format: should_format,
autosave: false,
},
project,
window,
cx,
)
})?
.await?;
} else if can_save_as && is_singleton {
let suggested_name =
cx.update(|_window, cx| item.suggested_filename(cx).to_string())?;
let new_path = pane.update_in(cx, |pane, window, cx| {
pane.activate_item(item_ix, true, true, window, cx);
pane.workspace.update(cx, |workspace, cx| {
let lister = if workspace.project().read(cx).is_local() {
DirectoryLister::Local(
workspace.project().clone(),
workspace.app_state().fs.clone(),
)
} else {
DirectoryLister::Project(workspace.project().clone())
};
workspace.prompt_for_new_path(lister, Some(suggested_name), window, cx)
})
})??;
let Some(new_path) = new_path.await.ok().flatten().into_iter().flatten().next()
else {
return Ok(false);
};
let project_path = pane
.update(cx, |pane, cx| {
pane.project
.update(cx, |project, cx| {
project.find_or_create_worktree(new_path, true, cx)
})
.ok()
})
.ok()
.flatten();
let save_task = if let Some(project_path) = project_path {
let (worktree, path) = project_path.await?;
let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id())?;
let new_path = ProjectPath {
worktree_id,
path: path.into(),
};
pane.update_in(cx, |pane, window, cx| {
if let Some(item) = pane.item_for_path(new_path.clone(), cx) {
pane.remove_item(item.item_id(), false, false, window, cx);
}
item.save_as(project, new_path, window, cx)
})?
} else {
return Ok(false);
};
save_task.await?;
return Ok(true);
}
}
pane.update(cx, |_, cx| {
cx.emit(Event::UserSavedItem {
item: item.downgrade_item(),
save_intent,
});
true
})
}
pub fn autosave_item(
item: &dyn ItemHandle,
project: Entity<Project>,
window: &mut Window,
cx: &mut App,
) -> Task<Result<()>> {
let format = !matches!(
item.workspace_settings(cx).autosave,
AutosaveSetting::AfterDelay { .. }
);
if item.can_autosave(cx) {
item.save(
SaveOptions {
format,
autosave: true,
},
project,
window,
cx,
)
} else {
Task::ready(Ok(()))
}
}
pub fn focus_active_item(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if let Some(active_item) = self.active_item() {
let focus_handle = active_item.item_focus_handle(cx);
window.focus(&focus_handle);
}
}
pub fn split(&mut self, direction: SplitDirection, cx: &mut Context<Self>) {
cx.emit(Event::Split(direction));
}
pub fn toolbar(&self) -> &Entity<Toolbar> {
&self.toolbar
}
pub fn handle_deleted_project_item(
&mut self,
entry_id: ProjectEntryId,
window: &mut Window,
cx: &mut Context<Pane>,
) -> Option<()> {
let item_id = self.items().find_map(|item| {
if item.is_singleton(cx) && item.project_entry_ids(cx).as_slice() == [entry_id] {
Some(item.item_id())
} else {
None
}
})?;
self.remove_item(item_id, false, true, window, cx);
self.nav_history.remove_item(item_id);
Some(())
}
fn update_toolbar(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let active_item = self
.items
.get(self.active_item_index)
.map(|item| item.as_ref());
self.toolbar.update(cx, |toolbar, cx| {
toolbar.set_active_item(active_item, window, cx);
});
}
fn update_status_bar(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let workspace = self.workspace.clone();
let pane = cx.entity();
window.defer(cx, move |window, cx| {
let Ok(status_bar) =
workspace.read_with(cx, |workspace, _| workspace.status_bar.clone())
else {
return;
};
status_bar.update(cx, move |status_bar, cx| {
status_bar.set_active_pane(&pane, window, cx);
});
});
}
fn entry_abs_path(&self, entry: ProjectEntryId, cx: &App) -> Option<PathBuf> {
let worktree = self
.workspace
.upgrade()?
.read(cx)
.project()
.read(cx)
.worktree_for_entry(entry, cx)?
.read(cx);
let entry = worktree.entry_for_id(entry)?;
match &entry.canonical_path {
Some(canonical_path) => Some(canonical_path.to_path_buf()),
None => worktree.absolutize(&entry.path).ok(),
}
}
pub fn icon_color(selected: bool) -> Color {
if selected {
Color::Default
} else {
Color::Muted
}
}
fn toggle_pin_tab(&mut self, _: &TogglePinTab, window: &mut Window, cx: &mut Context<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, window, cx);
} else {
self.pin_tab_at(active_tab_ix, window, cx);
}
}
fn unpin_all_tabs(&mut self, _: &UnpinAllTabs, window: &mut Window, cx: &mut Context<Self>) {
if self.items.is_empty() {
return;
}
let pinned_item_ids = self.pinned_item_ids().into_iter().rev();
for pinned_item_id in pinned_item_ids {
if let Some(ix) = self.index_for_item_id(pinned_item_id) {
self.unpin_tab_at(ix, window, cx);
}
}
}
fn pin_tab_at(&mut self, ix: usize, window: &mut Window, cx: &mut Context<Self>) {
self.change_tab_pin_state(ix, PinOperation::Pin, window, cx);
}
fn unpin_tab_at(&mut self, ix: usize, window: &mut Window, cx: &mut Context<Self>) {
self.change_tab_pin_state(ix, PinOperation::Unpin, window, cx);
}
fn change_tab_pin_state(
&mut self,
ix: usize,
operation: PinOperation,
window: &mut Window,
cx: &mut Context<Self>,
) {
maybe!({
let pane = cx.entity();
let destination_index = match operation {
PinOperation::Pin => self.pinned_tab_count.min(ix),
PinOperation::Unpin => self.pinned_tab_count.checked_sub(1)?,
};
let id = self.item_for_index(ix)?.item_id();
let should_activate = ix == self.active_item_index;
if matches!(operation, PinOperation::Pin) && self.is_active_preview_item(id) {
self.set_preview_item_id(None, cx);
}
match operation {
PinOperation::Pin => self.pinned_tab_count += 1,
PinOperation::Unpin => self.pinned_tab_count -= 1,
}
if ix == destination_index {
cx.notify();
} else {
self.workspace
.update(cx, |_, cx| {
cx.defer_in(window, move |_, window, cx| {
move_item(
&pane,
&pane,
id,
destination_index,
should_activate,
window,
cx,
);
});
})
.ok()?;
}
let event = match operation {
PinOperation::Pin => Event::ItemPinned,
PinOperation::Unpin => Event::ItemUnpinned,
};
cx.emit(event);
Some(())
});
}
fn is_tab_pinned(&self, ix: usize) -> bool {
self.pinned_tab_count > ix
}
fn has_unpinned_tabs(&self) -> bool {
self.pinned_tab_count < self.items.len()
}
fn activate_unpinned_tab(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.items.is_empty() {
return;
}
let Some(index) = self
.items()
.enumerate()
.find_map(|(index, _item)| (!self.is_tab_pinned(index)).then_some(index))
else {
return;
};
self.activate_item(index, true, true, window, cx);
}
fn render_tab(
&self,
ix: usize,
item: &dyn ItemHandle,
detail: usize,
focus_handle: &FocusHandle,
window: &mut Window,
cx: &mut Context<Pane>,
) -> impl IntoElement + use<> {
let is_active = ix == self.active_item_index;
let is_preview = self
.preview_item_id
.map(|id| id == item.item_id())
.unwrap_or(false);
let label = item.tab_content(
TabContentParams {
detail: Some(detail),
selected: is_active,
preview: is_preview,
deemphasized: !self.has_focus(window, cx),
},
window,
cx,
);
let item_diagnostic = item
.project_path(cx)
.map_or(None, |project_path| self.diagnostics.get(&project_path));
let decorated_icon = item_diagnostic.map_or(None, |diagnostic| {
let icon = match item.tab_icon(window, cx) {
Some(icon) => icon,
None => return None,
};
let knockout_item_color = if is_active {
cx.theme().colors().tab_active_background
} else {
cx.theme().colors().tab_bar_background
};
let (icon_decoration, icon_color) = if matches!(diagnostic, &DiagnosticSeverity::ERROR)
{
(IconDecorationKind::X, Color::Error)
} else {
(IconDecorationKind::Triangle, Color::Warning)
};
Some(DecoratedIcon::new(
icon.size(IconSize::Small).color(Color::Muted),
Some(
IconDecoration::new(icon_decoration, knockout_item_color, cx)
.color(icon_color.color(cx))
.position(Point {
x: px(-2.),
y: px(-2.),
}),
),
))
});
let icon = if decorated_icon.is_none() {
match item_diagnostic {
Some(&DiagnosticSeverity::ERROR) => None,
Some(&DiagnosticSeverity::WARNING) => None,
_ => item
.tab_icon(window, cx)
.map(|icon| icon.color(Color::Muted)),
}
.map(|icon| icon.size(IconSize::Small))
} else {
None
};
let settings = ItemSettings::get_global(cx);
let close_side = &settings.close_position;
let show_close_button = &settings.show_close_button;
let indicator = render_item_indicator(item.boxed_clone(), cx);
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)
.position(if is_first_item {
TabPosition::First
} else if is_last_item {
TabPosition::Last
} else {
TabPosition::Middle(position_relative_to_active_item)
})
.close_side(match close_side {
ClosePosition::Left => ui::TabCloseSide::Start,
ClosePosition::Right => ui::TabCloseSide::End,
})
.toggle_state(is_active)
.on_click(cx.listener(move |pane: &mut Self, _, window, cx| {
pane.activate_item(ix, true, true, window, cx)
}))
// TODO: This should be a click listener with the middle mouse button instead of a mouse down listener.
.on_mouse_down(
MouseButton::Middle,
cx.listener(move |pane, _event, window, cx| {
pane.close_item_by_id(item_id, SaveIntent::Close, window, cx)
.detach_and_log_err(cx);
}),
)
.on_mouse_down(
MouseButton::Left,
cx.listener(move |pane, event: &MouseDownEvent, _, cx| {
if let Some(id) = pane.preview_item_id
&& id == item_id
&& event.click_count > 1
{
pane.set_preview_item_id(None, cx);
}
}),
)
.on_drag(
DraggedTab {
item: item.boxed_clone(),
pane: cx.entity(),
detail,
is_active,
ix,
},
|tab, _, _, cx| cx.new(|_| tab.clone()),
)
.drag_over::<DraggedTab>(move |tab, dragged_tab: &DraggedTab, _, cx| {
let mut styled_tab = tab
.bg(cx.theme().colors().drop_target_background)
.border_color(cx.theme().colors().drop_target_border)
.border_0();
if ix < dragged_tab.ix {
styled_tab = styled_tab.border_l_2();
} else if ix > dragged_tab.ix {
styled_tab = styled_tab.border_r_2();
}
styled_tab
})
.drag_over::<DraggedSelection>(|tab, _, _, cx| {
tab.bg(cx.theme().colors().drop_target_background)
})
.when_some(self.can_drop_predicate.clone(), |this, p| {
this.can_drop(move |a, window, cx| p(a, window, cx))
})
.on_drop(
cx.listener(move |this, dragged_tab: &DraggedTab, window, cx| {
this.drag_split_direction = None;
this.handle_tab_drop(dragged_tab, ix, window, cx)
}),
)
.on_drop(
cx.listener(move |this, selection: &DraggedSelection, window, cx| {
this.drag_split_direction = None;
this.handle_dragged_selection_drop(selection, Some(ix), window, cx)
}),
)
.on_drop(cx.listener(move |this, paths, window, cx| {
this.drag_split_direction = None;
this.handle_external_paths_drop(paths, window, cx)
}))
.when_some(item.tab_tooltip_content(cx), |tab, content| match content {
TabTooltipContent::Text(text) => tab.tooltip(Tooltip::text(text)),
TabTooltipContent::Custom(element_fn) => {
tab.tooltip(move |window, cx| element_fn(window, cx))
}
})
.start_slot::<Indicator>(indicator)
.map(|this| {
let end_slot_action: &'static dyn Action;
let end_slot_tooltip_text: &'static str;
let end_slot = if is_pinned {
end_slot_action = &TogglePinTab;
end_slot_tooltip_text = "Unpin Tab";
IconButton::new("unpin tab", IconName::Pin)
.shape(IconButtonShape::Square)
.icon_color(Color::Muted)
.size(ButtonSize::None)
.icon_size(IconSize::Small)
.on_click(cx.listener(move |pane, _, window, cx| {
pane.unpin_tab_at(ix, window, cx);
}))
} else {
end_slot_action = &CloseActiveItem {
save_intent: None,
close_pinned: false,
};
end_slot_tooltip_text = "Close Tab";
match show_close_button {
ShowCloseButton::Always => IconButton::new("close tab", IconName::Close),
ShowCloseButton::Hover => {
IconButton::new("close tab", IconName::Close).visible_on_hover("")
}
ShowCloseButton::Hidden => return this,
}
.shape(IconButtonShape::Square)
.icon_color(Color::Muted)
.size(ButtonSize::None)
.icon_size(IconSize::Small)
.on_click(cx.listener(move |pane, _, window, cx| {
pane.close_item_by_id(item_id, SaveIntent::Close, window, cx)
.detach_and_log_err(cx);
}))
}
.map(|this| {
if is_active {
let focus_handle = focus_handle.clone();
this.tooltip(move |window, cx| {
Tooltip::for_action_in(
end_slot_tooltip_text,
end_slot_action,
&focus_handle,
window,
cx,
)
})
} else {
this.tooltip(Tooltip::text(end_slot_tooltip_text))
}
});
this.end_slot(end_slot)
})
.child(
h_flex()
.gap_1()
.items_center()
.children(
std::iter::once(if let Some(decorated_icon) = decorated_icon {
Some(div().child(decorated_icon.into_any_element()))
} else {
icon.map(|icon| div().child(icon.into_any_element()))
})
.flatten(),
)
.child(label),
);
let single_entry_to_resolve = self.items[ix]
.is_singleton(cx)
.then(|| self.items[ix].project_entry_ids(cx).get(0).copied())
.flatten();
let total_items = self.items.len();
let has_items_to_left = ix > 0;
let has_items_to_right = ix < total_items - 1;
let has_clean_items = self.items.iter().any(|item| !item.is_dirty(cx));
let is_pinned = self.is_tab_pinned(ix);
let pane = cx.entity().downgrade();
let menu_context = item.item_focus_handle(cx);
right_click_menu(ix)
.trigger(|_, _, _| tab)
.menu(move |window, cx| {
let pane = pane.clone();
let menu_context = menu_context.clone();
ContextMenu::build(window, cx, move |mut menu, window, cx| {
let close_active_item_action = CloseActiveItem {
save_intent: None,
close_pinned: true,
};
let close_inactive_items_action = CloseOtherItems {
save_intent: None,
close_pinned: false,
};
let close_items_to_the_left_action = CloseItemsToTheLeft {
close_pinned: false,
};
let close_items_to_the_right_action = CloseItemsToTheRight {
close_pinned: false,
};
let close_clean_items_action = CloseCleanItems {
close_pinned: false,
};
let close_all_items_action = CloseAllItems {
save_intent: None,
close_pinned: false,
};
if let Some(pane) = pane.upgrade() {
menu = menu
.entry(
"Close",
Some(Box::new(close_active_item_action)),
window.handler_for(&pane, move |pane, window, cx| {
pane.close_item_by_id(item_id, SaveIntent::Close, window, cx)
.detach_and_log_err(cx);
}),
)
.item(ContextMenuItem::Entry(
ContextMenuEntry::new("Close Others")
.action(Box::new(close_inactive_items_action.clone()))
.disabled(total_items == 1)
.handler(window.handler_for(&pane, move |pane, window, cx| {
pane.close_other_items(
&close_inactive_items_action,
Some(item_id),
window,
cx,
)
.detach_and_log_err(cx);
})),
))
.separator()
.item(ContextMenuItem::Entry(
ContextMenuEntry::new("Close Left")
.action(Box::new(close_items_to_the_left_action.clone()))
.disabled(!has_items_to_left)
.handler(window.handler_for(&pane, move |pane, window, cx| {
pane.close_items_to_the_left_by_id(
Some(item_id),
&close_items_to_the_left_action,
window,
cx,
)
.detach_and_log_err(cx);
})),
))
.item(ContextMenuItem::Entry(
ContextMenuEntry::new("Close Right")
.action(Box::new(close_items_to_the_right_action.clone()))
.disabled(!has_items_to_right)
.handler(window.handler_for(&pane, move |pane, window, cx| {
pane.close_items_to_the_right_by_id(
Some(item_id),
&close_items_to_the_right_action,
window,
cx,
)
.detach_and_log_err(cx);
})),
))
.separator()
.item(ContextMenuItem::Entry(
ContextMenuEntry::new("Close Clean")
.action(Box::new(close_clean_items_action.clone()))
.disabled(!has_clean_items)
.handler(window.handler_for(&pane, move |pane, window, cx| {
pane.close_clean_items(
&close_clean_items_action,
window,
cx,
)
.detach_and_log_err(cx)
})),
))
.entry(
"Close All",
Some(Box::new(close_all_items_action.clone())),
window.handler_for(&pane, move |pane, window, cx| {
pane.close_all_items(&close_all_items_action, window, cx)
.detach_and_log_err(cx)
}),
);
let pin_tab_entries = |menu: ContextMenu| {
menu.separator().map(|this| {
if is_pinned {
this.entry(
"Unpin Tab",
Some(TogglePinTab.boxed_clone()),
window.handler_for(&pane, move |pane, window, cx| {
pane.unpin_tab_at(ix, window, cx);
}),
)
} else {
this.entry(
"Pin Tab",
Some(TogglePinTab.boxed_clone()),
window.handler_for(&pane, move |pane, window, cx| {
pane.pin_tab_at(ix, window, cx);
}),
)
}
})
};
if let Some(entry) = single_entry_to_resolve {
let project_path = pane
.read(cx)
.item_for_entry(entry, cx)
.and_then(|item| item.project_path(cx));
let worktree = project_path.as_ref().and_then(|project_path| {
pane.read(cx)
.project
.upgrade()?
.read(cx)
.worktree_for_id(project_path.worktree_id, cx)
});
let has_relative_path = worktree.as_ref().is_some_and(|worktree| {
worktree
.read(cx)
.root_entry()
.is_some_and(|entry| entry.is_dir())
});
let entry_abs_path = pane.read(cx).entry_abs_path(entry, cx);
let parent_abs_path = entry_abs_path
.as_deref()
.and_then(|abs_path| Some(abs_path.parent()?.to_path_buf()));
let relative_path = project_path
.map(|project_path| project_path.path)
.filter(|_| has_relative_path);
let visible_in_project_panel = relative_path.is_some()
&& worktree.is_some_and(|worktree| worktree.read(cx).is_visible());
let entry_id = entry.to_proto();
menu = menu
.separator()
.when_some(entry_abs_path, |menu, abs_path| {
menu.entry(
"Copy Path",
Some(Box::new(zed_actions::workspace::CopyPath)),
window.handler_for(&pane, move |_, _, cx| {
cx.write_to_clipboard(ClipboardItem::new_string(
abs_path.to_string_lossy().to_string(),
));
}),
)
})
.when_some(relative_path, |menu, relative_path| {
menu.entry(
"Copy Relative Path",
Some(Box::new(zed_actions::workspace::CopyRelativePath)),
window.handler_for(&pane, move |_, _, cx| {
cx.write_to_clipboard(ClipboardItem::new_string(
relative_path.to_string_lossy().to_string(),
));
}),
)
})
.map(pin_tab_entries)
.separator()
.when(visible_in_project_panel, |menu| {
menu.entry(
"Reveal In Project Panel",
Some(Box::new(RevealInProjectPanel::default())),
window.handler_for(&pane, move |pane, _, cx| {
pane.project
.update(cx, |_, cx| {
cx.emit(project::Event::RevealInProjectPanel(
ProjectEntryId::from_proto(entry_id),
))
})
.ok();
}),
)
})
.when_some(parent_abs_path, |menu, parent_abs_path| {
menu.entry(
"Open in Terminal",
Some(Box::new(OpenInTerminal)),
window.handler_for(&pane, move |_, window, cx| {
window.dispatch_action(
OpenTerminal {
working_directory: parent_abs_path.clone(),
}
.boxed_clone(),
cx,
);
}),
)
});
} else {
menu = menu.map(pin_tab_entries);
}
}
menu.context(menu_context)
})
})
}
fn render_tab_bar(&mut self, window: &mut Window, cx: &mut Context<Pane>) -> AnyElement {
let focus_handle = self.focus_handle.clone();
let navigate_backward = IconButton::new("navigate_backward", IconName::ArrowLeft)
.icon_size(IconSize::Small)
.on_click({
let entity = cx.entity();
move |_, window, cx| {
entity.update(cx, |pane, cx| pane.navigate_backward(window, cx))
}
})
.disabled(!self.can_navigate_backward())
.tooltip({
let focus_handle = focus_handle.clone();
move |window, cx| {
Tooltip::for_action_in("Go Back", &GoBack, &focus_handle, window, cx)
}
});
let navigate_forward = IconButton::new("navigate_forward", IconName::ArrowRight)
.icon_size(IconSize::Small)
.on_click({
let entity = cx.entity();
move |_, window, cx| entity.update(cx, |pane, cx| pane.navigate_forward(window, cx))
})
.disabled(!self.can_navigate_forward())
.tooltip({
let focus_handle = focus_handle.clone();
move |window, cx| {
Tooltip::for_action_in("Go Forward", &GoForward, &focus_handle, window, cx)
}
});
let mut tab_items = self
.items
.iter()
.enumerate()
.zip(tab_details(&self.items, window, cx))
.map(|((ix, item), detail)| {
self.render_tab(ix, &**item, detail, &focus_handle, window, cx)
})
.collect::<Vec<_>>();
let tab_count = tab_items.len();
if self.is_tab_pinned(tab_count) {
log::warn!(
"Pinned tab count ({}) exceeds actual tab count ({}). \
This should not happen. If possible, add reproduction steps, \
in a comment, to https://github.com/zed-industries/zed/issues/33342",
self.pinned_tab_count,
tab_count
);
self.pinned_tab_count = tab_count;
}
let unpinned_tabs = tab_items.split_off(self.pinned_tab_count);
let pinned_tabs = tab_items;
TabBar::new("tab_bar")
.when(
self.display_nav_history_buttons.unwrap_or_default(),
|tab_bar| {
tab_bar
.start_child(navigate_backward)
.start_child(navigate_forward)
},
)
.map(|tab_bar| {
if self.show_tab_bar_buttons {
let render_tab_buttons = self.render_tab_bar_buttons.clone();
let (left_children, right_children) = render_tab_buttons(self, window, cx);
tab_bar
.start_children(left_children)
.end_children(right_children)
} else {
tab_bar
}
})
.children(pinned_tabs.len().ne(&0).then(|| {
let max_scroll = self.tab_bar_scroll_handle.max_offset().width;
// We need to check both because offset returns delta values even when the scroll handle is not scrollable
let is_scrollable = !max_scroll.is_zero();
let is_scrolled = self.tab_bar_scroll_handle.offset().x < px(0.);
let has_active_unpinned_tab = self.active_item_index >= self.pinned_tab_count;
h_flex()
.children(pinned_tabs)
.when(is_scrollable && is_scrolled, |this| {
this.when(has_active_unpinned_tab, |this| this.border_r_2())
.when(!has_active_unpinned_tab, |this| this.border_r_1())
.border_color(cx.theme().colors().border)
})
}))
.child(
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::<DraggedTab>(|bar, _, _, cx| {
bar.bg(cx.theme().colors().drop_target_background)
})
.drag_over::<DraggedSelection>(|bar, _, _, cx| {
bar.bg(cx.theme().colors().drop_target_background)
})
.on_drop(cx.listener(
move |this, dragged_tab: &DraggedTab, window, cx| {
this.drag_split_direction = None;
this.handle_tab_drop(dragged_tab, this.items.len(), window, cx)
},
))
.on_drop(cx.listener(
move |this, selection: &DraggedSelection, window, cx| {
this.drag_split_direction = None;
this.handle_project_entry_drop(
&selection.active_selection.entry_id,
Some(tab_count),
window,
cx,
)
},
))
.on_drop(cx.listener(move |this, paths, window, cx| {
this.drag_split_direction = None;
this.handle_external_paths_drop(paths, window, cx)
}))
.on_click(cx.listener(move |this, event: &ClickEvent, window, cx| {
if event.click_count() == 2 {
window.dispatch_action(
this.double_click_dispatch_action.boxed_clone(),
cx,
);
}
})),
),
)
.into_any_element()
}
pub fn render_menu_overlay(menu: &Entity<ContextMenu>) -> Div {
div().absolute().bottom_0().right_0().size_0().child(
deferred(anchored().anchor(Corner::TopRight).child(menu.clone())).with_priority(1),
)
}
pub fn set_zoomed(&mut self, zoomed: bool, cx: &mut Context<Self>) {
self.zoomed = zoomed;
cx.notify();
}
pub fn is_zoomed(&self) -> bool {
self.zoomed
}
fn handle_drag_move<T: 'static>(
&mut self,
event: &DragMoveEvent<T>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let can_split_predicate = self.can_split_predicate.take();
let can_split = match &can_split_predicate {
Some(can_split_predicate) => {
can_split_predicate(self, event.dragged_item(), window, cx)
}
None => false,
};
self.can_split_predicate = can_split_predicate;
if !can_split {
return;
}
let rect = event.bounds.size;
let size = event.bounds.size.width.min(event.bounds.size.height)
* WorkspaceSettings::get_global(cx).drop_target_size;
let relative_cursor = Point::new(
event.event.position.x - event.bounds.left(),
event.event.position.y - event.bounds.top(),
);
let direction = if relative_cursor.x < size
|| relative_cursor.x > rect.width - size
|| relative_cursor.y < size
|| relative_cursor.y > rect.height - size
{
[
SplitDirection::Up,
SplitDirection::Right,
SplitDirection::Down,
SplitDirection::Left,
]
.iter()
.min_by_key(|side| match side {
SplitDirection::Up => relative_cursor.y,
SplitDirection::Right => rect.width - relative_cursor.x,
SplitDirection::Down => rect.height - relative_cursor.y,
SplitDirection::Left => relative_cursor.x,
})
.cloned()
} else {
None
};
if direction != self.drag_split_direction {
self.drag_split_direction = direction;
}
}
pub fn handle_tab_drop(
&mut self,
dragged_tab: &DraggedTab,
ix: usize,
window: &mut Window,
cx: &mut Context<Self>,
) {
if let Some(custom_drop_handle) = self.custom_drop_handle.clone()
&& let ControlFlow::Break(()) = custom_drop_handle(self, dragged_tab, window, cx)
{
return;
}
let mut to_pane = cx.entity();
let split_direction = self.drag_split_direction;
let item_id = dragged_tab.item.item_id();
if let Some(preview_item_id) = self.preview_item_id
&& item_id == preview_item_id
{
self.set_preview_item_id(None, cx);
}
let is_clone = cfg!(target_os = "macos") && window.modifiers().alt
|| cfg!(not(target_os = "macos")) && window.modifiers().control;
let from_pane = dragged_tab.pane.clone();
self.workspace
.update(cx, |_, cx| {
cx.defer_in(window, move |workspace, window, cx| {
if let Some(split_direction) = split_direction {
to_pane = workspace.split_pane(to_pane, split_direction, window, cx);
}
let database_id = workspace.database_id();
let was_pinned_in_from_pane = from_pane.read_with(cx, |pane, _| {
pane.index_for_item_id(item_id)
.is_some_and(|ix| pane.is_tab_pinned(ix))
});
let to_pane_old_length = to_pane.read(cx).items.len();
if is_clone {
let Some(item) = from_pane
.read(cx)
.items()
.find(|item| item.item_id() == item_id)
.cloned()
else {
return;
};
if let Some(item) = item.clone_on_split(database_id, window, cx) {
to_pane.update(cx, |pane, cx| {
pane.add_item(item, true, true, None, window, cx);
})
}
} else {
move_item(&from_pane, &to_pane, item_id, ix, true, window, cx);
}
to_pane.update(cx, |this, _| {
if to_pane == from_pane {
let actual_ix = this
.items
.iter()
.position(|item| item.item_id() == item_id)
.unwrap_or(0);
let is_pinned_in_to_pane = this.is_tab_pinned(actual_ix);
if !was_pinned_in_from_pane && is_pinned_in_to_pane {
this.pinned_tab_count += 1;
} else if was_pinned_in_from_pane && !is_pinned_in_to_pane {
this.pinned_tab_count -= 1;
}
} else if this.items.len() >= to_pane_old_length {
let is_pinned_in_to_pane = this.is_tab_pinned(ix);
let item_created_pane = to_pane_old_length == 0;
let is_first_position = ix == 0;
let was_dropped_at_beginning = item_created_pane || is_first_position;
let should_remain_pinned = is_pinned_in_to_pane
|| (was_pinned_in_from_pane && was_dropped_at_beginning);
if should_remain_pinned {
this.pinned_tab_count += 1;
}
}
});
});
})
.log_err();
}
fn handle_dragged_selection_drop(
&mut self,
dragged_selection: &DraggedSelection,
dragged_onto: Option<usize>,
window: &mut Window,
cx: &mut Context<Self>,
) {
if let Some(custom_drop_handle) = self.custom_drop_handle.clone()
&& let ControlFlow::Break(()) = custom_drop_handle(self, dragged_selection, window, cx)
{
return;
}
self.handle_project_entry_drop(
&dragged_selection.active_selection.entry_id,
dragged_onto,
window,
cx,
);
}
fn handle_project_entry_drop(
&mut self,
project_entry_id: &ProjectEntryId,
target: Option<usize>,
window: &mut Window,
cx: &mut Context<Self>,
) {
if let Some(custom_drop_handle) = self.custom_drop_handle.clone()
&& let ControlFlow::Break(()) = custom_drop_handle(self, project_entry_id, window, cx)
{
return;
}
let mut to_pane = cx.entity();
let split_direction = self.drag_split_direction;
let project_entry_id = *project_entry_id;
self.workspace
.update(cx, |_, cx| {
cx.defer_in(window, move |workspace, window, cx| {
if let Some(project_path) = workspace
.project()
.read(cx)
.path_for_entry(project_entry_id, cx)
{
let load_path_task = workspace.load_path(project_path.clone(), window, cx);
cx.spawn_in(window, async move |workspace, cx| {
if let Some((project_entry_id, build_item)) =
load_path_task.await.notify_async_err(cx)
{
let (to_pane, new_item_handle) = workspace
.update_in(cx, |workspace, window, cx| {
if let Some(split_direction) = split_direction {
to_pane = workspace.split_pane(
to_pane,
split_direction,
window,
cx,
);
}
let new_item_handle = to_pane.update(cx, |pane, cx| {
pane.open_item(
project_entry_id,
project_path,
true,
false,
true,
target,
window,
cx,
build_item,
)
});
(to_pane, new_item_handle)
})
.log_err()?;
to_pane
.update_in(cx, |this, window, cx| {
let Some(index) = this.index_for_item(&*new_item_handle)
else {
return;
};
if target.is_some_and(|target| this.is_tab_pinned(target)) {
this.pin_tab_at(index, window, cx);
}
})
.ok()?
}
Some(())
})
.detach();
};
});
})
.log_err();
}
fn handle_external_paths_drop(
&mut self,
paths: &ExternalPaths,
window: &mut Window,
cx: &mut Context<Self>,
) {
if let Some(custom_drop_handle) = self.custom_drop_handle.clone()
&& let ControlFlow::Break(()) = custom_drop_handle(self, paths, window, cx)
{
return;
}
let mut to_pane = cx.entity();
let mut split_direction = self.drag_split_direction;
let paths = paths.paths().to_vec();
let is_remote = self
.workspace
.update(cx, |workspace, cx| {
if workspace.project().read(cx).is_via_collab() {
workspace.show_error(
&anyhow::anyhow!("Cannot drop files on a remote project"),
cx,
);
true
} else {
false
}
})
.unwrap_or(true);
if is_remote {
return;
}
self.workspace
.update(cx, |workspace, cx| {
let fs = Arc::clone(workspace.project().read(cx).fs());
cx.spawn_in(window, async move |workspace, cx| {
let mut is_file_checks = FuturesUnordered::new();
for path in &paths {
is_file_checks.push(fs.is_file(path))
}
let mut has_files_to_open = false;
while let Some(is_file) = is_file_checks.next().await {
if is_file {
has_files_to_open = true;
break;
}
}
drop(is_file_checks);
if !has_files_to_open {
split_direction = None;
}
if let Ok((open_task, to_pane)) =
workspace.update_in(cx, |workspace, window, cx| {
if let Some(split_direction) = split_direction {
to_pane =
workspace.split_pane(to_pane, split_direction, window, cx);
}
(
workspace.open_paths(
paths,
OpenOptions {
visible: Some(OpenVisible::OnlyDirectories),
..Default::default()
},
Some(to_pane.downgrade()),
window,
cx,
),
to_pane,
)
})
{
let opened_items: Vec<_> = open_task.await;
_ = workspace.update_in(cx, |workspace, window, cx| {
for item in opened_items.into_iter().flatten() {
if let Err(e) = item {
workspace.show_error(&e, cx);
}
}
if to_pane.read(cx).items_len() == 0 {
workspace.remove_pane(to_pane, None, window, cx);
}
});
}
})
.detach();
})
.log_err();
}
pub fn display_nav_history_buttons(&mut self, display: Option<bool>) {
self.display_nav_history_buttons = display;
}
fn pinned_item_ids(&self) -> Vec<EntityId> {
self.items
.iter()
.enumerate()
.filter_map(|(index, item)| {
if self.is_tab_pinned(index) {
return Some(item.item_id());
}
None
})
.collect()
}
fn clean_item_ids(&self, cx: &mut Context<Pane>) -> Vec<EntityId> {
self.items()
.filter_map(|item| {
if !item.is_dirty(cx) {
return Some(item.item_id());
}
None
})
.collect()
}
fn to_the_side_item_ids(&self, item_id: EntityId, side: Side) -> Vec<EntityId> {
match side {
Side::Left => self
.items()
.take_while(|item| item.item_id() != item_id)
.map(|item| item.item_id())
.collect(),
Side::Right => self
.items()
.rev()
.take_while(|item| item.item_id() != item_id)
.map(|item| item.item_id())
.collect(),
}
}
pub fn drag_split_direction(&self) -> Option<SplitDirection> {
self.drag_split_direction
}
pub fn set_zoom_out_on_close(&mut self, zoom_out_on_close: bool) {
self.zoom_out_on_close = zoom_out_on_close;
}
}
fn default_render_tab_bar_buttons(
pane: &mut Pane,
window: &mut Window,
cx: &mut Context<Pane>,
) -> (Option<AnyElement>, Option<AnyElement>) {
if !pane.has_focus(window, cx) && !pane.context_menu_focused(window, cx) {
return (None, None);
}
// Ideally we would return a vec of elements here to pass directly to the [TabBar]'s
// `end_slot`, but due to needing a view here that isn't possible.
let right_children = h_flex()
// Instead we need to replicate the spacing from the [TabBar]'s `end_slot` here.
.gap(DynamicSpacing::Base04.rems(cx))
.child(
PopoverMenu::new("pane-tab-bar-popover-menu")
.trigger_with_tooltip(
IconButton::new("plus", IconName::Plus).icon_size(IconSize::Small),
Tooltip::text("New..."),
)
.anchor(Corner::TopRight)
.with_handle(pane.new_item_context_menu_handle.clone())
.menu(move |window, cx| {
Some(ContextMenu::build(window, cx, |menu, _, _| {
menu.action("New File", NewFile.boxed_clone())
.action("Open File", ToggleFileFinder::default().boxed_clone())
.separator()
.action(
"Search Project",
DeploySearch {
replace_enabled: false,
included_files: None,
excluded_files: None,
}
.boxed_clone(),
)
.action("Search Symbols", ToggleProjectSymbols.boxed_clone())
.separator()
.action("New Terminal", NewTerminal.boxed_clone())
}))
}),
)
.child(
PopoverMenu::new("pane-tab-bar-split")
.trigger_with_tooltip(
IconButton::new("split", IconName::Split).icon_size(IconSize::Small),
Tooltip::text("Split Pane"),
)
.anchor(Corner::TopRight)
.with_handle(pane.split_item_context_menu_handle.clone())
.menu(move |window, cx| {
ContextMenu::build(window, cx, |menu, _, _| {
menu.action("Split Right", SplitRight.boxed_clone())
.action("Split Left", SplitLeft.boxed_clone())
.action("Split Up", SplitUp.boxed_clone())
.action("Split Down", SplitDown.boxed_clone())
})
.into()
}),
)
.child({
let zoomed = pane.is_zoomed();
IconButton::new("toggle_zoom", IconName::Maximize)
.icon_size(IconSize::Small)
.toggle_state(zoomed)
.selected_icon(IconName::Minimize)
.on_click(cx.listener(|pane, _, window, cx| {
pane.toggle_zoom(&crate::ToggleZoom, window, cx);
}))
.tooltip(move |window, cx| {
Tooltip::for_action(
if zoomed { "Zoom Out" } else { "Zoom In" },
&ToggleZoom,
window,
cx,
)
})
})
.into_any_element()
.into();
(None, right_children)
}
impl Focusable for Pane {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for Pane {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let mut key_context = KeyContext::new_with_defaults();
key_context.add("Pane");
if self.active_item().is_none() {
key_context.add("EmptyPane");
}
let should_display_tab_bar = self.should_display_tab_bar.clone();
let display_tab_bar = should_display_tab_bar(window, cx);
let Some(project) = self.project.upgrade() else {
return div().track_focus(&self.focus_handle(cx));
};
let is_local = project.read(cx).is_local();
v_flex()
.key_context(key_context)
.track_focus(&self.focus_handle(cx))
.size_full()
.flex_none()
.overflow_hidden()
.on_action(cx.listener(|pane, _: &AlternateFile, window, cx| {
pane.alternate_file(window, cx);
}))
.on_action(
cx.listener(|pane, _: &SplitLeft, _, cx| pane.split(SplitDirection::Left, cx)),
)
.on_action(cx.listener(|pane, _: &SplitUp, _, cx| pane.split(SplitDirection::Up, cx)))
.on_action(cx.listener(|pane, _: &SplitHorizontal, _, cx| {
pane.split(SplitDirection::horizontal(cx), cx)
}))
.on_action(cx.listener(|pane, _: &SplitVertical, _, cx| {
pane.split(SplitDirection::vertical(cx), cx)
}))
.on_action(
cx.listener(|pane, _: &SplitRight, _, cx| pane.split(SplitDirection::Right, cx)),
)
.on_action(
cx.listener(|pane, _: &SplitDown, _, cx| pane.split(SplitDirection::Down, cx)),
)
.on_action(
cx.listener(|pane, _: &GoBack, window, cx| pane.navigate_backward(window, cx)),
)
.on_action(
cx.listener(|pane, _: &GoForward, window, cx| pane.navigate_forward(window, cx)),
)
.on_action(cx.listener(|_, _: &JoinIntoNext, _, cx| {
cx.emit(Event::JoinIntoNext);
}))
.on_action(cx.listener(|_, _: &JoinAll, _, cx| {
cx.emit(Event::JoinAll);
}))
.on_action(cx.listener(Pane::toggle_zoom))
.on_action(
cx.listener(|pane: &mut Pane, action: &ActivateItem, window, cx| {
pane.activate_item(
action.0.min(pane.items.len().saturating_sub(1)),
true,
true,
window,
cx,
);
}),
)
.on_action(
cx.listener(|pane: &mut Pane, _: &ActivateLastItem, window, cx| {
pane.activate_item(pane.items.len().saturating_sub(1), true, true, window, cx);
}),
)
.on_action(
cx.listener(|pane: &mut Pane, _: &ActivatePreviousItem, window, cx| {
pane.activate_prev_item(true, window, cx);
}),
)
.on_action(
cx.listener(|pane: &mut Pane, _: &ActivateNextItem, window, cx| {
pane.activate_next_item(true, window, cx);
}),
)
.on_action(
cx.listener(|pane, _: &SwapItemLeft, window, cx| pane.swap_item_left(window, cx)),
)
.on_action(
cx.listener(|pane, _: &SwapItemRight, window, cx| pane.swap_item_right(window, cx)),
)
.on_action(cx.listener(|pane, action, window, cx| {
pane.toggle_pin_tab(action, window, cx);
}))
.on_action(cx.listener(|pane, action, window, cx| {
pane.unpin_all_tabs(action, window, 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()) {
if pane.is_active_preview_item(active_item_id) {
pane.set_preview_item_id(None, cx);
} else {
pane.set_preview_item_id(Some(active_item_id), cx);
}
}
}))
})
.on_action(
cx.listener(|pane: &mut Self, action: &CloseActiveItem, window, cx| {
pane.close_active_item(action, window, cx)
.detach_and_log_err(cx)
}),
)
.on_action(
cx.listener(|pane: &mut Self, action: &CloseOtherItems, window, cx| {
pane.close_other_items(action, None, window, cx)
.detach_and_log_err(cx);
}),
)
.on_action(
cx.listener(|pane: &mut Self, action: &CloseCleanItems, window, cx| {
pane.close_clean_items(action, window, cx)
.detach_and_log_err(cx)
}),
)
.on_action(cx.listener(
|pane: &mut Self, action: &CloseItemsToTheLeft, window, cx| {
pane.close_items_to_the_left_by_id(None, action, window, cx)
.detach_and_log_err(cx)
},
))
.on_action(cx.listener(
|pane: &mut Self, action: &CloseItemsToTheRight, window, cx| {
pane.close_items_to_the_right_by_id(None, action, window, cx)
.detach_and_log_err(cx)
},
))
.on_action(
cx.listener(|pane: &mut Self, action: &CloseAllItems, window, cx| {
pane.close_all_items(action, window, cx)
.detach_and_log_err(cx)
}),
)
.on_action(
cx.listener(|pane: &mut Self, action: &RevealInProjectPanel, _, cx| {
let entry_id = action
.entry_id
.map(ProjectEntryId::from_proto)
.or_else(|| pane.active_item()?.project_entry_ids(cx).first().copied());
if let Some(entry_id) = entry_id {
pane.project
.update(cx, |_, cx| {
cx.emit(project::Event::RevealInProjectPanel(entry_id))
})
.ok();
}
}),
)
.on_action(cx.listener(|_, _: &menu::Cancel, window, cx| {
if cx.stop_active_drag(window) {
} else {
cx.propagate();
}
}))
.when(self.active_item().is_some() && display_tab_bar, |pane| {
pane.child((self.render_tab_bar.clone())(self, window, cx))
})
.child({
let has_worktrees = project.read(cx).visible_worktrees(cx).next().is_some();
// main content
div()
.flex_1()
.relative()
.group("")
.overflow_hidden()
.on_drag_move::<DraggedTab>(cx.listener(Self::handle_drag_move))
.on_drag_move::<DraggedSelection>(cx.listener(Self::handle_drag_move))
.when(is_local, |div| {
div.on_drag_move::<ExternalPaths>(cx.listener(Self::handle_drag_move))
})
.map(|div| {
if let Some(item) = self.active_item() {
div.id("pane_placeholder")
.v_flex()
.size_full()
.overflow_hidden()
.child(self.toolbar.clone())
.child(item.to_any())
} else {
let placeholder = div
.id("pane_placeholder")
.h_flex()
.size_full()
.justify_center()
.on_click(cx.listener(
move |this, event: &ClickEvent, window, cx| {
if event.click_count() == 2 {
window.dispatch_action(
this.double_click_dispatch_action.boxed_clone(),
cx,
);
}
},
));
if has_worktrees {
placeholder
} else {
placeholder.child(
Label::new("Open a file or project to get started.")
.color(Color::Muted),
)
}
}
})
.child(
// drag target
div()
.invisible()
.absolute()
.bg(cx.theme().colors().drop_target_background)
.group_drag_over::<DraggedTab>("", |style| style.visible())
.group_drag_over::<DraggedSelection>("", |style| style.visible())
.when(is_local, |div| {
div.group_drag_over::<ExternalPaths>("", |style| style.visible())
})
.when_some(self.can_drop_predicate.clone(), |this, p| {
this.can_drop(move |a, window, cx| p(a, window, cx))
})
.on_drop(cx.listener(move |this, dragged_tab, window, cx| {
this.handle_tab_drop(
dragged_tab,
this.active_item_index(),
window,
cx,
)
}))
.on_drop(cx.listener(
move |this, selection: &DraggedSelection, window, cx| {
this.handle_dragged_selection_drop(selection, None, window, cx)
},
))
.on_drop(cx.listener(move |this, paths, window, cx| {
this.handle_external_paths_drop(paths, window, cx)
}))
.map(|div| {
let size = DefiniteLength::Fraction(0.5);
match self.drag_split_direction {
None => div.top_0().right_0().bottom_0().left_0(),
Some(SplitDirection::Up) => {
div.top_0().left_0().right_0().h(size)
}
Some(SplitDirection::Down) => {
div.left_0().bottom_0().right_0().h(size)
}
Some(SplitDirection::Left) => {
div.top_0().left_0().bottom_0().w(size)
}
Some(SplitDirection::Right) => {
div.top_0().bottom_0().right_0().w(size)
}
}
}),
)
})
.on_mouse_down(
MouseButton::Navigate(NavigationDirection::Back),
cx.listener(|pane, _, window, cx| {
if let Some(workspace) = pane.workspace.upgrade() {
let pane = cx.entity().downgrade();
window.defer(cx, move |window, cx| {
workspace.update(cx, |workspace, cx| {
workspace.go_back(pane, window, cx).detach_and_log_err(cx)
})
})
}
}),
)
.on_mouse_down(
MouseButton::Navigate(NavigationDirection::Forward),
cx.listener(|pane, _, window, cx| {
if let Some(workspace) = pane.workspace.upgrade() {
let pane = cx.entity().downgrade();
window.defer(cx, move |window, cx| {
workspace.update(cx, |workspace, cx| {
workspace
.go_forward(pane, window, cx)
.detach_and_log_err(cx)
})
})
}
}),
)
}
}
impl ItemNavHistory {
pub fn push<D: 'static + Send + Any>(&mut self, data: Option<D>, cx: &mut App) {
if self
.item
.upgrade()
.is_some_and(|item| item.include_in_nav_history())
{
self.history
.push(data, self.item.clone(), self.is_preview, cx);
}
}
pub fn pop_backward(&mut self, cx: &mut App) -> Option<NavigationEntry> {
self.history.pop(NavigationMode::GoingBack, cx)
}
pub fn pop_forward(&mut self, cx: &mut App) -> Option<NavigationEntry> {
self.history.pop(NavigationMode::GoingForward, cx)
}
}
impl NavHistory {
pub fn for_each_entry(
&self,
cx: &App,
mut f: impl FnMut(&NavigationEntry, (ProjectPath, Option<PathBuf>)),
) {
let borrowed_history = self.0.lock();
borrowed_history
.forward_stack
.iter()
.chain(borrowed_history.backward_stack.iter())
.chain(borrowed_history.closed_stack.iter())
.for_each(|entry| {
if let Some(project_and_abs_path) =
borrowed_history.paths_by_item.get(&entry.item.id())
{
f(entry, project_and_abs_path.clone());
} else if let Some(item) = entry.item.upgrade()
&& let Some(path) = item.project_path(cx)
{
f(entry, (path, None));
}
})
}
pub fn set_mode(&mut self, mode: NavigationMode) {
self.0.lock().mode = mode;
}
pub fn mode(&self) -> NavigationMode {
self.0.lock().mode
}
pub fn disable(&mut self) {
self.0.lock().mode = NavigationMode::Disabled;
}
pub fn enable(&mut self) {
self.0.lock().mode = NavigationMode::Normal;
}
pub fn pop(&mut self, mode: NavigationMode, cx: &mut App) -> Option<NavigationEntry> {
let mut state = self.0.lock();
let entry = match mode {
NavigationMode::Normal | NavigationMode::Disabled | NavigationMode::ClosingItem => {
return None;
}
NavigationMode::GoingBack => &mut state.backward_stack,
NavigationMode::GoingForward => &mut state.forward_stack,
NavigationMode::ReopeningClosedItem => &mut state.closed_stack,
}
.pop_back();
if entry.is_some() {
state.did_update(cx);
}
entry
}
pub fn push<D: 'static + Send + Any>(
&mut self,
data: Option<D>,
item: Arc<dyn WeakItemHandle>,
is_preview: bool,
cx: &mut App,
) {
let state = &mut *self.0.lock();
match state.mode {
NavigationMode::Disabled => {}
NavigationMode::Normal | NavigationMode::ReopeningClosedItem => {
if state.backward_stack.len() >= MAX_NAVIGATION_HISTORY_LEN {
state.backward_stack.pop_front();
}
state.backward_stack.push_back(NavigationEntry {
item,
data: data.map(|data| Box::new(data) as Box<dyn Any + Send>),
timestamp: state.next_timestamp.fetch_add(1, Ordering::SeqCst),
is_preview,
});
state.forward_stack.clear();
}
NavigationMode::GoingBack => {
if state.forward_stack.len() >= MAX_NAVIGATION_HISTORY_LEN {
state.forward_stack.pop_front();
}
state.forward_stack.push_back(NavigationEntry {
item,
data: data.map(|data| Box::new(data) as Box<dyn Any + Send>),
timestamp: state.next_timestamp.fetch_add(1, Ordering::SeqCst),
is_preview,
});
}
NavigationMode::GoingForward => {
if state.backward_stack.len() >= MAX_NAVIGATION_HISTORY_LEN {
state.backward_stack.pop_front();
}
state.backward_stack.push_back(NavigationEntry {
item,
data: data.map(|data| Box::new(data) as Box<dyn Any + Send>),
timestamp: state.next_timestamp.fetch_add(1, Ordering::SeqCst),
is_preview,
});
}
NavigationMode::ClosingItem => {
if state.closed_stack.len() >= MAX_NAVIGATION_HISTORY_LEN {
state.closed_stack.pop_front();
}
state.closed_stack.push_back(NavigationEntry {
item,
data: data.map(|data| Box::new(data) as Box<dyn Any + Send>),
timestamp: state.next_timestamp.fetch_add(1, Ordering::SeqCst),
is_preview,
});
}
}
state.did_update(cx);
}
pub fn remove_item(&mut self, item_id: EntityId) {
let mut state = self.0.lock();
state.paths_by_item.remove(&item_id);
state
.backward_stack
.retain(|entry| entry.item.id() != item_id);
state
.forward_stack
.retain(|entry| entry.item.id() != item_id);
state
.closed_stack
.retain(|entry| entry.item.id() != item_id);
}
pub fn path_for_item(&self, item_id: EntityId) -> Option<(ProjectPath, Option<PathBuf>)> {
self.0.lock().paths_by_item.get(&item_id).cloned()
}
}
impl NavHistoryState {
pub fn did_update(&self, cx: &mut App) {
if let Some(pane) = self.pane.upgrade() {
cx.defer(move |cx| {
pane.update(cx, |pane, cx| pane.history_updated(cx));
});
}
}
}
fn dirty_message_for(buffer_path: Option<ProjectPath>) -> String {
let path = buffer_path
.as_ref()
.and_then(|p| {
p.path
.to_str()
.and_then(|s| if s.is_empty() { None } else { Some(s) })
})
.unwrap_or("This buffer");
let path = truncate_and_remove_front(path, 80);
format!("{path} contains unsaved edits. Do you want to save it?")
}
pub fn tab_details(items: &[Box<dyn ItemHandle>], _window: &Window, cx: &App) -> Vec<usize> {
let mut tab_details = items.iter().map(|_| 0).collect::<Vec<_>>();
let mut tab_descriptions = HashMap::default();
let mut done = false;
while !done {
done = true;
// Store item indices by their tab description.
for (ix, (item, detail)) in items.iter().zip(&tab_details).enumerate() {
let description = item.tab_content_text(*detail, cx);
if *detail == 0 || description != item.tab_content_text(detail - 1, cx) {
tab_descriptions
.entry(description)
.or_insert(Vec::new())
.push(ix);
}
}
// If two or more items have the same tab description, increase their level
// of detail and try again.
for (_, item_ixs) in tab_descriptions.drain() {
if item_ixs.len() > 1 {
done = false;
for ix in item_ixs {
tab_details[ix] += 1;
}
}
}
}
tab_details
}
pub fn render_item_indicator(item: Box<dyn ItemHandle>, cx: &App) -> Option<Indicator> {
maybe!({
let indicator_color = match (item.has_conflict(cx), item.is_dirty(cx)) {
(true, _) => Color::Warning,
(_, true) => Color::Accent,
(false, false) => return None,
};
Some(Indicator::dot().color(indicator_color))
})
}
impl Render for DraggedTab {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let ui_font = ThemeSettings::get_global(cx).ui_font.clone();
let label = self.item.tab_content(
TabContentParams {
detail: Some(self.detail),
selected: false,
preview: false,
deemphasized: false,
},
window,
cx,
);
Tab::new("")
.toggle_state(self.is_active)
.child(label)
.render(window, cx)
.font(ui_font)
}
}
#[cfg(test)]
mod tests {
use std::num::NonZero;
use super::*;
use crate::item::test::{TestItem, TestProjectItem};
use gpui::{TestAppContext, VisualTestContext};
use project::FakeFs;
use settings::SettingsStore;
use theme::LoadThemes;
use util::TryFutureExt;
#[gpui::test]
async fn test_add_item_capped_to_max_tabs(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, None, cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
for i in 0..7 {
add_labeled_item(&pane, format!("{}", i).as_str(), false, cx);
}
set_max_tabs(cx, Some(5));
add_labeled_item(&pane, "7", false, cx);
// Remove items to respect the max tab cap.
assert_item_labels(&pane, ["3", "4", "5", "6", "7*"], cx);
pane.update_in(cx, |pane, window, cx| {
pane.activate_item(0, false, false, window, cx);
});
add_labeled_item(&pane, "X", false, cx);
// Respect activation order.
assert_item_labels(&pane, ["3", "X*", "5", "6", "7"], cx);
for i in 0..7 {
add_labeled_item(&pane, format!("D{}", i).as_str(), true, cx);
}
// Keeps dirty items, even over max tab cap.
assert_item_labels(
&pane,
["D0^", "D1^", "D2^", "D3^", "D4^", "D5^", "D6*^"],
cx,
);
set_max_tabs(cx, None);
for i in 0..7 {
add_labeled_item(&pane, format!("N{}", i).as_str(), false, cx);
}
// No cap when max tabs is None.
assert_item_labels(
&pane,
[
"D0^", "D1^", "D2^", "D3^", "D4^", "D5^", "D6^", "N0", "N1", "N2", "N3", "N4",
"N5", "N6*",
],
cx,
);
}
#[gpui::test]
async fn test_reduce_max_tabs_closes_existing_items(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, None, cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
add_labeled_item(&pane, "A", false, cx);
add_labeled_item(&pane, "B", false, cx);
let item_c = add_labeled_item(&pane, "C", false, cx);
let item_d = add_labeled_item(&pane, "D", false, cx);
add_labeled_item(&pane, "E", false, cx);
add_labeled_item(&pane, "Settings", false, cx);
assert_item_labels(&pane, ["A", "B", "C", "D", "E", "Settings*"], cx);
set_max_tabs(cx, Some(5));
assert_item_labels(&pane, ["B", "C", "D", "E", "Settings*"], cx);
set_max_tabs(cx, Some(4));
assert_item_labels(&pane, ["C", "D", "E", "Settings*"], cx);
pane.update_in(cx, |pane, window, cx| {
let ix = pane.index_for_item_id(item_c.item_id()).unwrap();
pane.pin_tab_at(ix, window, cx);
let ix = pane.index_for_item_id(item_d.item_id()).unwrap();
pane.pin_tab_at(ix, window, cx);
});
assert_item_labels(&pane, ["C!", "D!", "E", "Settings*"], cx);
set_max_tabs(cx, Some(2));
assert_item_labels(&pane, ["C!", "D!", "Settings*"], cx);
}
#[gpui::test]
async fn test_allow_pinning_dirty_item_at_max_tabs(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, None, cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
set_max_tabs(cx, Some(1));
let item_a = add_labeled_item(&pane, "A", true, cx);
pane.update_in(cx, |pane, window, cx| {
let ix = pane.index_for_item_id(item_a.item_id()).unwrap();
pane.pin_tab_at(ix, window, cx);
});
assert_item_labels(&pane, ["A*^!"], cx);
}
#[gpui::test]
async fn test_allow_pinning_non_dirty_item_at_max_tabs(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, None, cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
set_max_tabs(cx, Some(1));
let item_a = add_labeled_item(&pane, "A", false, cx);
pane.update_in(cx, |pane, window, cx| {
let ix = pane.index_for_item_id(item_a.item_id()).unwrap();
pane.pin_tab_at(ix, window, cx);
});
assert_item_labels(&pane, ["A*!"], cx);
}
#[gpui::test]
async fn test_pin_tabs_incrementally_at_max_capacity(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, None, cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
set_max_tabs(cx, Some(3));
let item_a = add_labeled_item(&pane, "A", false, cx);
assert_item_labels(&pane, ["A*"], cx);
pane.update_in(cx, |pane, window, cx| {
let ix = pane.index_for_item_id(item_a.item_id()).unwrap();
pane.pin_tab_at(ix, window, cx);
});
assert_item_labels(&pane, ["A*!"], cx);
let item_b = add_labeled_item(&pane, "B", false, cx);
assert_item_labels(&pane, ["A!", "B*"], cx);
pane.update_in(cx, |pane, window, cx| {
let ix = pane.index_for_item_id(item_b.item_id()).unwrap();
pane.pin_tab_at(ix, window, cx);
});
assert_item_labels(&pane, ["A!", "B*!"], cx);
let item_c = add_labeled_item(&pane, "C", false, cx);
assert_item_labels(&pane, ["A!", "B!", "C*"], cx);
pane.update_in(cx, |pane, window, cx| {
let ix = pane.index_for_item_id(item_c.item_id()).unwrap();
pane.pin_tab_at(ix, window, cx);
});
assert_item_labels(&pane, ["A!", "B!", "C*!"], cx);
}
#[gpui::test]
async fn test_pin_tabs_left_to_right_after_opening_at_max_capacity(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, None, cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
set_max_tabs(cx, Some(3));
let item_a = add_labeled_item(&pane, "A", false, cx);
assert_item_labels(&pane, ["A*"], cx);
let item_b = add_labeled_item(&pane, "B", false, cx);
assert_item_labels(&pane, ["A", "B*"], cx);
let item_c = add_labeled_item(&pane, "C", false, cx);
assert_item_labels(&pane, ["A", "B", "C*"], cx);
pane.update_in(cx, |pane, window, cx| {
let ix = pane.index_for_item_id(item_a.item_id()).unwrap();
pane.pin_tab_at(ix, window, cx);
});
assert_item_labels(&pane, ["A!", "B", "C*"], cx);
pane.update_in(cx, |pane, window, cx| {
let ix = pane.index_for_item_id(item_b.item_id()).unwrap();
pane.pin_tab_at(ix, window, cx);
});
assert_item_labels(&pane, ["A!", "B!", "C*"], cx);
pane.update_in(cx, |pane, window, cx| {
let ix = pane.index_for_item_id(item_c.item_id()).unwrap();
pane.pin_tab_at(ix, window, cx);
});
assert_item_labels(&pane, ["A!", "B!", "C*!"], cx);
}
#[gpui::test]
async fn test_pin_tabs_right_to_left_after_opening_at_max_capacity(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, None, cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
set_max_tabs(cx, Some(3));
let item_a = add_labeled_item(&pane, "A", false, cx);
assert_item_labels(&pane, ["A*"], cx);
let item_b = add_labeled_item(&pane, "B", false, cx);
assert_item_labels(&pane, ["A", "B*"], cx);
let item_c = add_labeled_item(&pane, "C", false, cx);
assert_item_labels(&pane, ["A", "B", "C*"], cx);
pane.update_in(cx, |pane, window, cx| {
let ix = pane.index_for_item_id(item_c.item_id()).unwrap();
pane.pin_tab_at(ix, window, cx);
});
assert_item_labels(&pane, ["C*!", "A", "B"], cx);
pane.update_in(cx, |pane, window, cx| {
let ix = pane.index_for_item_id(item_b.item_id()).unwrap();
pane.pin_tab_at(ix, window, cx);
});
assert_item_labels(&pane, ["C*!", "B!", "A"], cx);
pane.update_in(cx, |pane, window, cx| {
let ix = pane.index_for_item_id(item_a.item_id()).unwrap();
pane.pin_tab_at(ix, window, cx);
});
assert_item_labels(&pane, ["C*!", "B!", "A!"], cx);
}
#[gpui::test]
async fn test_pinned_tabs_never_closed_at_max_tabs(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, None, cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
let item_a = add_labeled_item(&pane, "A", false, cx);
pane.update_in(cx, |pane, window, cx| {
let ix = pane.index_for_item_id(item_a.item_id()).unwrap();
pane.pin_tab_at(ix, window, cx);
});
let item_b = add_labeled_item(&pane, "B", false, cx);
pane.update_in(cx, |pane, window, cx| {
let ix = pane.index_for_item_id(item_b.item_id()).unwrap();
pane.pin_tab_at(ix, window, cx);
});
add_labeled_item(&pane, "C", false, cx);
add_labeled_item(&pane, "D", false, cx);
add_labeled_item(&pane, "E", false, cx);
assert_item_labels(&pane, ["A!", "B!", "C", "D", "E*"], cx);
set_max_tabs(cx, Some(3));
add_labeled_item(&pane, "F", false, cx);
assert_item_labels(&pane, ["A!", "B!", "F*"], cx);
add_labeled_item(&pane, "G", false, cx);
assert_item_labels(&pane, ["A!", "B!", "G*"], cx);
add_labeled_item(&pane, "H", false, cx);
assert_item_labels(&pane, ["A!", "B!", "H*"], cx);
}
#[gpui::test]
async fn test_always_allows_one_unpinned_item_over_max_tabs_regardless_of_pinned_count(
cx: &mut TestAppContext,
) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, None, cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
set_max_tabs(cx, Some(3));
let item_a = add_labeled_item(&pane, "A", false, cx);
pane.update_in(cx, |pane, window, cx| {
let ix = pane.index_for_item_id(item_a.item_id()).unwrap();
pane.pin_tab_at(ix, window, cx);
});
let item_b = add_labeled_item(&pane, "B", false, cx);
pane.update_in(cx, |pane, window, cx| {
let ix = pane.index_for_item_id(item_b.item_id()).unwrap();
pane.pin_tab_at(ix, window, cx);
});
let item_c = add_labeled_item(&pane, "C", false, cx);
pane.update_in(cx, |pane, window, cx| {
let ix = pane.index_for_item_id(item_c.item_id()).unwrap();
pane.pin_tab_at(ix, window, cx);
});
assert_item_labels(&pane, ["A!", "B!", "C*!"], cx);
let item_d = add_labeled_item(&pane, "D", false, cx);
assert_item_labels(&pane, ["A!", "B!", "C!", "D*"], cx);
pane.update_in(cx, |pane, window, cx| {
let ix = pane.index_for_item_id(item_d.item_id()).unwrap();
pane.pin_tab_at(ix, window, cx);
});
assert_item_labels(&pane, ["A!", "B!", "C!", "D*!"], cx);
add_labeled_item(&pane, "E", false, cx);
assert_item_labels(&pane, ["A!", "B!", "C!", "D!", "E*"], cx);
add_labeled_item(&pane, "F", false, cx);
assert_item_labels(&pane, ["A!", "B!", "C!", "D!", "F*"], cx);
}
#[gpui::test]
async fn test_can_open_one_item_when_all_tabs_are_dirty_at_max(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, None, cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
set_max_tabs(cx, Some(3));
add_labeled_item(&pane, "A", true, cx);
assert_item_labels(&pane, ["A*^"], cx);
add_labeled_item(&pane, "B", true, cx);
assert_item_labels(&pane, ["A^", "B*^"], cx);
add_labeled_item(&pane, "C", true, cx);
assert_item_labels(&pane, ["A^", "B^", "C*^"], cx);
add_labeled_item(&pane, "D", false, cx);
assert_item_labels(&pane, ["A^", "B^", "C^", "D*"], cx);
add_labeled_item(&pane, "E", false, cx);
assert_item_labels(&pane, ["A^", "B^", "C^", "E*"], cx);
add_labeled_item(&pane, "F", false, cx);
assert_item_labels(&pane, ["A^", "B^", "C^", "F*"], cx);
add_labeled_item(&pane, "G", true, cx);
assert_item_labels(&pane, ["A^", "B^", "C^", "G*^"], cx);
}
#[gpui::test]
async fn test_toggle_pin_tab(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, None, cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
set_labeled_items(&pane, ["A", "B*", "C"], cx);
assert_item_labels(&pane, ["A", "B*", "C"], cx);
pane.update_in(cx, |pane, window, cx| {
pane.toggle_pin_tab(&TogglePinTab, window, cx);
});
assert_item_labels(&pane, ["B*!", "A", "C"], cx);
pane.update_in(cx, |pane, window, cx| {
pane.toggle_pin_tab(&TogglePinTab, window, cx);
});
assert_item_labels(&pane, ["B*", "A", "C"], cx);
}
#[gpui::test]
async fn test_unpin_all_tabs(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, None, cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
// Unpin all, in an empty pane
pane.update_in(cx, |pane, window, cx| {
pane.unpin_all_tabs(&UnpinAllTabs, window, cx);
});
assert_item_labels(&pane, [], cx);
let item_a = add_labeled_item(&pane, "A", false, cx);
let item_b = add_labeled_item(&pane, "B", false, cx);
let item_c = add_labeled_item(&pane, "C", false, cx);
assert_item_labels(&pane, ["A", "B", "C*"], cx);
// Unpin all, when no tabs are pinned
pane.update_in(cx, |pane, window, cx| {
pane.unpin_all_tabs(&UnpinAllTabs, window, cx);
});
assert_item_labels(&pane, ["A", "B", "C*"], cx);
// Pin inactive tabs only
pane.update_in(cx, |pane, window, cx| {
let ix = pane.index_for_item_id(item_a.item_id()).unwrap();
pane.pin_tab_at(ix, window, cx);
let ix = pane.index_for_item_id(item_b.item_id()).unwrap();
pane.pin_tab_at(ix, window, cx);
});
assert_item_labels(&pane, ["A!", "B!", "C*"], cx);
pane.update_in(cx, |pane, window, cx| {
pane.unpin_all_tabs(&UnpinAllTabs, window, cx);
});
assert_item_labels(&pane, ["A", "B", "C*"], cx);
// Pin all tabs
pane.update_in(cx, |pane, window, cx| {
let ix = pane.index_for_item_id(item_a.item_id()).unwrap();
pane.pin_tab_at(ix, window, cx);
let ix = pane.index_for_item_id(item_b.item_id()).unwrap();
pane.pin_tab_at(ix, window, cx);
let ix = pane.index_for_item_id(item_c.item_id()).unwrap();
pane.pin_tab_at(ix, window, cx);
});
assert_item_labels(&pane, ["A!", "B!", "C*!"], cx);
// Activate middle tab
pane.update_in(cx, |pane, window, cx| {
pane.activate_item(1, false, false, window, cx);
});
assert_item_labels(&pane, ["A!", "B*!", "C!"], cx);
pane.update_in(cx, |pane, window, cx| {
pane.unpin_all_tabs(&UnpinAllTabs, window, cx);
});
// Order has not changed
assert_item_labels(&pane, ["A", "B*", "C"], cx);
}
#[gpui::test]
async fn test_pinning_active_tab_without_position_change_maintains_focus(
cx: &mut TestAppContext,
) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, None, cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
// Add A
let item_a = add_labeled_item(&pane, "A", false, cx);
assert_item_labels(&pane, ["A*"], cx);
// Add B
add_labeled_item(&pane, "B", false, cx);
assert_item_labels(&pane, ["A", "B*"], cx);
// Activate A again
pane.update_in(cx, |pane, window, cx| {
let ix = pane.index_for_item_id(item_a.item_id()).unwrap();
pane.activate_item(ix, true, true, window, cx);
});
assert_item_labels(&pane, ["A*", "B"], cx);
// Pin A - remains active
pane.update_in(cx, |pane, window, cx| {
let ix = pane.index_for_item_id(item_a.item_id()).unwrap();
pane.pin_tab_at(ix, window, cx);
});
assert_item_labels(&pane, ["A*!", "B"], cx);
// Unpin A - remain active
pane.update_in(cx, |pane, window, cx| {
let ix = pane.index_for_item_id(item_a.item_id()).unwrap();
pane.unpin_tab_at(ix, window, cx);
});
assert_item_labels(&pane, ["A*", "B"], cx);
}
#[gpui::test]
async fn test_pinning_active_tab_with_position_change_maintains_focus(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, None, cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
// Add A, B, C
add_labeled_item(&pane, "A", false, cx);
add_labeled_item(&pane, "B", false, cx);
let item_c = add_labeled_item(&pane, "C", false, cx);
assert_item_labels(&pane, ["A", "B", "C*"], cx);
// Pin C - moves to pinned area, remains active
pane.update_in(cx, |pane, window, cx| {
let ix = pane.index_for_item_id(item_c.item_id()).unwrap();
pane.pin_tab_at(ix, window, cx);
});
assert_item_labels(&pane, ["C*!", "A", "B"], cx);
// Unpin C - moves after pinned area, remains active
pane.update_in(cx, |pane, window, cx| {
let ix = pane.index_for_item_id(item_c.item_id()).unwrap();
pane.unpin_tab_at(ix, window, cx);
});
assert_item_labels(&pane, ["C*", "A", "B"], cx);
}
#[gpui::test]
async fn test_pinning_inactive_tab_without_position_change_preserves_existing_focus(
cx: &mut TestAppContext,
) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, None, cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
// Add A, B
let item_a = add_labeled_item(&pane, "A", false, cx);
add_labeled_item(&pane, "B", false, cx);
assert_item_labels(&pane, ["A", "B*"], cx);
// Pin A - already in pinned area, B remains active
pane.update_in(cx, |pane, window, cx| {
let ix = pane.index_for_item_id(item_a.item_id()).unwrap();
pane.pin_tab_at(ix, window, cx);
});
assert_item_labels(&pane, ["A!", "B*"], cx);
// Unpin A - stays in place, B remains active
pane.update_in(cx, |pane, window, cx| {
let ix = pane.index_for_item_id(item_a.item_id()).unwrap();
pane.unpin_tab_at(ix, window, cx);
});
assert_item_labels(&pane, ["A", "B*"], cx);
}
#[gpui::test]
async fn test_pinning_inactive_tab_with_position_change_preserves_existing_focus(
cx: &mut TestAppContext,
) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, None, cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
// Add A, B, C
add_labeled_item(&pane, "A", false, cx);
let item_b = add_labeled_item(&pane, "B", false, cx);
let item_c = add_labeled_item(&pane, "C", false, cx);
assert_item_labels(&pane, ["A", "B", "C*"], cx);
// Activate B
pane.update_in(cx, |pane, window, cx| {
let ix = pane.index_for_item_id(item_b.item_id()).unwrap();
pane.activate_item(ix, true, true, window, cx);
});
assert_item_labels(&pane, ["A", "B*", "C"], cx);
// Pin C - moves to pinned area, B remains active
pane.update_in(cx, |pane, window, cx| {
let ix = pane.index_for_item_id(item_c.item_id()).unwrap();
pane.pin_tab_at(ix, window, cx);
});
assert_item_labels(&pane, ["C!", "A", "B*"], cx);
// Unpin C - moves after pinned area, B remains active
pane.update_in(cx, |pane, window, cx| {
let ix = pane.index_for_item_id(item_c.item_id()).unwrap();
pane.unpin_tab_at(ix, window, cx);
});
assert_item_labels(&pane, ["C", "A", "B*"], cx);
}
#[gpui::test]
async fn test_drag_unpinned_tab_to_split_creates_pane_with_unpinned_tab(
cx: &mut TestAppContext,
) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, None, cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let pane_a = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
// Add A, B. Pin B. Activate A
let item_a = add_labeled_item(&pane_a, "A", false, cx);
let item_b = add_labeled_item(&pane_a, "B", false, cx);
pane_a.update_in(cx, |pane, window, cx| {
let ix = pane.index_for_item_id(item_b.item_id()).unwrap();
pane.pin_tab_at(ix, window, cx);
let ix = pane.index_for_item_id(item_a.item_id()).unwrap();
pane.activate_item(ix, true, true, window, cx);
});
// Drag A to create new split
pane_a.update_in(cx, |pane, window, cx| {
pane.drag_split_direction = Some(SplitDirection::Right);
let dragged_tab = DraggedTab {
pane: pane_a.clone(),
item: item_a.boxed_clone(),
ix: 0,
detail: 0,
is_active: true,
};
pane.handle_tab_drop(&dragged_tab, 0, window, cx);
});
// A should be moved to new pane. B should remain pinned, A should not be pinned
let (pane_a, pane_b) = workspace.read_with(cx, |workspace, _| {
let panes = workspace.panes();
(panes[0].clone(), panes[1].clone())
});
assert_item_labels(&pane_a, ["B*!"], cx);
assert_item_labels(&pane_b, ["A*"], cx);
}
#[gpui::test]
async fn test_drag_pinned_tab_to_split_creates_pane_with_pinned_tab(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, None, cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let pane_a = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
// Add A, B. Pin both. Activate A
let item_a = add_labeled_item(&pane_a, "A", false, cx);
let item_b = add_labeled_item(&pane_a, "B", false, cx);
pane_a.update_in(cx, |pane, window, cx| {
let ix = pane.index_for_item_id(item_a.item_id()).unwrap();
pane.pin_tab_at(ix, window, cx);
let ix = pane.index_for_item_id(item_b.item_id()).unwrap();
pane.pin_tab_at(ix, window, cx);
let ix = pane.index_for_item_id(item_a.item_id()).unwrap();
pane.activate_item(ix, true, true, window, cx);
});
assert_item_labels(&pane_a, ["A*!", "B!"], cx);
// Drag A to create new split
pane_a.update_in(cx, |pane, window, cx| {
pane.drag_split_direction = Some(SplitDirection::Right);
let dragged_tab = DraggedTab {
pane: pane_a.clone(),
item: item_a.boxed_clone(),
ix: 0,
detail: 0,
is_active: true,
};
pane.handle_tab_drop(&dragged_tab, 0, window, cx);
});
// A should be moved to new pane. Both A and B should still be pinned
let (pane_a, pane_b) = workspace.read_with(cx, |workspace, _| {
let panes = workspace.panes();
(panes[0].clone(), panes[1].clone())
});
assert_item_labels(&pane_a, ["B*!"], cx);
assert_item_labels(&pane_b, ["A*!"], cx);
}
#[gpui::test]
async fn test_drag_pinned_tab_into_existing_panes_pinned_region(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, None, cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let pane_a = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
// Add A to pane A and pin
let item_a = add_labeled_item(&pane_a, "A", false, cx);
pane_a.update_in(cx, |pane, window, cx| {
let ix = pane.index_for_item_id(item_a.item_id()).unwrap();
pane.pin_tab_at(ix, window, cx);
});
assert_item_labels(&pane_a, ["A*!"], cx);
// Add B to pane B and pin
let pane_b = workspace.update_in(cx, |workspace, window, cx| {
workspace.split_pane(pane_a.clone(), SplitDirection::Right, window, cx)
});
let item_b = add_labeled_item(&pane_b, "B", false, cx);
pane_b.update_in(cx, |pane, window, cx| {
let ix = pane.index_for_item_id(item_b.item_id()).unwrap();
pane.pin_tab_at(ix, window, cx);
});
assert_item_labels(&pane_b, ["B*!"], cx);
// Move A from pane A to pane B's pinned region
pane_b.update_in(cx, |pane, window, cx| {
let dragged_tab = DraggedTab {
pane: pane_a.clone(),
item: item_a.boxed_clone(),
ix: 0,
detail: 0,
is_active: true,
};
pane.handle_tab_drop(&dragged_tab, 0, window, cx);
});
// A should stay pinned
assert_item_labels(&pane_a, [], cx);
assert_item_labels(&pane_b, ["A*!", "B!"], cx);
}
#[gpui::test]
async fn test_drag_pinned_tab_into_existing_panes_unpinned_region(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, None, cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let pane_a = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
// Add A to pane A and pin
let item_a = add_labeled_item(&pane_a, "A", false, cx);
pane_a.update_in(cx, |pane, window, cx| {
let ix = pane.index_for_item_id(item_a.item_id()).unwrap();
pane.pin_tab_at(ix, window, cx);
});
assert_item_labels(&pane_a, ["A*!"], cx);
// Create pane B with pinned item B
let pane_b = workspace.update_in(cx, |workspace, window, cx| {
workspace.split_pane(pane_a.clone(), SplitDirection::Right, window, cx)
});
let item_b = add_labeled_item(&pane_b, "B", false, cx);
assert_item_labels(&pane_b, ["B*"], cx);
pane_b.update_in(cx, |pane, window, cx| {
let ix = pane.index_for_item_id(item_b.item_id()).unwrap();
pane.pin_tab_at(ix, window, cx);
});
assert_item_labels(&pane_b, ["B*!"], cx);
// Move A from pane A to pane B's unpinned region
pane_b.update_in(cx, |pane, window, cx| {
let dragged_tab = DraggedTab {
pane: pane_a.clone(),
item: item_a.boxed_clone(),
ix: 0,
detail: 0,
is_active: true,
};
pane.handle_tab_drop(&dragged_tab, 1, window, cx);
});
// A should become pinned
assert_item_labels(&pane_a, [], cx);
assert_item_labels(&pane_b, ["B!", "A*"], cx);
}
#[gpui::test]
async fn test_drag_pinned_tab_into_existing_panes_first_position_with_no_pinned_tabs(
cx: &mut TestAppContext,
) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, None, cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let pane_a = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
// Add A to pane A and pin
let item_a = add_labeled_item(&pane_a, "A", false, cx);
pane_a.update_in(cx, |pane, window, cx| {
let ix = pane.index_for_item_id(item_a.item_id()).unwrap();
pane.pin_tab_at(ix, window, cx);
});
assert_item_labels(&pane_a, ["A*!"], cx);
// Add B to pane B
let pane_b = workspace.update_in(cx, |workspace, window, cx| {
workspace.split_pane(pane_a.clone(), SplitDirection::Right, window, cx)
});
add_labeled_item(&pane_b, "B", false, cx);
assert_item_labels(&pane_b, ["B*"], cx);
// Move A from pane A to position 0 in pane B, indicating it should stay pinned
pane_b.update_in(cx, |pane, window, cx| {
let dragged_tab = DraggedTab {
pane: pane_a.clone(),
item: item_a.boxed_clone(),
ix: 0,
detail: 0,
is_active: true,
};
pane.handle_tab_drop(&dragged_tab, 0, window, cx);
});
// A should stay pinned
assert_item_labels(&pane_a, [], cx);
assert_item_labels(&pane_b, ["A*!", "B"], cx);
}
#[gpui::test]
async fn test_drag_pinned_tab_into_existing_pane_at_max_capacity_closes_unpinned_tabs(
cx: &mut TestAppContext,
) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, None, cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let pane_a = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
set_max_tabs(cx, Some(2));
// Add A, B to pane A. Pin both
let item_a = add_labeled_item(&pane_a, "A", false, cx);
let item_b = add_labeled_item(&pane_a, "B", false, cx);
pane_a.update_in(cx, |pane, window, cx| {
let ix = pane.index_for_item_id(item_a.item_id()).unwrap();
pane.pin_tab_at(ix, window, cx);
let ix = pane.index_for_item_id(item_b.item_id()).unwrap();
pane.pin_tab_at(ix, window, cx);
});
assert_item_labels(&pane_a, ["A!", "B*!"], cx);
// Add C, D to pane B. Pin both
let pane_b = workspace.update_in(cx, |workspace, window, cx| {
workspace.split_pane(pane_a.clone(), SplitDirection::Right, window, cx)
});
let item_c = add_labeled_item(&pane_b, "C", false, cx);
let item_d = add_labeled_item(&pane_b, "D", false, cx);
pane_b.update_in(cx, |pane, window, cx| {
let ix = pane.index_for_item_id(item_c.item_id()).unwrap();
pane.pin_tab_at(ix, window, cx);
let ix = pane.index_for_item_id(item_d.item_id()).unwrap();
pane.pin_tab_at(ix, window, cx);
});
assert_item_labels(&pane_b, ["C!", "D*!"], cx);
// Add a third unpinned item to pane B (exceeds max tabs), but is allowed,
// as we allow 1 tab over max if the others are pinned or dirty
add_labeled_item(&pane_b, "E", false, cx);
assert_item_labels(&pane_b, ["C!", "D!", "E*"], cx);
// Drag pinned A from pane A to position 0 in pane B
pane_b.update_in(cx, |pane, window, cx| {
let dragged_tab = DraggedTab {
pane: pane_a.clone(),
item: item_a.boxed_clone(),
ix: 0,
detail: 0,
is_active: true,
};
pane.handle_tab_drop(&dragged_tab, 0, window, cx);
});
// E (unpinned) should be closed, leaving 3 pinned items
assert_item_labels(&pane_a, ["B*!"], cx);
assert_item_labels(&pane_b, ["A*!", "C!", "D!"], cx);
}
#[gpui::test]
async fn test_drag_last_pinned_tab_to_same_position_stays_pinned(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, None, cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let pane_a = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
// Add A to pane A and pin it
let item_a = add_labeled_item(&pane_a, "A", false, cx);
pane_a.update_in(cx, |pane, window, cx| {
let ix = pane.index_for_item_id(item_a.item_id()).unwrap();
pane.pin_tab_at(ix, window, cx);
});
assert_item_labels(&pane_a, ["A*!"], cx);
// Drag pinned A to position 1 (directly to the right) in the same pane
pane_a.update_in(cx, |pane, window, cx| {
let dragged_tab = DraggedTab {
pane: pane_a.clone(),
item: item_a.boxed_clone(),
ix: 0,
detail: 0,
is_active: true,
};
pane.handle_tab_drop(&dragged_tab, 1, window, cx);
});
// A should still be pinned and active
assert_item_labels(&pane_a, ["A*!"], cx);
}
#[gpui::test]
async fn test_drag_pinned_tab_beyond_last_pinned_tab_in_same_pane_stays_pinned(
cx: &mut TestAppContext,
) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, None, cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let pane_a = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
// Add A, B to pane A and pin both
let item_a = add_labeled_item(&pane_a, "A", false, cx);
let item_b = add_labeled_item(&pane_a, "B", false, cx);
pane_a.update_in(cx, |pane, window, cx| {
let ix = pane.index_for_item_id(item_a.item_id()).unwrap();
pane.pin_tab_at(ix, window, cx);
let ix = pane.index_for_item_id(item_b.item_id()).unwrap();
pane.pin_tab_at(ix, window, cx);
});
assert_item_labels(&pane_a, ["A!", "B*!"], cx);
// Drag pinned A right of B in the same pane
pane_a.update_in(cx, |pane, window, cx| {
let dragged_tab = DraggedTab {
pane: pane_a.clone(),
item: item_a.boxed_clone(),
ix: 0,
detail: 0,
is_active: true,
};
pane.handle_tab_drop(&dragged_tab, 2, window, cx);
});
// A stays pinned
assert_item_labels(&pane_a, ["B!", "A*!"], cx);
}
#[gpui::test]
async fn test_dragging_pinned_tab_onto_unpinned_tab_reduces_unpinned_tab_count(
cx: &mut TestAppContext,
) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, None, cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let pane_a = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
// Add A, B to pane A and pin A
let item_a = add_labeled_item(&pane_a, "A", false, cx);
add_labeled_item(&pane_a, "B", false, cx);
pane_a.update_in(cx, |pane, window, cx| {
let ix = pane.index_for_item_id(item_a.item_id()).unwrap();
pane.pin_tab_at(ix, window, cx);
});
assert_item_labels(&pane_a, ["A!", "B*"], cx);
// Drag pinned A on top of B in the same pane, which changes tab order to B, A
pane_a.update_in(cx, |pane, window, cx| {
let dragged_tab = DraggedTab {
pane: pane_a.clone(),
item: item_a.boxed_clone(),
ix: 0,
detail: 0,
is_active: true,
};
pane.handle_tab_drop(&dragged_tab, 1, window, cx);
});
// Neither are pinned
assert_item_labels(&pane_a, ["B", "A*"], cx);
}
#[gpui::test]
async fn test_drag_pinned_tab_beyond_unpinned_tab_in_same_pane_becomes_unpinned(
cx: &mut TestAppContext,
) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, None, cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let pane_a = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
// Add A, B to pane A and pin A
let item_a = add_labeled_item(&pane_a, "A", false, cx);
add_labeled_item(&pane_a, "B", false, cx);
pane_a.update_in(cx, |pane, window, cx| {
let ix = pane.index_for_item_id(item_a.item_id()).unwrap();
pane.pin_tab_at(ix, window, cx);
});
assert_item_labels(&pane_a, ["A!", "B*"], cx);
// Drag pinned A right of B in the same pane
pane_a.update_in(cx, |pane, window, cx| {
let dragged_tab = DraggedTab {
pane: pane_a.clone(),
item: item_a.boxed_clone(),
ix: 0,
detail: 0,
is_active: true,
};
pane.handle_tab_drop(&dragged_tab, 2, window, cx);
});
// A becomes unpinned
assert_item_labels(&pane_a, ["B", "A*"], cx);
}
#[gpui::test]
async fn test_drag_unpinned_tab_in_front_of_pinned_tab_in_same_pane_becomes_pinned(
cx: &mut TestAppContext,
) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, None, cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let pane_a = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
// Add A, B to pane A and pin A
let item_a = add_labeled_item(&pane_a, "A", false, cx);
let item_b = add_labeled_item(&pane_a, "B", false, cx);
pane_a.update_in(cx, |pane, window, cx| {
let ix = pane.index_for_item_id(item_a.item_id()).unwrap();
pane.pin_tab_at(ix, window, cx);
});
assert_item_labels(&pane_a, ["A!", "B*"], cx);
// Drag pinned B left of A in the same pane
pane_a.update_in(cx, |pane, window, cx| {
let dragged_tab = DraggedTab {
pane: pane_a.clone(),
item: item_b.boxed_clone(),
ix: 1,
detail: 0,
is_active: true,
};
pane.handle_tab_drop(&dragged_tab, 0, window, cx);
});
// A becomes unpinned
assert_item_labels(&pane_a, ["B*!", "A!"], cx);
}
#[gpui::test]
async fn test_drag_unpinned_tab_to_the_pinned_region_stays_pinned(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, None, cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let pane_a = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
// Add A, B, C to pane A and pin A
let item_a = add_labeled_item(&pane_a, "A", false, cx);
add_labeled_item(&pane_a, "B", false, cx);
let item_c = add_labeled_item(&pane_a, "C", false, cx);
pane_a.update_in(cx, |pane, window, cx| {
let ix = pane.index_for_item_id(item_a.item_id()).unwrap();
pane.pin_tab_at(ix, window, cx);
});
assert_item_labels(&pane_a, ["A!", "B", "C*"], cx);
// Drag pinned C left of B in the same pane
pane_a.update_in(cx, |pane, window, cx| {
let dragged_tab = DraggedTab {
pane: pane_a.clone(),
item: item_c.boxed_clone(),
ix: 2,
detail: 0,
is_active: true,
};
pane.handle_tab_drop(&dragged_tab, 1, window, cx);
});
// A stays pinned, B and C remain unpinned
assert_item_labels(&pane_a, ["A!", "C*", "B"], cx);
}
#[gpui::test]
async fn test_drag_unpinned_tab_into_existing_panes_pinned_region(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, None, cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let pane_a = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
// Add unpinned item A to pane A
let item_a = add_labeled_item(&pane_a, "A", false, cx);
assert_item_labels(&pane_a, ["A*"], cx);
// Create pane B with pinned item B
let pane_b = workspace.update_in(cx, |workspace, window, cx| {
workspace.split_pane(pane_a.clone(), SplitDirection::Right, window, cx)
});
let item_b = add_labeled_item(&pane_b, "B", false, cx);
pane_b.update_in(cx, |pane, window, cx| {
let ix = pane.index_for_item_id(item_b.item_id()).unwrap();
pane.pin_tab_at(ix, window, cx);
});
assert_item_labels(&pane_b, ["B*!"], cx);
// Move A from pane A to pane B's pinned region
pane_b.update_in(cx, |pane, window, cx| {
let dragged_tab = DraggedTab {
pane: pane_a.clone(),
item: item_a.boxed_clone(),
ix: 0,
detail: 0,
is_active: true,
};
pane.handle_tab_drop(&dragged_tab, 0, window, cx);
});
// A should become pinned since it was dropped in the pinned region
assert_item_labels(&pane_a, [], cx);
assert_item_labels(&pane_b, ["A*!", "B!"], cx);
}
#[gpui::test]
async fn test_drag_unpinned_tab_into_existing_panes_unpinned_region(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, None, cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let pane_a = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
// Add unpinned item A to pane A
let item_a = add_labeled_item(&pane_a, "A", false, cx);
assert_item_labels(&pane_a, ["A*"], cx);
// Create pane B with one pinned item B
let pane_b = workspace.update_in(cx, |workspace, window, cx| {
workspace.split_pane(pane_a.clone(), SplitDirection::Right, window, cx)
});
let item_b = add_labeled_item(&pane_b, "B", false, cx);
pane_b.update_in(cx, |pane, window, cx| {
let ix = pane.index_for_item_id(item_b.item_id()).unwrap();
pane.pin_tab_at(ix, window, cx);
});
assert_item_labels(&pane_b, ["B*!"], cx);
// Move A from pane A to pane B's unpinned region
pane_b.update_in(cx, |pane, window, cx| {
let dragged_tab = DraggedTab {
pane: pane_a.clone(),
item: item_a.boxed_clone(),
ix: 0,
detail: 0,
is_active: true,
};
pane.handle_tab_drop(&dragged_tab, 1, window, cx);
});
// A should remain unpinned since it was dropped outside the pinned region
assert_item_labels(&pane_a, [], cx);
assert_item_labels(&pane_b, ["B!", "A*"], cx);
}
#[gpui::test]
async fn test_drag_pinned_tab_throughout_entire_range_of_pinned_tabs_both_directions(
cx: &mut TestAppContext,
) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, None, cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let pane_a = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
// Add A, B, C and pin all
let item_a = add_labeled_item(&pane_a, "A", false, cx);
let item_b = add_labeled_item(&pane_a, "B", false, cx);
let item_c = add_labeled_item(&pane_a, "C", false, cx);
assert_item_labels(&pane_a, ["A", "B", "C*"], cx);
pane_a.update_in(cx, |pane, window, cx| {
let ix = pane.index_for_item_id(item_a.item_id()).unwrap();
pane.pin_tab_at(ix, window, cx);
let ix = pane.index_for_item_id(item_b.item_id()).unwrap();
pane.pin_tab_at(ix, window, cx);
let ix = pane.index_for_item_id(item_c.item_id()).unwrap();
pane.pin_tab_at(ix, window, cx);
});
assert_item_labels(&pane_a, ["A!", "B!", "C*!"], cx);
// Move A to right of B
pane_a.update_in(cx, |pane, window, cx| {
let dragged_tab = DraggedTab {
pane: pane_a.clone(),
item: item_a.boxed_clone(),
ix: 0,
detail: 0,
is_active: true,
};
pane.handle_tab_drop(&dragged_tab, 1, window, cx);
});
// A should be after B and all are pinned
assert_item_labels(&pane_a, ["B!", "A*!", "C!"], cx);
// Move A to right of C
pane_a.update_in(cx, |pane, window, cx| {
let dragged_tab = DraggedTab {
pane: pane_a.clone(),
item: item_a.boxed_clone(),
ix: 1,
detail: 0,
is_active: true,
};
pane.handle_tab_drop(&dragged_tab, 2, window, cx);
});
// A should be after C and all are pinned
assert_item_labels(&pane_a, ["B!", "C!", "A*!"], cx);
// Move A to left of C
pane_a.update_in(cx, |pane, window, cx| {
let dragged_tab = DraggedTab {
pane: pane_a.clone(),
item: item_a.boxed_clone(),
ix: 2,
detail: 0,
is_active: true,
};
pane.handle_tab_drop(&dragged_tab, 1, window, cx);
});
// A should be before C and all are pinned
assert_item_labels(&pane_a, ["B!", "A*!", "C!"], cx);
// Move A to left of B
pane_a.update_in(cx, |pane, window, cx| {
let dragged_tab = DraggedTab {
pane: pane_a.clone(),
item: item_a.boxed_clone(),
ix: 1,
detail: 0,
is_active: true,
};
pane.handle_tab_drop(&dragged_tab, 0, window, cx);
});
// A should be before B and all are pinned
assert_item_labels(&pane_a, ["A*!", "B!", "C!"], cx);
}
#[gpui::test]
async fn test_drag_first_tab_to_last_position(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, None, cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let pane_a = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
// Add A, B, C
let item_a = add_labeled_item(&pane_a, "A", false, cx);
add_labeled_item(&pane_a, "B", false, cx);
add_labeled_item(&pane_a, "C", false, cx);
assert_item_labels(&pane_a, ["A", "B", "C*"], cx);
// Move A to the end
pane_a.update_in(cx, |pane, window, cx| {
let dragged_tab = DraggedTab {
pane: pane_a.clone(),
item: item_a.boxed_clone(),
ix: 0,
detail: 0,
is_active: true,
};
pane.handle_tab_drop(&dragged_tab, 2, window, cx);
});
// A should be at the end
assert_item_labels(&pane_a, ["B", "C", "A*"], cx);
}
#[gpui::test]
async fn test_drag_last_tab_to_first_position(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, None, cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let pane_a = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
// Add A, B, C
add_labeled_item(&pane_a, "A", false, cx);
add_labeled_item(&pane_a, "B", false, cx);
let item_c = add_labeled_item(&pane_a, "C", false, cx);
assert_item_labels(&pane_a, ["A", "B", "C*"], cx);
// Move C to the beginning
pane_a.update_in(cx, |pane, window, cx| {
let dragged_tab = DraggedTab {
pane: pane_a.clone(),
item: item_c.boxed_clone(),
ix: 2,
detail: 0,
is_active: true,
};
pane.handle_tab_drop(&dragged_tab, 0, window, cx);
});
// C should be at the beginning
assert_item_labels(&pane_a, ["C*", "A", "B"], cx);
}
#[gpui::test]
async fn test_add_item_with_new_item(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, None, cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
// 1. Add with a destination index
// a. Add before the active item
set_labeled_items(&pane, ["A", "B*", "C"], cx);
pane.update_in(cx, |pane, window, cx| {
pane.add_item(
Box::new(cx.new(|cx| TestItem::new(cx).with_label("D"))),
false,
false,
Some(0),
window,
cx,
);
});
assert_item_labels(&pane, ["D*", "A", "B", "C"], cx);
// b. Add after the active item
set_labeled_items(&pane, ["A", "B*", "C"], cx);
pane.update_in(cx, |pane, window, cx| {
pane.add_item(
Box::new(cx.new(|cx| TestItem::new(cx).with_label("D"))),
false,
false,
Some(2),
window,
cx,
);
});
assert_item_labels(&pane, ["A", "B", "D*", "C"], cx);
// c. Add at the end of the item list (including off the length)
set_labeled_items(&pane, ["A", "B*", "C"], cx);
pane.update_in(cx, |pane, window, cx| {
pane.add_item(
Box::new(cx.new(|cx| TestItem::new(cx).with_label("D"))),
false,
false,
Some(5),
window,
cx,
);
});
assert_item_labels(&pane, ["A", "B", "C", "D*"], cx);
// 2. Add without a destination index
// a. Add with active item at the start of the item list
set_labeled_items(&pane, ["A*", "B", "C"], cx);
pane.update_in(cx, |pane, window, cx| {
pane.add_item(
Box::new(cx.new(|cx| TestItem::new(cx).with_label("D"))),
false,
false,
None,
window,
cx,
);
});
set_labeled_items(&pane, ["A", "D*", "B", "C"], cx);
// b. Add with active item at the end of the item list
set_labeled_items(&pane, ["A", "B", "C*"], cx);
pane.update_in(cx, |pane, window, cx| {
pane.add_item(
Box::new(cx.new(|cx| TestItem::new(cx).with_label("D"))),
false,
false,
None,
window,
cx,
);
});
assert_item_labels(&pane, ["A", "B", "C", "D*"], cx);
}
#[gpui::test]
async fn test_add_item_with_existing_item(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, None, cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
// 1. Add with a destination index
// 1a. Add before the active item
let [_, _, _, d] = set_labeled_items(&pane, ["A", "B*", "C", "D"], cx);
pane.update_in(cx, |pane, window, cx| {
pane.add_item(d, false, false, Some(0), window, cx);
});
assert_item_labels(&pane, ["D*", "A", "B", "C"], cx);
// 1b. Add after the active item
let [_, _, _, d] = set_labeled_items(&pane, ["A", "B*", "C", "D"], cx);
pane.update_in(cx, |pane, window, cx| {
pane.add_item(d, false, false, Some(2), window, cx);
});
assert_item_labels(&pane, ["A", "B", "D*", "C"], cx);
// 1c. Add at the end of the item list (including off the length)
let [a, _, _, _] = set_labeled_items(&pane, ["A", "B*", "C", "D"], cx);
pane.update_in(cx, |pane, window, cx| {
pane.add_item(a, false, false, Some(5), window, cx);
});
assert_item_labels(&pane, ["B", "C", "D", "A*"], cx);
// 1d. Add same item to active index
let [_, b, _] = set_labeled_items(&pane, ["A", "B*", "C"], cx);
pane.update_in(cx, |pane, window, cx| {
pane.add_item(b, false, false, Some(1), window, cx);
});
assert_item_labels(&pane, ["A", "B*", "C"], cx);
// 1e. Add item to index after same item in last position
let [_, _, c] = set_labeled_items(&pane, ["A", "B*", "C"], cx);
pane.update_in(cx, |pane, window, cx| {
pane.add_item(c, false, false, Some(2), window, cx);
});
assert_item_labels(&pane, ["A", "B", "C*"], cx);
// 2. Add without a destination index
// 2a. Add with active item at the start of the item list
let [_, _, _, d] = set_labeled_items(&pane, ["A*", "B", "C", "D"], cx);
pane.update_in(cx, |pane, window, cx| {
pane.add_item(d, false, false, None, window, cx);
});
assert_item_labels(&pane, ["A", "D*", "B", "C"], cx);
// 2b. Add with active item at the end of the item list
let [a, _, _, _] = set_labeled_items(&pane, ["A", "B", "C", "D*"], cx);
pane.update_in(cx, |pane, window, cx| {
pane.add_item(a, false, false, None, window, cx);
});
assert_item_labels(&pane, ["B", "C", "D", "A*"], cx);
// 2c. Add active item to active item at end of list
let [_, _, c] = set_labeled_items(&pane, ["A", "B", "C*"], cx);
pane.update_in(cx, |pane, window, cx| {
pane.add_item(c, false, false, None, window, cx);
});
assert_item_labels(&pane, ["A", "B", "C*"], cx);
// 2d. Add active item to active item at start of list
let [a, _, _] = set_labeled_items(&pane, ["A*", "B", "C"], cx);
pane.update_in(cx, |pane, window, cx| {
pane.add_item(a, false, false, None, window, cx);
});
assert_item_labels(&pane, ["A*", "B", "C"], cx);
}
#[gpui::test]
async fn test_add_item_with_same_project_entries(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, None, cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
// singleton view
pane.update_in(cx, |pane, window, cx| {
pane.add_item(
Box::new(cx.new(|cx| {
TestItem::new(cx)
.with_singleton(true)
.with_label("buffer 1")
.with_project_items(&[TestProjectItem::new(1, "one.txt", cx)])
})),
false,
false,
None,
window,
cx,
);
});
assert_item_labels(&pane, ["buffer 1*"], cx);
// new singleton view with the same project entry
pane.update_in(cx, |pane, window, cx| {
pane.add_item(
Box::new(cx.new(|cx| {
TestItem::new(cx)
.with_singleton(true)
.with_label("buffer 1")
.with_project_items(&[TestProjectItem::new(1, "1.txt", cx)])
})),
false,
false,
None,
window,
cx,
);
});
assert_item_labels(&pane, ["buffer 1*"], cx);
// new singleton view with different project entry
pane.update_in(cx, |pane, window, cx| {
pane.add_item(
Box::new(cx.new(|cx| {
TestItem::new(cx)
.with_singleton(true)
.with_label("buffer 2")
.with_project_items(&[TestProjectItem::new(2, "2.txt", cx)])
})),
false,
false,
None,
window,
cx,
);
});
assert_item_labels(&pane, ["buffer 1", "buffer 2*"], cx);
// new multibuffer view with the same project entry
pane.update_in(cx, |pane, window, cx| {
pane.add_item(
Box::new(cx.new(|cx| {
TestItem::new(cx)
.with_singleton(false)
.with_label("multibuffer 1")
.with_project_items(&[TestProjectItem::new(1, "1.txt", cx)])
})),
false,
false,
None,
window,
cx,
);
});
assert_item_labels(&pane, ["buffer 1", "buffer 2", "multibuffer 1*"], cx);
// another multibuffer view with the same project entry
pane.update_in(cx, |pane, window, cx| {
pane.add_item(
Box::new(cx.new(|cx| {
TestItem::new(cx)
.with_singleton(false)
.with_label("multibuffer 1b")
.with_project_items(&[TestProjectItem::new(1, "1.txt", cx)])
})),
false,
false,
None,
window,
cx,
);
});
assert_item_labels(
&pane,
["buffer 1", "buffer 2", "multibuffer 1", "multibuffer 1b*"],
cx,
);
}
#[gpui::test]
async fn test_remove_item_ordering_history(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, None, cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
add_labeled_item(&pane, "A", false, cx);
add_labeled_item(&pane, "B", false, cx);
add_labeled_item(&pane, "C", false, cx);
add_labeled_item(&pane, "D", false, cx);
assert_item_labels(&pane, ["A", "B", "C", "D*"], cx);
pane.update_in(cx, |pane, window, cx| {
pane.activate_item(1, false, false, window, cx)
});
add_labeled_item(&pane, "1", false, cx);
assert_item_labels(&pane, ["A", "B", "1*", "C", "D"], cx);
pane.update_in(cx, |pane, window, cx| {
pane.close_active_item(
&CloseActiveItem {
save_intent: None,
close_pinned: false,
},
window,
cx,
)
})
.await
.unwrap();
assert_item_labels(&pane, ["A", "B*", "C", "D"], cx);
pane.update_in(cx, |pane, window, cx| {
pane.activate_item(3, false, false, window, cx)
});
assert_item_labels(&pane, ["A", "B", "C", "D*"], cx);
pane.update_in(cx, |pane, window, cx| {
pane.close_active_item(
&CloseActiveItem {
save_intent: None,
close_pinned: false,
},
window,
cx,
)
})
.await
.unwrap();
assert_item_labels(&pane, ["A", "B*", "C"], cx);
pane.update_in(cx, |pane, window, cx| {
pane.close_active_item(
&CloseActiveItem {
save_intent: None,
close_pinned: false,
},
window,
cx,
)
})
.await
.unwrap();
assert_item_labels(&pane, ["A", "C*"], cx);
pane.update_in(cx, |pane, window, cx| {
pane.close_active_item(
&CloseActiveItem {
save_intent: None,
close_pinned: false,
},
window,
cx,
)
})
.await
.unwrap();
assert_item_labels(&pane, ["A*"], cx);
}
#[gpui::test]
async fn test_remove_item_ordering_neighbour(cx: &mut TestAppContext) {
init_test(cx);
cx.update_global::<SettingsStore, ()>(|s, cx| {
s.update_user_settings::<ItemSettings>(cx, |s| {
s.activate_on_close = Some(ActivateOnClose::Neighbour);
});
});
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, None, cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
add_labeled_item(&pane, "A", false, cx);
add_labeled_item(&pane, "B", false, cx);
add_labeled_item(&pane, "C", false, cx);
add_labeled_item(&pane, "D", false, cx);
assert_item_labels(&pane, ["A", "B", "C", "D*"], cx);
pane.update_in(cx, |pane, window, cx| {
pane.activate_item(1, false, false, window, cx)
});
add_labeled_item(&pane, "1", false, cx);
assert_item_labels(&pane, ["A", "B", "1*", "C", "D"], cx);
pane.update_in(cx, |pane, window, cx| {
pane.close_active_item(
&CloseActiveItem {
save_intent: None,
close_pinned: false,
},
window,
cx,
)
})
.await
.unwrap();
assert_item_labels(&pane, ["A", "B", "C*", "D"], cx);
pane.update_in(cx, |pane, window, cx| {
pane.activate_item(3, false, false, window, cx)
});
assert_item_labels(&pane, ["A", "B", "C", "D*"], cx);
pane.update_in(cx, |pane, window, cx| {
pane.close_active_item(
&CloseActiveItem {
save_intent: None,
close_pinned: false,
},
window,
cx,
)
})
.await
.unwrap();
assert_item_labels(&pane, ["A", "B", "C*"], cx);
pane.update_in(cx, |pane, window, cx| {
pane.close_active_item(
&CloseActiveItem {
save_intent: None,
close_pinned: false,
},
window,
cx,
)
})
.await
.unwrap();
assert_item_labels(&pane, ["A", "B*"], cx);
pane.update_in(cx, |pane, window, cx| {
pane.close_active_item(
&CloseActiveItem {
save_intent: None,
close_pinned: false,
},
window,
cx,
)
})
.await
.unwrap();
assert_item_labels(&pane, ["A*"], cx);
}
#[gpui::test]
async fn test_remove_item_ordering_left_neighbour(cx: &mut TestAppContext) {
init_test(cx);
cx.update_global::<SettingsStore, ()>(|s, cx| {
s.update_user_settings::<ItemSettings>(cx, |s| {
s.activate_on_close = Some(ActivateOnClose::LeftNeighbour);
});
});
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, None, cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
add_labeled_item(&pane, "A", false, cx);
add_labeled_item(&pane, "B", false, cx);
add_labeled_item(&pane, "C", false, cx);
add_labeled_item(&pane, "D", false, cx);
assert_item_labels(&pane, ["A", "B", "C", "D*"], cx);
pane.update_in(cx, |pane, window, cx| {
pane.activate_item(1, false, false, window, cx)
});
add_labeled_item(&pane, "1", false, cx);
assert_item_labels(&pane, ["A", "B", "1*", "C", "D"], cx);
pane.update_in(cx, |pane, window, cx| {
pane.close_active_item(
&CloseActiveItem {
save_intent: None,
close_pinned: false,
},
window,
cx,
)
})
.await
.unwrap();
assert_item_labels(&pane, ["A", "B*", "C", "D"], cx);
pane.update_in(cx, |pane, window, cx| {
pane.activate_item(3, false, false, window, cx)
});
assert_item_labels(&pane, ["A", "B", "C", "D*"], cx);
pane.update_in(cx, |pane, window, cx| {
pane.close_active_item(
&CloseActiveItem {
save_intent: None,
close_pinned: false,
},
window,
cx,
)
})
.await
.unwrap();
assert_item_labels(&pane, ["A", "B", "C*"], cx);
pane.update_in(cx, |pane, window, cx| {
pane.activate_item(0, false, false, window, cx)
});
assert_item_labels(&pane, ["A*", "B", "C"], cx);
pane.update_in(cx, |pane, window, cx| {
pane.close_active_item(
&CloseActiveItem {
save_intent: None,
close_pinned: false,
},
window,
cx,
)
})
.await
.unwrap();
assert_item_labels(&pane, ["B*", "C"], cx);
pane.update_in(cx, |pane, window, cx| {
pane.close_active_item(
&CloseActiveItem {
save_intent: None,
close_pinned: false,
},
window,
cx,
)
})
.await
.unwrap();
assert_item_labels(&pane, ["C*"], cx);
}
#[gpui::test]
async fn test_close_inactive_items(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, None, cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
let item_a = add_labeled_item(&pane, "A", false, cx);
pane.update_in(cx, |pane, window, cx| {
let ix = pane.index_for_item_id(item_a.item_id()).unwrap();
pane.pin_tab_at(ix, window, cx);
});
assert_item_labels(&pane, ["A*!"], cx);
let item_b = add_labeled_item(&pane, "B", false, cx);
pane.update_in(cx, |pane, window, cx| {
let ix = pane.index_for_item_id(item_b.item_id()).unwrap();
pane.pin_tab_at(ix, window, cx);
});
assert_item_labels(&pane, ["A!", "B*!"], cx);
add_labeled_item(&pane, "C", false, cx);
assert_item_labels(&pane, ["A!", "B!", "C*"], cx);
add_labeled_item(&pane, "D", false, cx);
add_labeled_item(&pane, "E", false, cx);
assert_item_labels(&pane, ["A!", "B!", "C", "D", "E*"], cx);
pane.update_in(cx, |pane, window, cx| {
pane.close_other_items(
&CloseOtherItems {
save_intent: None,
close_pinned: false,
},
None,
window,
cx,
)
})
.await
.unwrap();
assert_item_labels(&pane, ["A!", "B!", "E*"], cx);
}
#[gpui::test]
async fn test_running_close_inactive_items_via_an_inactive_item(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, None, cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
add_labeled_item(&pane, "A", false, cx);
assert_item_labels(&pane, ["A*"], cx);
let item_b = add_labeled_item(&pane, "B", false, cx);
assert_item_labels(&pane, ["A", "B*"], cx);
add_labeled_item(&pane, "C", false, cx);
add_labeled_item(&pane, "D", false, cx);
add_labeled_item(&pane, "E", false, cx);
assert_item_labels(&pane, ["A", "B", "C", "D", "E*"], cx);
pane.update_in(cx, |pane, window, cx| {
pane.close_other_items(
&CloseOtherItems {
save_intent: None,
close_pinned: false,
},
Some(item_b.item_id()),
window,
cx,
)
})
.await
.unwrap();
assert_item_labels(&pane, ["B*"], cx);
}
#[gpui::test]
async fn test_close_clean_items(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, None, cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
add_labeled_item(&pane, "A", true, cx);
add_labeled_item(&pane, "B", false, cx);
add_labeled_item(&pane, "C", true, cx);
add_labeled_item(&pane, "D", false, cx);
add_labeled_item(&pane, "E", false, cx);
assert_item_labels(&pane, ["A^", "B", "C^", "D", "E*"], cx);
pane.update_in(cx, |pane, window, cx| {
pane.close_clean_items(
&CloseCleanItems {
close_pinned: false,
},
window,
cx,
)
})
.await
.unwrap();
assert_item_labels(&pane, ["A^", "C*^"], cx);
}
#[gpui::test]
async fn test_close_items_to_the_left(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, None, cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
set_labeled_items(&pane, ["A", "B", "C*", "D", "E"], cx);
pane.update_in(cx, |pane, window, cx| {
pane.close_items_to_the_left_by_id(
None,
&CloseItemsToTheLeft {
close_pinned: false,
},
window,
cx,
)
})
.await
.unwrap();
assert_item_labels(&pane, ["C*", "D", "E"], cx);
}
#[gpui::test]
async fn test_close_items_to_the_right(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, None, cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
set_labeled_items(&pane, ["A", "B", "C*", "D", "E"], cx);
pane.update_in(cx, |pane, window, cx| {
pane.close_items_to_the_right_by_id(
None,
&CloseItemsToTheRight {
close_pinned: false,
},
window,
cx,
)
})
.await
.unwrap();
assert_item_labels(&pane, ["A", "B", "C*"], cx);
}
#[gpui::test]
async fn test_close_all_items(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, None, cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
let item_a = add_labeled_item(&pane, "A", false, cx);
add_labeled_item(&pane, "B", false, cx);
add_labeled_item(&pane, "C", false, cx);
assert_item_labels(&pane, ["A", "B", "C*"], cx);
pane.update_in(cx, |pane, window, cx| {
let ix = pane.index_for_item_id(item_a.item_id()).unwrap();
pane.pin_tab_at(ix, window, cx);
pane.close_all_items(
&CloseAllItems {
save_intent: None,
close_pinned: false,
},
window,
cx,
)
})
.await
.unwrap();
assert_item_labels(&pane, ["A*!"], cx);
pane.update_in(cx, |pane, window, cx| {
let ix = pane.index_for_item_id(item_a.item_id()).unwrap();
pane.unpin_tab_at(ix, window, cx);
pane.close_all_items(
&CloseAllItems {
save_intent: None,
close_pinned: false,
},
window,
cx,
)
})
.await
.unwrap();
assert_item_labels(&pane, [], cx);
add_labeled_item(&pane, "A", true, cx).update(cx, |item, cx| {
item.project_items
.push(TestProjectItem::new_dirty(1, "A.txt", cx))
});
add_labeled_item(&pane, "B", true, cx).update(cx, |item, cx| {
item.project_items
.push(TestProjectItem::new_dirty(2, "B.txt", cx))
});
add_labeled_item(&pane, "C", true, cx).update(cx, |item, cx| {
item.project_items
.push(TestProjectItem::new_dirty(3, "C.txt", cx))
});
assert_item_labels(&pane, ["A^", "B^", "C*^"], cx);
let save = pane.update_in(cx, |pane, window, cx| {
pane.close_all_items(
&CloseAllItems {
save_intent: None,
close_pinned: false,
},
window,
cx,
)
});
cx.executor().run_until_parked();
cx.simulate_prompt_answer("Save all");
save.await.unwrap();
assert_item_labels(&pane, [], cx);
add_labeled_item(&pane, "A", true, cx);
add_labeled_item(&pane, "B", true, cx);
add_labeled_item(&pane, "C", true, cx);
assert_item_labels(&pane, ["A^", "B^", "C*^"], cx);
let save = pane.update_in(cx, |pane, window, cx| {
pane.close_all_items(
&CloseAllItems {
save_intent: None,
close_pinned: false,
},
window,
cx,
)
});
cx.executor().run_until_parked();
cx.simulate_prompt_answer("Discard all");
save.await.unwrap();
assert_item_labels(&pane, [], cx);
}
#[gpui::test]
async fn test_close_with_save_intent(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, None, cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
let a = cx.update(|_, cx| TestProjectItem::new_dirty(1, "A.txt", cx));
let b = cx.update(|_, cx| TestProjectItem::new_dirty(1, "B.txt", cx));
let c = cx.update(|_, cx| TestProjectItem::new_dirty(1, "C.txt", cx));
add_labeled_item(&pane, "AB", true, cx).update(cx, |item, _| {
item.project_items.push(a.clone());
item.project_items.push(b.clone());
});
add_labeled_item(&pane, "C", true, cx)
.update(cx, |item, _| item.project_items.push(c.clone()));
assert_item_labels(&pane, ["AB^", "C*^"], cx);
pane.update_in(cx, |pane, window, cx| {
pane.close_all_items(
&CloseAllItems {
save_intent: Some(SaveIntent::Save),
close_pinned: false,
},
window,
cx,
)
})
.await
.unwrap();
assert_item_labels(&pane, [], cx);
cx.update(|_, cx| {
assert!(!a.read(cx).is_dirty);
assert!(!b.read(cx).is_dirty);
assert!(!c.read(cx).is_dirty);
});
}
#[gpui::test]
async fn test_close_all_items_including_pinned(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, None, cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
let item_a = add_labeled_item(&pane, "A", false, cx);
add_labeled_item(&pane, "B", false, cx);
add_labeled_item(&pane, "C", false, cx);
assert_item_labels(&pane, ["A", "B", "C*"], cx);
pane.update_in(cx, |pane, window, cx| {
let ix = pane.index_for_item_id(item_a.item_id()).unwrap();
pane.pin_tab_at(ix, window, cx);
pane.close_all_items(
&CloseAllItems {
save_intent: None,
close_pinned: true,
},
window,
cx,
)
})
.await
.unwrap();
assert_item_labels(&pane, [], cx);
}
#[gpui::test]
async fn test_close_pinned_tab_with_non_pinned_in_same_pane(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, None, cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
// Non-pinned tabs in same pane
let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
add_labeled_item(&pane, "A", false, cx);
add_labeled_item(&pane, "B", false, cx);
add_labeled_item(&pane, "C", false, cx);
pane.update_in(cx, |pane, window, cx| {
pane.pin_tab_at(0, window, cx);
});
set_labeled_items(&pane, ["A*", "B", "C"], cx);
pane.update_in(cx, |pane, window, cx| {
pane.close_active_item(
&CloseActiveItem {
save_intent: None,
close_pinned: false,
},
window,
cx,
)
.unwrap();
});
// Non-pinned tab should be active
assert_item_labels(&pane, ["A!", "B*", "C"], cx);
}
#[gpui::test]
async fn test_close_pinned_tab_with_non_pinned_in_different_pane(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, None, cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
// No non-pinned tabs in same pane, non-pinned tabs in another pane
let pane1 = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
let pane2 = workspace.update_in(cx, |workspace, window, cx| {
workspace.split_pane(pane1.clone(), SplitDirection::Right, window, cx)
});
add_labeled_item(&pane1, "A", false, cx);
pane1.update_in(cx, |pane, window, cx| {
pane.pin_tab_at(0, window, cx);
});
set_labeled_items(&pane1, ["A*"], cx);
add_labeled_item(&pane2, "B", false, cx);
set_labeled_items(&pane2, ["B"], cx);
pane1.update_in(cx, |pane, window, cx| {
pane.close_active_item(
&CloseActiveItem {
save_intent: None,
close_pinned: false,
},
window,
cx,
)
.unwrap();
});
// Non-pinned tab of other pane should be active
assert_item_labels(&pane2, ["B*"], cx);
}
#[gpui::test]
async fn ensure_item_closing_actions_do_not_panic_when_no_items_exist(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, None, cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
assert_item_labels(&pane, [], cx);
pane.update_in(cx, |pane, window, cx| {
pane.close_active_item(
&CloseActiveItem {
save_intent: None,
close_pinned: false,
},
window,
cx,
)
})
.await
.unwrap();
pane.update_in(cx, |pane, window, cx| {
pane.close_other_items(
&CloseOtherItems {
save_intent: None,
close_pinned: false,
},
None,
window,
cx,
)
})
.await
.unwrap();
pane.update_in(cx, |pane, window, cx| {
pane.close_all_items(
&CloseAllItems {
save_intent: None,
close_pinned: false,
},
window,
cx,
)
})
.await
.unwrap();
pane.update_in(cx, |pane, window, cx| {
pane.close_clean_items(
&CloseCleanItems {
close_pinned: false,
},
window,
cx,
)
})
.await
.unwrap();
pane.update_in(cx, |pane, window, cx| {
pane.close_items_to_the_right_by_id(
None,
&CloseItemsToTheRight {
close_pinned: false,
},
window,
cx,
)
})
.await
.unwrap();
pane.update_in(cx, |pane, window, cx| {
pane.close_items_to_the_left_by_id(
None,
&CloseItemsToTheLeft {
close_pinned: false,
},
window,
cx,
)
})
.await
.unwrap();
}
fn init_test(cx: &mut TestAppContext) {
cx.update(|cx| {
let settings_store = SettingsStore::test(cx);
cx.set_global(settings_store);
theme::init(LoadThemes::JustBase, cx);
crate::init_settings(cx);
Project::init_settings(cx);
});
}
fn set_max_tabs(cx: &mut TestAppContext, value: Option<usize>) {
cx.update_global(|store: &mut SettingsStore, cx| {
store.update_user_settings::<WorkspaceSettings>(cx, |settings| {
settings.max_tabs = value.map(|v| NonZero::new(v).unwrap())
});
});
}
fn add_labeled_item(
pane: &Entity<Pane>,
label: &str,
is_dirty: bool,
cx: &mut VisualTestContext,
) -> Box<Entity<TestItem>> {
pane.update_in(cx, |pane, window, cx| {
let labeled_item =
Box::new(cx.new(|cx| TestItem::new(cx).with_label(label).with_dirty(is_dirty)));
pane.add_item(labeled_item.clone(), false, false, None, window, cx);
labeled_item
})
}
fn set_labeled_items<const COUNT: usize>(
pane: &Entity<Pane>,
labels: [&str; COUNT],
cx: &mut VisualTestContext,
) -> [Box<Entity<TestItem>>; COUNT] {
pane.update_in(cx, |pane, window, cx| {
pane.items.clear();
let mut active_item_index = 0;
let mut index = 0;
let items = labels.map(|mut label| {
if label.ends_with('*') {
label = label.trim_end_matches('*');
active_item_index = index;
}
let labeled_item = Box::new(cx.new(|cx| TestItem::new(cx).with_label(label)));
pane.add_item(labeled_item.clone(), false, false, None, window, cx);
index += 1;
labeled_item
});
pane.activate_item(active_item_index, false, false, window, cx);
items
})
}
// Assert the item label, with the active item label suffixed with a '*'
#[track_caller]
fn assert_item_labels<const COUNT: usize>(
pane: &Entity<Pane>,
expected_states: [&str; COUNT],
cx: &mut VisualTestContext,
) {
let actual_states = pane.update(cx, |pane, cx| {
pane.items
.iter()
.enumerate()
.map(|(ix, item)| {
let mut state = item
.to_any()
.downcast::<TestItem>()
.unwrap()
.read(cx)
.label
.clone();
if ix == pane.active_item_index {
state.push('*');
}
if item.is_dirty(cx) {
state.push('^');
}
if pane.is_tab_pinned(ix) {
state.push('!');
}
state
})
.collect::<Vec<_>>()
});
assert_eq!(
actual_states, expected_states,
"pane items do not match expectation"
);
}
}