From d0bafce86bf94c3ddafae865896ae31cf89711e9 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Wed, 27 Nov 2024 20:22:39 +0200 Subject: [PATCH] Allow splitting the terminal panel (#21238) Closes https://github.com/zed-industries/zed/issues/4351 ![it_splits](https://github.com/user-attachments/assets/40de03c9-2173-4441-ba96-8e91537956e0) Applies the same splitting mechanism, as Zed's central pane has, to the terminal panel. Similar navigation, splitting and (de)serialization capabilities are supported. Notable caveats: * zooming keeps the terminal splits' ratio, rather expanding the terminal pane * on macOs, central panel is split with `cmd-k up/down/etc.` but `cmd-k` is a "standard" terminal clearing keybinding on macOS, so terminal panel splitting is done via `ctrl-k up/down/etc.` * task terminals are "split" into regular terminals, and also not persisted (same as currently in the terminal) Seems ok for the initial version, we can revisit and polish things later. Release Notes: - Added the ability to split the terminal panel --- Cargo.lock | 1 + assets/keymaps/default-macos.json | 6 +- crates/assistant/src/assistant_panel.rs | 1 - crates/editor/src/items.rs | 14 +- crates/gpui/src/app.rs | 2 +- crates/gpui/src/elements/div.rs | 39 +- crates/gpui/src/text_system/line_layout.rs | 26 +- crates/gpui/src/window.rs | 20 +- crates/image_viewer/src/image_viewer.rs | 6 +- crates/terminal_view/Cargo.toml | 1 + crates/terminal_view/src/persistence.rs | 345 +++++++++- crates/terminal_view/src/terminal_panel.rs | 696 +++++++++++++-------- crates/terminal_view/src/terminal_view.rs | 10 +- crates/workspace/src/item.rs | 7 +- crates/workspace/src/pane.rs | 32 +- crates/workspace/src/pane_group.rs | 50 +- crates/workspace/src/workspace.rs | 45 +- 17 files changed, 953 insertions(+), 348 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d9da330daa..9e1354c40d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12418,6 +12418,7 @@ name = "terminal_view" version = "0.1.0" dependencies = [ "anyhow", + "async-recursion 1.1.1", "breadcrumbs", "client", "collections", diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 514604ef98..f3990cecee 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -732,7 +732,11 @@ "cmd-end": "terminal::ScrollToBottom", "shift-home": "terminal::ScrollToTop", "shift-end": "terminal::ScrollToBottom", - "ctrl-shift-space": "terminal::ToggleViMode" + "ctrl-shift-space": "terminal::ToggleViMode", + "ctrl-k up": "pane::SplitUp", + "ctrl-k down": "pane::SplitDown", + "ctrl-k left": "pane::SplitLeft", + "ctrl-k right": "pane::SplitRight" } } ] diff --git a/crates/assistant/src/assistant_panel.rs b/crates/assistant/src/assistant_panel.rs index 7467d5dfd4..79e026cb51 100644 --- a/crates/assistant/src/assistant_panel.rs +++ b/crates/assistant/src/assistant_panel.rs @@ -416,7 +416,6 @@ impl AssistantPanel { ControlFlow::Break(()) }); - pane.set_can_split(false, cx); pane.set_can_navigate(true, cx); pane.display_nav_history_buttons(None); pane.set_should_display_tab_bar(|_| true); diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index 51ad9b9dec..813b212761 100644 --- a/crates/editor/src/items.rs +++ b/crates/editor/src/items.rs @@ -47,7 +47,7 @@ use workspace::item::{BreadcrumbText, FollowEvent}; use workspace::{ item::{FollowableItem, Item, ItemEvent, ProjectItem}, searchable::{Direction, SearchEvent, SearchableItem, SearchableItemHandle}, - ItemId, ItemNavHistory, Pane, ToolbarItemLocation, ViewId, Workspace, WorkspaceId, + ItemId, ItemNavHistory, ToolbarItemLocation, ViewId, Workspace, WorkspaceId, }; pub const MAX_TAB_TITLE_LEN: usize = 24; @@ -954,7 +954,7 @@ impl SerializableItem for Editor { workspace: WeakView, workspace_id: workspace::WorkspaceId, item_id: ItemId, - cx: &mut ViewContext, + cx: &mut WindowContext, ) -> Task>> { let serialized_editor = match DB .get_serialized_editor(item_id, workspace_id) @@ -989,7 +989,7 @@ impl SerializableItem for Editor { contents: Some(contents), language, .. - } => cx.spawn(|pane, mut cx| { + } => cx.spawn(|mut cx| { let project = project.clone(); async move { let language = if let Some(language_name) = language { @@ -1019,7 +1019,7 @@ impl SerializableItem for Editor { buffer.set_text(contents, cx); })?; - pane.update(&mut cx, |_, cx| { + cx.update(|cx| { cx.new_view(|cx| { let mut editor = Editor::for_buffer(buffer, Some(project), cx); @@ -1046,7 +1046,7 @@ impl SerializableItem for Editor { match project_item { Some(project_item) => { - cx.spawn(|pane, mut cx| async move { + cx.spawn(|mut cx| async move { let (_, project_item) = project_item.await?; let buffer = project_item.downcast::().map_err(|_| { anyhow!("Project item at stored path was not a buffer") @@ -1073,7 +1073,7 @@ impl SerializableItem for Editor { })?; } - pane.update(&mut cx, |_, cx| { + cx.update(|cx| { cx.new_view(|cx| { let mut editor = Editor::for_buffer(buffer, Some(project), cx); @@ -1087,7 +1087,7 @@ impl SerializableItem for Editor { let open_by_abs_path = workspace.update(cx, |workspace, cx| { workspace.open_abs_path(abs_path.clone(), false, cx) }); - cx.spawn(|_, mut cx| async move { + cx.spawn(|mut cx| async move { let editor = open_by_abs_path?.await?.downcast::().with_context(|| format!("Failed to downcast to Editor after opening abs path {abs_path:?}"))?; editor.update(&mut cx, |editor, cx| { editor.read_scroll_position_from_db(item_id, workspace_id, cx); diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index 0776e5c72e..87ee3942dd 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -1578,7 +1578,7 @@ pub struct AnyDrag { pub view: AnyView, /// The value of the dragged item, to be dropped - pub value: Box, + pub value: Arc, /// This is used to render the dragged item in the same place /// on the original element that the drag was initiated diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index 6928ca74ee..909af004a5 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -35,6 +35,7 @@ use std::{ mem, ops::DerefMut, rc::Rc, + sync::Arc, time::Duration, }; use taffy::style::Overflow; @@ -61,6 +62,7 @@ pub struct DragMoveEvent { /// The bounds of this element. pub bounds: Bounds, drag: PhantomData, + dragged_item: Arc, } impl DragMoveEvent { @@ -71,6 +73,11 @@ impl DragMoveEvent { .and_then(|drag| drag.value.downcast_ref::()) .expect("DragMoveEvent is only valid when the stored active drag is of the same type.") } + + /// An item that is about to be dropped. + pub fn dragged_item(&self) -> &dyn Any { + self.dragged_item.as_ref() + } } impl Interactivity { @@ -243,20 +250,20 @@ impl Interactivity { { self.mouse_move_listeners .push(Box::new(move |event, phase, hitbox, cx| { - if phase == DispatchPhase::Capture - && cx - .active_drag - .as_ref() - .is_some_and(|drag| drag.value.as_ref().type_id() == TypeId::of::()) - { - (listener)( - &DragMoveEvent { - event: event.clone(), - bounds: hitbox.bounds, - drag: PhantomData, - }, - cx, - ); + if phase == DispatchPhase::Capture { + if let Some(drag) = &cx.active_drag { + if drag.value.as_ref().type_id() == TypeId::of::() { + (listener)( + &DragMoveEvent { + event: event.clone(), + bounds: hitbox.bounds, + drag: PhantomData, + dragged_item: Arc::clone(&drag.value), + }, + cx, + ); + } + } } })); } @@ -454,7 +461,7 @@ impl Interactivity { "calling on_drag more than once on the same element is not supported" ); self.drag_listener = Some(( - Box::new(value), + Arc::new(value), Box::new(move |value, offset, cx| { constructor(value.downcast_ref().unwrap(), offset, cx).into() }), @@ -1292,7 +1299,7 @@ pub struct Interactivity { pub(crate) drop_listeners: Vec<(TypeId, DropListener)>, pub(crate) can_drop_predicate: Option, pub(crate) click_listeners: Vec, - pub(crate) drag_listener: Option<(Box, DragListener)>, + pub(crate) drag_listener: Option<(Arc, DragListener)>, pub(crate) hover_listener: Option>, pub(crate) tooltip_builder: Option, pub(crate) occlude_mouse: bool, diff --git a/crates/gpui/src/text_system/line_layout.rs b/crates/gpui/src/text_system/line_layout.rs index 66eb914a30..13a7896a3f 100644 --- a/crates/gpui/src/text_system/line_layout.rs +++ b/crates/gpui/src/text_system/line_layout.rs @@ -385,20 +385,28 @@ impl LineLayoutCache { let mut previous_frame = &mut *self.previous_frame.lock(); let mut current_frame = &mut *self.current_frame.write(); - for key in &previous_frame.used_lines[range.start.lines_index..range.end.lines_index] { - if let Some((key, line)) = previous_frame.lines.remove_entry(key) { - current_frame.lines.insert(key, line); + if let Some(cached_keys) = previous_frame + .used_lines + .get(range.start.lines_index..range.end.lines_index) + { + for key in cached_keys { + if let Some((key, line)) = previous_frame.lines.remove_entry(key) { + current_frame.lines.insert(key, line); + } + current_frame.used_lines.push(key.clone()); } - current_frame.used_lines.push(key.clone()); } - for key in &previous_frame.used_wrapped_lines - [range.start.wrapped_lines_index..range.end.wrapped_lines_index] + if let Some(cached_keys) = previous_frame + .used_wrapped_lines + .get(range.start.wrapped_lines_index..range.end.wrapped_lines_index) { - if let Some((key, line)) = previous_frame.wrapped_lines.remove_entry(key) { - current_frame.wrapped_lines.insert(key, line); + for key in cached_keys { + if let Some((key, line)) = previous_frame.wrapped_lines.remove_entry(key) { + current_frame.wrapped_lines.insert(key, line); + } + current_frame.used_wrapped_lines.push(key.clone()); } - current_frame.used_wrapped_lines.push(key.clone()); } } diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index c1c14edba2..902c699cb7 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -1752,12 +1752,18 @@ impl<'a> WindowContext<'a> { .iter_mut() .map(|listener| listener.take()), ); - window.next_frame.accessed_element_states.extend( - window.rendered_frame.accessed_element_states[range.start.accessed_element_states_index - ..range.end.accessed_element_states_index] - .iter() - .map(|(id, type_id)| (GlobalElementId(id.0.clone()), *type_id)), - ); + if let Some(element_states) = window + .rendered_frame + .accessed_element_states + .get(range.start.accessed_element_states_index..range.end.accessed_element_states_index) + { + window.next_frame.accessed_element_states.extend( + element_states + .iter() + .map(|(id, type_id)| (GlobalElementId(id.0.clone()), *type_id)), + ); + } + window .text_system .reuse_layouts(range.start.line_layout_index..range.end.line_layout_index); @@ -3126,7 +3132,7 @@ impl<'a> WindowContext<'a> { self.window.mouse_position = position; if self.active_drag.is_none() { self.active_drag = Some(AnyDrag { - value: Box::new(paths.clone()), + value: Arc::new(paths.clone()), view: self.new_view(|_| paths).into(), cursor_offset: position, }); diff --git a/crates/image_viewer/src/image_viewer.rs b/crates/image_viewer/src/image_viewer.rs index 1d03e77e76..ed87562e64 100644 --- a/crates/image_viewer/src/image_viewer.rs +++ b/crates/image_viewer/src/image_viewer.rs @@ -16,7 +16,7 @@ use settings::Settings; use util::paths::PathExt; use workspace::{ item::{BreadcrumbText, Item, ProjectItem, SerializableItem, TabContentParams}, - ItemId, ItemSettings, Pane, ToolbarItemLocation, Workspace, WorkspaceId, + ItemId, ItemSettings, ToolbarItemLocation, Workspace, WorkspaceId, }; const IMAGE_VIEWER_KIND: &str = "ImageView"; @@ -172,9 +172,9 @@ impl SerializableItem for ImageView { _workspace: WeakView, workspace_id: WorkspaceId, item_id: ItemId, - cx: &mut ViewContext, + cx: &mut WindowContext, ) -> Task>> { - cx.spawn(|_pane, mut cx| async move { + cx.spawn(|mut cx| async move { let image_path = IMAGE_VIEWER .get_image_path(item_id, workspace_id)? .ok_or_else(|| anyhow::anyhow!("No image path found"))?; diff --git a/crates/terminal_view/Cargo.toml b/crates/terminal_view/Cargo.toml index e57d9d1fc6..7e4a4fe76f 100644 --- a/crates/terminal_view/Cargo.toml +++ b/crates/terminal_view/Cargo.toml @@ -14,6 +14,7 @@ doctest = false [dependencies] anyhow.workspace = true +async-recursion.workspace = true breadcrumbs.workspace = true collections.workspace = true db.workspace = true diff --git a/crates/terminal_view/src/persistence.rs b/crates/terminal_view/src/persistence.rs index b8c31e05b0..dd430963d2 100644 --- a/crates/terminal_view/src/persistence.rs +++ b/crates/terminal_view/src/persistence.rs @@ -1,8 +1,351 @@ use anyhow::Result; +use async_recursion::async_recursion; +use collections::HashSet; +use futures::{stream::FuturesUnordered, StreamExt as _}; +use gpui::{AsyncWindowContext, Axis, Model, Task, View, WeakView}; +use project::{terminals::TerminalKind, Project}; +use serde::{Deserialize, Serialize}; use std::path::PathBuf; +use ui::{Pixels, ViewContext, VisualContext as _, WindowContext}; +use util::ResultExt as _; use db::{define_connection, query, sqlez::statement::Statement, sqlez_macros::sql}; -use workspace::{ItemId, WorkspaceDb, WorkspaceId}; +use workspace::{ + ItemHandle, ItemId, Member, Pane, PaneAxis, PaneGroup, SerializableItem as _, Workspace, + WorkspaceDb, WorkspaceId, +}; + +use crate::{ + default_working_directory, + terminal_panel::{new_terminal_pane, TerminalPanel}, + TerminalView, +}; + +pub(crate) fn serialize_pane_group( + pane_group: &PaneGroup, + active_pane: &View, + cx: &WindowContext, +) -> SerializedPaneGroup { + build_serialized_pane_group(&pane_group.root, active_pane, cx) +} + +fn build_serialized_pane_group( + pane_group: &Member, + active_pane: &View, + cx: &WindowContext, +) -> SerializedPaneGroup { + match pane_group { + Member::Axis(PaneAxis { + axis, + members, + flexes, + bounding_boxes: _, + }) => SerializedPaneGroup::Group { + axis: SerializedAxis(*axis), + children: members + .iter() + .map(|member| build_serialized_pane_group(member, active_pane, cx)) + .collect::>(), + flexes: Some(flexes.lock().clone()), + }, + Member::Pane(pane_handle) => { + SerializedPaneGroup::Pane(serialize_pane(pane_handle, pane_handle == active_pane, cx)) + } + } +} + +fn serialize_pane(pane: &View, active: bool, cx: &WindowContext) -> SerializedPane { + let mut items_to_serialize = HashSet::default(); + let pane = pane.read(cx); + let children = pane + .items() + .filter_map(|item| { + let terminal_view = item.act_as::(cx)?; + if terminal_view.read(cx).terminal().read(cx).task().is_some() { + None + } else { + let id = item.item_id().as_u64(); + items_to_serialize.insert(id); + Some(id) + } + }) + .collect::>(); + let active_item = pane + .active_item() + .map(|item| item.item_id().as_u64()) + .filter(|active_id| items_to_serialize.contains(active_id)); + + SerializedPane { + active, + children, + active_item, + } +} + +pub(crate) fn deserialize_terminal_panel( + workspace: WeakView, + project: Model, + database_id: WorkspaceId, + serialized_panel: SerializedTerminalPanel, + cx: &mut WindowContext, +) -> Task>> { + cx.spawn(move |mut cx| async move { + let terminal_panel = workspace.update(&mut cx, |workspace, cx| { + cx.new_view(|cx| { + let mut panel = TerminalPanel::new(workspace, cx); + panel.height = serialized_panel.height.map(|h| h.round()); + panel.width = serialized_panel.width.map(|w| w.round()); + panel + }) + })?; + match &serialized_panel.items { + SerializedItems::NoSplits(item_ids) => { + let items = deserialize_terminal_views( + database_id, + project, + workspace, + item_ids.as_slice(), + &mut cx, + ) + .await; + let active_item = serialized_panel.active_item_id; + terminal_panel.update(&mut cx, |terminal_panel, cx| { + terminal_panel.active_pane.update(cx, |pane, cx| { + populate_pane_items(pane, items, active_item, cx); + }); + })?; + } + SerializedItems::WithSplits(serialized_pane_group) => { + let center_pane = deserialize_pane_group( + workspace, + project, + terminal_panel.clone(), + database_id, + serialized_pane_group, + &mut cx, + ) + .await; + if let Some((center_group, active_pane)) = center_pane { + terminal_panel.update(&mut cx, |terminal_panel, _| { + terminal_panel.center = PaneGroup::with_root(center_group); + terminal_panel.active_pane = + active_pane.unwrap_or_else(|| terminal_panel.center.first_pane()); + })?; + } + } + } + + Ok(terminal_panel) + }) +} + +fn populate_pane_items( + pane: &mut Pane, + items: Vec>, + active_item: Option, + cx: &mut ViewContext<'_, Pane>, +) { + let mut item_index = pane.items_len(); + for item in items { + let activate_item = Some(item.item_id().as_u64()) == active_item; + pane.add_item(Box::new(item), false, false, None, cx); + item_index += 1; + if activate_item { + pane.activate_item(item_index, false, false, cx); + } + } +} + +#[async_recursion(?Send)] +async fn deserialize_pane_group( + workspace: WeakView, + project: Model, + panel: View, + workspace_id: WorkspaceId, + serialized: &SerializedPaneGroup, + cx: &mut AsyncWindowContext, +) -> Option<(Member, Option>)> { + match serialized { + SerializedPaneGroup::Group { + axis, + flexes, + children, + } => { + let mut current_active_pane = None; + let mut members = Vec::new(); + for child in children { + if let Some((new_member, active_pane)) = deserialize_pane_group( + workspace.clone(), + project.clone(), + panel.clone(), + workspace_id, + child, + cx, + ) + .await + { + members.push(new_member); + current_active_pane = current_active_pane.or(active_pane); + } + } + + if members.is_empty() { + return None; + } + + if members.len() == 1 { + return Some((members.remove(0), current_active_pane)); + } + + Some(( + Member::Axis(PaneAxis::load(axis.0, members, flexes.clone())), + current_active_pane, + )) + } + SerializedPaneGroup::Pane(serialized_pane) => { + let active = serialized_pane.active; + let new_items = deserialize_terminal_views( + workspace_id, + project.clone(), + workspace.clone(), + serialized_pane.children.as_slice(), + cx, + ) + .await; + + let pane = panel + .update(cx, |_, cx| { + new_terminal_pane(workspace.clone(), project.clone(), cx) + }) + .log_err()?; + let active_item = serialized_pane.active_item; + pane.update(cx, |pane, cx| { + populate_pane_items(pane, new_items, active_item, cx); + // Avoid blank panes in splits + if pane.items_len() == 0 { + let working_directory = workspace + .update(cx, |workspace, cx| default_working_directory(workspace, cx)) + .ok() + .flatten(); + let kind = TerminalKind::Shell(working_directory); + let window = cx.window_handle(); + let terminal = project + .update(cx, |project, cx| project.create_terminal(kind, window, cx)) + .log_err()?; + let terminal_view = Box::new(cx.new_view(|cx| { + TerminalView::new( + terminal.clone(), + workspace.clone(), + Some(workspace_id), + cx, + ) + })); + pane.add_item(terminal_view, true, false, None, cx); + } + Some(()) + }) + .ok() + .flatten()?; + Some((Member::Pane(pane.clone()), active.then_some(pane))) + } + } +} + +async fn deserialize_terminal_views( + workspace_id: WorkspaceId, + project: Model, + workspace: WeakView, + item_ids: &[u64], + cx: &mut AsyncWindowContext, +) -> Vec> { + let mut items = Vec::with_capacity(item_ids.len()); + let mut deserialized_items = item_ids + .iter() + .map(|item_id| { + cx.update(|cx| { + TerminalView::deserialize( + project.clone(), + workspace.clone(), + workspace_id, + *item_id, + cx, + ) + }) + .unwrap_or_else(|e| Task::ready(Err(e.context("no window present")))) + }) + .collect::>(); + while let Some(item) = deserialized_items.next().await { + if let Some(item) = item.log_err() { + items.push(item); + } + } + items +} + +#[derive(Debug, Serialize, Deserialize)] +pub(crate) struct SerializedTerminalPanel { + pub items: SerializedItems, + // A deprecated field, kept for backwards compatibility for the code before terminal splits were introduced. + pub active_item_id: Option, + pub width: Option, + pub height: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(untagged)] +pub(crate) enum SerializedItems { + // The data stored before terminal splits were introduced. + NoSplits(Vec), + WithSplits(SerializedPaneGroup), +} + +#[derive(Debug, Serialize, Deserialize)] +pub(crate) enum SerializedPaneGroup { + Pane(SerializedPane), + Group { + axis: SerializedAxis, + flexes: Option>, + children: Vec, + }, +} + +#[derive(Debug, Serialize, Deserialize)] +pub(crate) struct SerializedPane { + pub active: bool, + pub children: Vec, + pub active_item: Option, +} + +#[derive(Debug)] +pub(crate) struct SerializedAxis(pub Axis); + +impl Serialize for SerializedAxis { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self.0 { + Axis::Horizontal => serializer.serialize_str("horizontal"), + Axis::Vertical => serializer.serialize_str("vertical"), + } + } +} + +impl<'de> Deserialize<'de> for SerializedAxis { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + match s.as_str() { + "horizontal" => Ok(SerializedAxis(Axis::Horizontal)), + "vertical" => Ok(SerializedAxis(Axis::Vertical)), + invalid => Err(serde::de::Error::custom(format!( + "Invalid axis value: '{invalid}'" + ))), + } + } +} define_connection! { pub static ref TERMINAL_DB: TerminalDb = diff --git a/crates/terminal_view/src/terminal_panel.rs b/crates/terminal_view/src/terminal_panel.rs index ee10e924f4..38b2eda676 100644 --- a/crates/terminal_view/src/terminal_panel.rs +++ b/crates/terminal_view/src/terminal_panel.rs @@ -1,19 +1,24 @@ -use std::{ops::ControlFlow, path::PathBuf, sync::Arc}; +use std::{cmp, ops::ControlFlow, path::PathBuf, sync::Arc, time::Duration}; -use crate::{default_working_directory, TerminalView}; +use crate::{ + default_working_directory, + persistence::{ + deserialize_terminal_panel, serialize_pane_group, SerializedItems, SerializedTerminalPanel, + }, + TerminalView, +}; use breadcrumbs::Breadcrumbs; -use collections::{HashMap, HashSet}; +use collections::HashMap; use db::kvp::KEY_VALUE_STORE; use futures::future::join_all; use gpui::{ - actions, Action, AnchorCorner, AnyView, AppContext, AsyncWindowContext, Entity, EventEmitter, + actions, Action, AnchorCorner, AnyView, AppContext, AsyncWindowContext, EventEmitter, ExternalPaths, FocusHandle, FocusableView, IntoElement, Model, ParentElement, Pixels, Render, - Styled, Subscription, Task, View, ViewContext, VisualContext, WeakView, WindowContext, + Styled, Task, View, ViewContext, VisualContext, WeakView, WindowContext, }; use itertools::Itertools; -use project::{terminals::TerminalKind, Fs, ProjectEntryId}; +use project::{terminals::TerminalKind, Fs, Project, ProjectEntryId}; use search::{buffer_search::DivRegistrar, BufferSearchBar}; -use serde::{Deserialize, Serialize}; use settings::Settings; use task::{RevealStrategy, Shell, SpawnInTerminal, TaskId}; use terminal::{ @@ -21,16 +26,18 @@ use terminal::{ Terminal, }; use ui::{ - h_flex, ButtonCommon, Clickable, ContextMenu, IconButton, IconSize, PopoverMenu, Selectable, - Tooltip, + div, h_flex, ButtonCommon, Clickable, ContextMenu, IconButton, IconSize, InteractiveElement, + PopoverMenu, Selectable, Tooltip, }; use util::{ResultExt, TryFutureExt}; use workspace::{ dock::{DockPosition, Panel, PanelEvent}, item::SerializableItem, - pane, + move_item, pane, ui::IconName, - DraggedTab, ItemId, NewTerminal, Pane, ToggleZoom, Workspace, + ActivateNextPane, ActivatePane, ActivatePaneInDirection, ActivatePreviousPane, DraggedTab, + ItemId, NewTerminal, Pane, PaneGroup, SplitDirection, SwapPaneInDirection, ToggleZoom, + Workspace, }; use anyhow::Result; @@ -60,14 +67,14 @@ pub fn init(cx: &mut AppContext) { } pub struct TerminalPanel { - pane: View, + pub(crate) active_pane: View, + pub(crate) center: PaneGroup, fs: Arc, workspace: WeakView, - width: Option, - height: Option, + pub(crate) width: Option, + pub(crate) height: Option, pending_serialization: Task>, pending_terminals_to_add: usize, - _subscriptions: Vec, deferred_tasks: HashMap>, enabled: bool, assistant_enabled: bool, @@ -75,85 +82,14 @@ pub struct TerminalPanel { } impl TerminalPanel { - fn new(workspace: &Workspace, cx: &mut ViewContext) -> Self { - let pane = cx.new_view(|cx| { - let mut pane = Pane::new( - workspace.weak_handle(), - workspace.project().clone(), - Default::default(), - None, - NewTerminal.boxed_clone(), - cx, - ); - pane.set_can_split(false, cx); - pane.set_can_navigate(false, cx); - pane.display_nav_history_buttons(None); - pane.set_should_display_tab_bar(|_| true); - - let is_local = workspace.project().read(cx).is_local(); - let workspace = workspace.weak_handle(); - pane.set_custom_drop_handle(cx, move |pane, dropped_item, cx| { - if let Some(tab) = dropped_item.downcast_ref::() { - let item = if &tab.pane == cx.view() { - pane.item_for_index(tab.ix) - } else { - tab.pane.read(cx).item_for_index(tab.ix) - }; - if let Some(item) = item { - if item.downcast::().is_some() { - return ControlFlow::Continue(()); - } else if let Some(project_path) = item.project_path(cx) { - if let Some(entry_path) = workspace - .update(cx, |workspace, cx| { - workspace - .project() - .read(cx) - .absolute_path(&project_path, cx) - }) - .log_err() - .flatten() - { - add_paths_to_terminal(pane, &[entry_path], cx); - } - } - } - } else if let Some(&entry_id) = dropped_item.downcast_ref::() { - if let Some(entry_path) = workspace - .update(cx, |workspace, cx| { - let project = workspace.project().read(cx); - project - .path_for_entry(entry_id, cx) - .and_then(|project_path| project.absolute_path(&project_path, cx)) - }) - .log_err() - .flatten() - { - add_paths_to_terminal(pane, &[entry_path], cx); - } - } else if is_local { - if let Some(paths) = dropped_item.downcast_ref::() { - add_paths_to_terminal(pane, paths.paths(), cx); - } - } - - ControlFlow::Break(()) - }); - let buffer_search_bar = cx.new_view(search::BufferSearchBar::new); - let breadcrumbs = cx.new_view(|_| Breadcrumbs::new()); - pane.toolbar().update(cx, |toolbar, cx| { - toolbar.add_item(buffer_search_bar, cx); - toolbar.add_item(breadcrumbs, cx); - }); - pane - }); - let subscriptions = vec![ - cx.observe(&pane, |_, _, cx| cx.notify()), - cx.subscribe(&pane, Self::handle_pane_event), - ]; - let project = workspace.project().read(cx); - let enabled = project.supports_terminal(cx); - let this = Self { - pane, + pub fn new(workspace: &Workspace, cx: &mut ViewContext) -> Self { + let project = workspace.project(); + let pane = new_terminal_pane(workspace.weak_handle(), project.clone(), cx); + let center = PaneGroup::new(pane.clone()); + let enabled = project.read(cx).supports_terminal(cx); + let terminal_panel = Self { + center, + active_pane: pane, fs: workspace.app_state().fs.clone(), workspace: workspace.weak_handle(), pending_serialization: Task::ready(None), @@ -161,20 +97,19 @@ impl TerminalPanel { height: None, pending_terminals_to_add: 0, deferred_tasks: HashMap::default(), - _subscriptions: subscriptions, enabled, assistant_enabled: false, assistant_tab_bar_button: None, }; - this.apply_tab_bar_buttons(cx); - this + terminal_panel.apply_tab_bar_buttons(&terminal_panel.active_pane, cx); + terminal_panel } pub fn asssistant_enabled(&mut self, enabled: bool, cx: &mut ViewContext) { self.assistant_enabled = enabled; if enabled { let focus_handle = self - .pane + .active_pane .read(cx) .active_item() .map(|item| item.focus_handle(cx)) @@ -186,12 +121,14 @@ impl TerminalPanel { } else { self.assistant_tab_bar_button = None; } - self.apply_tab_bar_buttons(cx); + for pane in self.center.panes() { + self.apply_tab_bar_buttons(pane, cx); + } } - fn apply_tab_bar_buttons(&self, cx: &mut ViewContext) { + fn apply_tab_bar_buttons(&self, terminal_pane: &View, cx: &mut ViewContext) { let assistant_tab_bar_button = self.assistant_tab_bar_button.clone(); - self.pane.update(cx, |pane, cx| { + terminal_pane.update(cx, |pane, cx| { pane.set_render_tab_bar_buttons(cx, move |pane, cx| { if !pane.has_focus(cx) && !pane.context_menu_focused(cx) { return (None, None); @@ -268,80 +205,45 @@ impl TerminalPanel { .log_err() .flatten(); - let (panel, pane, items) = workspace.update(&mut cx, |workspace, cx| { - let panel = cx.new_view(|cx| TerminalPanel::new(workspace, cx)); - let items = if let Some((serialized_panel, database_id)) = - serialized_panel.as_ref().zip(workspace.database_id()) - { - panel.update(cx, |panel, cx| { - cx.notify(); - panel.height = serialized_panel.height.map(|h| h.round()); - panel.width = serialized_panel.width.map(|w| w.round()); - panel.pane.update(cx, |_, cx| { - serialized_panel - .items - .iter() - .map(|item_id| { - TerminalView::deserialize( - workspace.project().clone(), - workspace.weak_handle(), - database_id, - *item_id, - cx, - ) - }) - .collect::>() - }) - }) - } else { - Vec::new() - }; - let pane = panel.read(cx).pane.clone(); - (panel, pane, items) - })?; + let terminal_panel = workspace + .update(&mut cx, |workspace, cx| { + match serialized_panel.zip(workspace.database_id()) { + Some((serialized_panel, database_id)) => deserialize_terminal_panel( + workspace.weak_handle(), + workspace.project().clone(), + database_id, + serialized_panel, + cx, + ), + None => Task::ready(Ok(cx.new_view(|cx| TerminalPanel::new(workspace, cx)))), + } + })? + .await?; if let Some(workspace) = workspace.upgrade() { - panel - .update(&mut cx, |panel, cx| { - panel._subscriptions.push(cx.subscribe( - &workspace, - |terminal_panel, _, e, cx| { - if let workspace::Event::SpawnTask(spawn_in_terminal) = e { - terminal_panel.spawn_task(spawn_in_terminal, cx); - }; - }, - )) + terminal_panel + .update(&mut cx, |_, cx| { + cx.subscribe(&workspace, |terminal_panel, _, e, cx| { + if let workspace::Event::SpawnTask(spawn_in_terminal) = e { + terminal_panel.spawn_task(spawn_in_terminal, cx); + }; + }) + .detach(); }) .ok(); } - let pane = pane.downgrade(); - let items = futures::future::join_all(items).await; - let mut alive_item_ids = Vec::new(); - pane.update(&mut cx, |pane, cx| { - let active_item_id = serialized_panel - .as_ref() - .and_then(|panel| panel.active_item_id); - let mut active_ix = None; - for item in items { - if let Some(item) = item.log_err() { - let item_id = item.entity_id().as_u64(); - pane.add_item(Box::new(item), false, false, None, cx); - alive_item_ids.push(item_id as ItemId); - if Some(item_id) == active_item_id { - active_ix = Some(pane.items_len() - 1); - } - } - } - - if let Some(active_ix) = active_ix { - pane.activate_item(active_ix, false, false, cx) - } - })?; - // Since panels/docks are loaded outside from the workspace, we cleanup here, instead of through the workspace. if let Some(workspace) = workspace.upgrade() { let cleanup_task = workspace.update(&mut cx, |workspace, cx| { + let alive_item_ids = terminal_panel + .read(cx) + .center + .panes() + .into_iter() + .flat_map(|pane| pane.read(cx).items()) + .map(|item| item.item_id().as_u64() as ItemId) + .collect(); workspace .database_id() .map(|workspace_id| TerminalView::cleanup(workspace_id, alive_item_ids, cx)) @@ -351,33 +253,92 @@ impl TerminalPanel { } } - Ok(panel) + Ok(terminal_panel) } fn handle_pane_event( &mut self, - _pane: View, + pane: View, event: &pane::Event, cx: &mut ViewContext, ) { match event { pane::Event::ActivateItem { .. } => self.serialize(cx), pane::Event::RemovedItem { .. } => self.serialize(cx), - pane::Event::Remove { .. } => cx.emit(PanelEvent::Close), + pane::Event::Remove { focus_on_pane } => { + let pane_count_before_removal = self.center.panes().len(); + let _removal_result = self.center.remove(&pane); + if pane_count_before_removal == 1 { + cx.emit(PanelEvent::Close); + } else { + if let Some(focus_on_pane) = + focus_on_pane.as_ref().or_else(|| self.center.panes().pop()) + { + focus_on_pane.focus_handle(cx).focus(cx); + } + } + } pane::Event::ZoomIn => cx.emit(PanelEvent::ZoomIn), pane::Event::ZoomOut => cx.emit(PanelEvent::ZoomOut), - pane::Event::AddItem { item } => { if let Some(workspace) = self.workspace.upgrade() { - let pane = self.pane.clone(); - workspace.update(cx, |workspace, cx| item.added_to_pane(workspace, pane, cx)) + workspace.update(cx, |workspace, cx| { + item.added_to_pane(workspace, pane.clone(), cx) + }) } } + pane::Event::Split(direction) => { + let Some(new_pane) = self.new_pane_with_cloned_active_terminal(cx) else { + return; + }; + self.center.split(&pane, &new_pane, *direction).log_err(); + } + pane::Event::Focus => { + self.active_pane = pane.clone(); + } _ => {} } } + fn new_pane_with_cloned_active_terminal( + &mut self, + cx: &mut ViewContext, + ) -> Option> { + let workspace = self.workspace.clone().upgrade()?; + let project = workspace.read(cx).project().clone(); + let working_directory = self + .active_pane + .read(cx) + .active_item() + .and_then(|item| item.downcast::()) + .and_then(|terminal_view| { + terminal_view + .read(cx) + .terminal() + .read(cx) + .working_directory() + }) + .or_else(|| default_working_directory(workspace.read(cx), cx)); + let kind = TerminalKind::Shell(working_directory); + let window = cx.window_handle(); + let terminal = project + .update(cx, |project, cx| project.create_terminal(kind, window, cx)) + .log_err()?; + let database_id = workspace.read(cx).database_id(); + let terminal_view = Box::new(cx.new_view(|cx| { + TerminalView::new(terminal.clone(), self.workspace.clone(), database_id, cx) + })); + let pane = new_terminal_pane(self.workspace.clone(), project, cx); + self.apply_tab_bar_buttons(&pane, cx); + pane.update(cx, |pane, cx| { + pane.add_item(terminal_view, true, true, None, cx); + }); + cx.focus_view(&pane); + + Some(pane) + } + pub fn open_terminal( workspace: &mut Workspace, action: &workspace::OpenTerminal, @@ -494,7 +455,7 @@ impl TerminalPanel { .detach_and_log_err(cx); return; } - let (existing_item_index, existing_terminal) = terminals_for_task + let (existing_item_index, task_pane, existing_terminal) = terminals_for_task .last() .expect("covered no terminals case above") .clone(); @@ -503,7 +464,13 @@ impl TerminalPanel { !use_new_terminal, "Should have handled 'allow_concurrent_runs && use_new_terminal' case above" ); - self.replace_terminal(spawn_task, existing_item_index, existing_terminal, cx); + self.replace_terminal( + spawn_task, + task_pane, + existing_item_index, + existing_terminal, + cx, + ); } else { self.deferred_tasks.insert( spawn_in_terminal.id.clone(), @@ -518,6 +485,7 @@ impl TerminalPanel { } else { terminal_panel.replace_terminal( spawn_task, + task_pane, existing_item_index, existing_terminal, cx, @@ -562,25 +530,36 @@ impl TerminalPanel { &self, label: &str, cx: &mut AppContext, - ) -> Vec<(usize, View)> { - self.pane - .read(cx) - .items() - .enumerate() - .filter_map(|(index, item)| Some((index, item.act_as::(cx)?))) - .filter_map(|(index, terminal_view)| { - let task_state = terminal_view.read(cx).terminal().read(cx).task()?; - if &task_state.full_label == label { - Some((index, terminal_view)) - } else { - None - } + ) -> Vec<(usize, View, View)> { + self.center + .panes() + .into_iter() + .flat_map(|pane| { + pane.read(cx) + .items() + .enumerate() + .filter_map(|(index, item)| Some((index, item.act_as::(cx)?))) + .filter_map(|(index, terminal_view)| { + let task_state = terminal_view.read(cx).terminal().read(cx).task()?; + if &task_state.full_label == label { + Some((index, terminal_view)) + } else { + None + } + }) + .map(|(index, terminal_view)| (index, pane.clone(), terminal_view)) }) .collect() } - fn activate_terminal_view(&self, item_index: usize, focus: bool, cx: &mut WindowContext) { - self.pane.update(cx, |pane, cx| { + fn activate_terminal_view( + &self, + pane: &View, + item_index: usize, + focus: bool, + cx: &mut WindowContext, + ) { + pane.update(cx, |pane, cx| { pane.activate_item(item_index, true, focus, cx) }) } @@ -601,7 +580,7 @@ impl TerminalPanel { self.pending_terminals_to_add += 1; cx.spawn(|terminal_panel, mut cx| async move { - let pane = terminal_panel.update(&mut cx, |this, _| this.pane.clone())?; + let pane = terminal_panel.update(&mut cx, |this, _| this.active_pane.clone())?; let result = workspace.update(&mut cx, |workspace, cx| { let window = cx.window_handle(); let terminal = workspace @@ -640,52 +619,49 @@ impl TerminalPanel { } fn serialize(&mut self, cx: &mut ViewContext) { - let mut items_to_serialize = HashSet::default(); - let items = self - .pane - .read(cx) - .items() - .filter_map(|item| { - let terminal_view = item.act_as::(cx)?; - if terminal_view.read(cx).terminal().read(cx).task().is_some() { - None - } else { - let id = item.item_id().as_u64(); - items_to_serialize.insert(id); - Some(id) - } - }) - .collect::>(); - let active_item_id = self - .pane - .read(cx) - .active_item() - .map(|item| item.item_id().as_u64()) - .filter(|active_id| items_to_serialize.contains(active_id)); let height = self.height; let width = self.width; - self.pending_serialization = cx.background_executor().spawn( - async move { - KEY_VALUE_STORE - .write_kvp( - TERMINAL_PANEL_KEY.into(), - serde_json::to_string(&SerializedTerminalPanel { - items, - active_item_id, - height, - width, - })?, - ) - .await?; - anyhow::Ok(()) - } - .log_err(), - ); + self.pending_serialization = cx.spawn(|terminal_panel, mut cx| async move { + cx.background_executor() + .timer(Duration::from_millis(50)) + .await; + let terminal_panel = terminal_panel.upgrade()?; + let items = terminal_panel + .update(&mut cx, |terminal_panel, cx| { + SerializedItems::WithSplits(serialize_pane_group( + &terminal_panel.center, + &terminal_panel.active_pane, + cx, + )) + }) + .ok()?; + cx.background_executor() + .spawn( + async move { + KEY_VALUE_STORE + .write_kvp( + TERMINAL_PANEL_KEY.into(), + serde_json::to_string(&SerializedTerminalPanel { + items, + active_item_id: None, + height, + width, + })?, + ) + .await?; + anyhow::Ok(()) + } + .log_err(), + ) + .await; + Some(()) + }); } fn replace_terminal( &self, spawn_task: SpawnInTerminal, + task_pane: View, terminal_item_index: usize, terminal_to_replace: View, cx: &mut ViewContext<'_, Self>, @@ -708,7 +684,7 @@ impl TerminalPanel { match reveal { RevealStrategy::Always => { - self.activate_terminal_view(terminal_item_index, true, cx); + self.activate_terminal_view(&task_pane, terminal_item_index, true, cx); let task_workspace = self.workspace.clone(); cx.spawn(|_, mut cx| async move { task_workspace @@ -718,7 +694,7 @@ impl TerminalPanel { .detach(); } RevealStrategy::NoFocus => { - self.activate_terminal_view(terminal_item_index, false, cx); + self.activate_terminal_view(&task_pane, terminal_item_index, false, cx); let task_workspace = self.workspace.clone(); cx.spawn(|_, mut cx| async move { task_workspace @@ -734,7 +710,7 @@ impl TerminalPanel { } fn has_no_terminals(&self, cx: &WindowContext) -> bool { - self.pane.read(cx).items_len() == 0 && self.pending_terminals_to_add == 0 + self.active_pane.read(cx).items_len() == 0 && self.pending_terminals_to_add == 0 } pub fn assistant_enabled(&self) -> bool { @@ -742,11 +718,149 @@ impl TerminalPanel { } } +pub fn new_terminal_pane( + workspace: WeakView, + project: Model, + cx: &mut ViewContext, +) -> View { + let is_local = project.read(cx).is_local(); + let terminal_panel = cx.view().clone(); + let pane = cx.new_view(|cx| { + let mut pane = Pane::new( + workspace.clone(), + project.clone(), + Default::default(), + None, + NewTerminal.boxed_clone(), + cx, + ); + pane.set_can_navigate(false, cx); + pane.display_nav_history_buttons(None); + pane.set_should_display_tab_bar(|_| true); + + let terminal_panel_for_split_check = terminal_panel.clone(); + pane.set_can_split(Some(Arc::new(move |pane, dragged_item, cx| { + if let Some(tab) = dragged_item.downcast_ref::() { + let current_pane = cx.view().clone(); + let can_drag_away = + terminal_panel_for_split_check.update(cx, |terminal_panel, _| { + let current_panes = terminal_panel.center.panes(); + !current_panes.contains(&&tab.pane) + || current_panes.len() > 1 + || (tab.pane != current_pane || pane.items_len() > 1) + }); + if can_drag_away { + let item = if tab.pane == current_pane { + pane.item_for_index(tab.ix) + } else { + tab.pane.read(cx).item_for_index(tab.ix) + }; + if let Some(item) = item { + return item.downcast::().is_some(); + } + } + } + false + }))); + + let buffer_search_bar = cx.new_view(search::BufferSearchBar::new); + let breadcrumbs = cx.new_view(|_| Breadcrumbs::new()); + pane.toolbar().update(cx, |toolbar, cx| { + toolbar.add_item(buffer_search_bar, cx); + toolbar.add_item(breadcrumbs, cx); + }); + + pane.set_custom_drop_handle(cx, move |pane, dropped_item, cx| { + if let Some(tab) = dropped_item.downcast_ref::() { + let this_pane = cx.view().clone(); + let belongs_to_this_pane = tab.pane == this_pane; + let item = if belongs_to_this_pane { + pane.item_for_index(tab.ix) + } else { + tab.pane.read(cx).item_for_index(tab.ix) + }; + if let Some(item) = item { + if item.downcast::().is_some() { + let source = tab.pane.clone(); + let item_id_to_move = item.item_id(); + + let new_pane = pane.drag_split_direction().and_then(|split_direction| { + terminal_panel.update(cx, |terminal_panel, cx| { + let new_pane = + new_terminal_pane(workspace.clone(), project.clone(), cx); + terminal_panel.apply_tab_bar_buttons(&new_pane, cx); + terminal_panel + .center + .split(&this_pane, &new_pane, split_direction) + .log_err()?; + Some(new_pane) + }) + }); + + let destination; + let destination_index; + if let Some(new_pane) = new_pane { + destination_index = new_pane.read(cx).active_item_index(); + destination = new_pane; + } else if belongs_to_this_pane { + return ControlFlow::Break(()); + } else { + destination = cx.view().clone(); + destination_index = pane.active_item_index(); + } + // Destination pane may be the one currently updated, so defer the move. + cx.spawn(|_, mut cx| async move { + cx.update(|cx| { + move_item( + &source, + &destination, + item_id_to_move, + destination_index, + cx, + ); + }) + .ok(); + }) + .detach(); + } else if let Some(project_path) = item.project_path(cx) { + if let Some(entry_path) = project.read(cx).absolute_path(&project_path, cx) + { + add_paths_to_terminal(pane, &[entry_path], cx); + } + } + } + } else if let Some(&entry_id) = dropped_item.downcast_ref::() { + if let Some(entry_path) = project + .read(cx) + .path_for_entry(entry_id, cx) + .and_then(|project_path| project.read(cx).absolute_path(&project_path, cx)) + { + add_paths_to_terminal(pane, &[entry_path], cx); + } + } else if is_local { + if let Some(paths) = dropped_item.downcast_ref::() { + add_paths_to_terminal(pane, paths.paths(), cx); + } + } + + ControlFlow::Break(()) + }); + + pane + }); + + cx.subscribe(&pane, TerminalPanel::handle_pane_event) + .detach(); + cx.observe(&pane, |_, _, cx| cx.notify()).detach(); + + pane +} + async fn wait_for_terminals_tasks( - terminals_for_task: Vec<(usize, View)>, + terminals_for_task: Vec<(usize, View, View)>, cx: &mut AsyncWindowContext, ) { - let pending_tasks = terminals_for_task.iter().filter_map(|(_, terminal)| { + let pending_tasks = terminals_for_task.iter().filter_map(|(_, _, terminal)| { terminal .update(cx, |terminal_view, cx| { terminal_view @@ -781,7 +895,7 @@ impl Render for TerminalPanel { let mut registrar = DivRegistrar::new( |panel, cx| { panel - .pane + .active_pane .read(cx) .toolbar() .read(cx) @@ -790,13 +904,99 @@ impl Render for TerminalPanel { cx, ); BufferSearchBar::register(&mut registrar); - registrar.into_div().size_full().child(self.pane.clone()) + let registrar = registrar.into_div(); + self.workspace + .update(cx, |workspace, cx| { + registrar.size_full().child(self.center.render( + workspace.project(), + &HashMap::default(), + None, + &self.active_pane, + workspace.zoomed_item(), + workspace.app_state(), + cx, + )) + }) + .ok() + .map(|div| { + div.on_action({ + cx.listener(|terminal_panel, action: &ActivatePaneInDirection, cx| { + if let Some(pane) = terminal_panel.center.find_pane_in_direction( + &terminal_panel.active_pane, + action.0, + cx, + ) { + cx.focus_view(&pane); + } + }) + }) + .on_action( + cx.listener(|terminal_panel, _action: &ActivateNextPane, cx| { + let panes = terminal_panel.center.panes(); + if let Some(ix) = panes + .iter() + .position(|pane| **pane == terminal_panel.active_pane) + { + let next_ix = (ix + 1) % panes.len(); + let next_pane = panes[next_ix].clone(); + cx.focus_view(&next_pane); + } + }), + ) + .on_action( + cx.listener(|terminal_panel, _action: &ActivatePreviousPane, cx| { + let panes = terminal_panel.center.panes(); + if let Some(ix) = panes + .iter() + .position(|pane| **pane == terminal_panel.active_pane) + { + let prev_ix = cmp::min(ix.wrapping_sub(1), panes.len() - 1); + let prev_pane = panes[prev_ix].clone(); + cx.focus_view(&prev_pane); + } + }), + ) + .on_action(cx.listener(|terminal_panel, action: &ActivatePane, cx| { + let panes = terminal_panel.center.panes(); + if let Some(pane) = panes.get(action.0).map(|p| (*p).clone()) { + cx.focus_view(&pane); + } else { + if let Some(new_pane) = + terminal_panel.new_pane_with_cloned_active_terminal(cx) + { + terminal_panel + .center + .split( + &terminal_panel.active_pane, + &new_pane, + SplitDirection::Right, + ) + .log_err(); + } + } + })) + .on_action(cx.listener( + |terminal_panel, action: &SwapPaneInDirection, cx| { + if let Some(to) = terminal_panel + .center + .find_pane_in_direction(&terminal_panel.active_pane, action.0, cx) + .cloned() + { + terminal_panel + .center + .swap(&terminal_panel.active_pane.clone(), &to); + cx.notify(); + } + }, + )) + }) + .unwrap_or_else(|| div()) } } impl FocusableView for TerminalPanel { fn focus_handle(&self, cx: &AppContext) -> FocusHandle { - self.pane.focus_handle(cx) + self.active_pane.focus_handle(cx) } } @@ -848,11 +1048,12 @@ impl Panel for TerminalPanel { } fn is_zoomed(&self, cx: &WindowContext) -> bool { - self.pane.read(cx).is_zoomed() + self.active_pane.read(cx).is_zoomed() } fn set_zoomed(&mut self, zoomed: bool, cx: &mut ViewContext) { - self.pane.update(cx, |pane, cx| pane.set_zoomed(zoomed, cx)); + self.active_pane + .update(cx, |pane, cx| pane.set_zoomed(zoomed, cx)); } fn set_active(&mut self, active: bool, cx: &mut ViewContext) { @@ -872,7 +1073,12 @@ impl Panel for TerminalPanel { } fn icon_label(&self, cx: &WindowContext) -> Option { - let count = self.pane.read(cx).items_len(); + let count = self + .center + .panes() + .into_iter() + .map(|pane| pane.read(cx).items_len()) + .sum::(); if count == 0 { None } else { @@ -901,7 +1107,7 @@ impl Panel for TerminalPanel { } fn pane(&self) -> Option> { - Some(self.pane.clone()) + Some(self.active_pane.clone()) } } @@ -923,14 +1129,6 @@ impl Render for InlineAssistTabBarButton { } } -#[derive(Serialize, Deserialize)] -struct SerializedTerminalPanel { - items: Vec, - active_item_id: Option, - width: Option, - height: Option, -} - fn retrieve_system_shell() -> Option { #[cfg(not(target_os = "windows"))] { diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index ad0c7f520d..35ad35a0e1 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -33,8 +33,8 @@ use workspace::{ notifications::NotifyResultExt, register_serializable_item, searchable::{SearchEvent, SearchOptions, SearchableItem, SearchableItemHandle}, - CloseActiveItem, NewCenterTerminal, NewTerminal, OpenVisible, Pane, ToolbarItemLocation, - Workspace, WorkspaceId, + CloseActiveItem, NewCenterTerminal, NewTerminal, OpenVisible, ToolbarItemLocation, Workspace, + WorkspaceId, }; use anyhow::Context; @@ -1222,10 +1222,10 @@ impl SerializableItem for TerminalView { workspace: WeakView, workspace_id: workspace::WorkspaceId, item_id: workspace::ItemId, - cx: &mut ViewContext, + cx: &mut WindowContext, ) -> Task>> { let window = cx.window_handle(); - cx.spawn(|pane, mut cx| async move { + cx.spawn(|mut cx| async move { let cwd = cx .update(|cx| { let from_db = TERMINAL_DB @@ -1249,7 +1249,7 @@ impl SerializableItem for TerminalView { let terminal = project.update(&mut cx, |project, cx| { project.create_terminal(TerminalKind::Shell(cwd), window, cx) })??; - pane.update(&mut cx, |_, cx| { + cx.update(|cx| { cx.new_view(|cx| TerminalView::new(terminal, workspace, Some(workspace_id), cx)) }) }) diff --git a/crates/workspace/src/item.rs b/crates/workspace/src/item.rs index a7bf90dd17..20437145cb 100644 --- a/crates/workspace/src/item.rs +++ b/crates/workspace/src/item.rs @@ -315,7 +315,7 @@ pub trait SerializableItem: Item { _workspace: WeakView, _workspace_id: WorkspaceId, _item_id: ItemId, - _cx: &mut ViewContext, + _cx: &mut WindowContext, ) -> Task>>; fn serialize( @@ -1032,7 +1032,7 @@ impl WeakFollowableItemHandle for WeakView { #[cfg(any(test, feature = "test-support"))] pub mod test { use super::{Item, ItemEvent, SerializableItem, TabContentParams}; - use crate::{ItemId, ItemNavHistory, Pane, Workspace, WorkspaceId}; + use crate::{ItemId, ItemNavHistory, Workspace, WorkspaceId}; use gpui::{ AnyElement, AppContext, Context as _, EntityId, EventEmitter, FocusableView, InteractiveElement, IntoElement, Model, Render, SharedString, Task, View, ViewContext, @@ -1040,6 +1040,7 @@ pub mod test { }; use project::{Project, ProjectEntryId, ProjectPath, WorktreeId}; use std::{any::Any, cell::Cell, path::Path}; + use ui::WindowContext; pub struct TestProjectItem { pub entry_id: Option, @@ -1339,7 +1340,7 @@ pub mod test { _workspace: WeakView, workspace_id: WorkspaceId, _item_id: ItemId, - cx: &mut ViewContext, + cx: &mut WindowContext, ) -> Task>> { let view = cx.new_view(|cx| Self::new_deserialized(workspace_id, cx)); Task::ready(Ok(view)) diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index 4eec2f18d1..69485846e9 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -291,7 +291,7 @@ pub struct Pane { can_drop_predicate: Option bool>>, custom_drop_handle: Option) -> ControlFlow<(), ()>>>, - can_split: bool, + can_split_predicate: Option) -> bool>>, should_display_tab_bar: Rc) -> bool>, render_tab_bar_buttons: Rc) -> (Option, Option)>, @@ -411,7 +411,7 @@ impl Pane { project, can_drop_predicate, custom_drop_handle: None, - can_split: true, + can_split_predicate: None, should_display_tab_bar: Rc::new(|cx| TabBarSettings::get_global(cx).show), render_tab_bar_buttons: Rc::new(move |pane, cx| { if !pane.has_focus(cx) && !pane.context_menu_focused(cx) { @@ -623,9 +623,13 @@ impl Pane { self.should_display_tab_bar = Rc::new(should_display_tab_bar); } - pub fn set_can_split(&mut self, can_split: bool, cx: &mut ViewContext) { - self.can_split = can_split; - cx.notify(); + pub fn set_can_split( + &mut self, + can_split_predicate: Option< + Arc) -> bool + 'static>, + >, + ) { + self.can_split_predicate = can_split_predicate; } pub fn set_can_navigate(&mut self, can_navigate: bool, cx: &mut ViewContext) { @@ -2384,8 +2388,18 @@ impl Pane { self.zoomed } - fn handle_drag_move(&mut self, event: &DragMoveEvent, cx: &mut ViewContext) { - if !self.can_split { + fn handle_drag_move( + &mut self, + event: &DragMoveEvent, + cx: &mut ViewContext, + ) { + 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(), cx), + None => false, + }; + self.can_split_predicate = can_split_predicate; + if !can_split { return; } @@ -2679,6 +2693,10 @@ impl Pane { }) .collect() } + + pub fn drag_split_direction(&self) -> Option { + self.drag_split_direction + } } impl FocusableView for Pane { diff --git a/crates/workspace/src/pane_group.rs b/crates/workspace/src/pane_group.rs index 6f7d1a66b9..4461e58925 100644 --- a/crates/workspace/src/pane_group.rs +++ b/crates/workspace/src/pane_group.rs @@ -27,11 +27,11 @@ const VERTICAL_MIN_SIZE: f32 = 100.; /// Single-pane group is a regular pane. #[derive(Clone)] pub struct PaneGroup { - pub(crate) root: Member, + pub root: Member, } impl PaneGroup { - pub(crate) fn with_root(root: Member) -> Self { + pub fn with_root(root: Member) -> Self { Self { root } } @@ -122,7 +122,7 @@ impl PaneGroup { } #[allow(clippy::too_many_arguments)] - pub(crate) fn render( + pub fn render( &self, project: &Model, follower_states: &HashMap, @@ -144,19 +144,51 @@ impl PaneGroup { ) } - pub(crate) fn panes(&self) -> Vec<&View> { + pub fn panes(&self) -> Vec<&View> { let mut panes = Vec::new(); self.root.collect_panes(&mut panes); panes } - pub(crate) fn first_pane(&self) -> View { + pub fn first_pane(&self) -> View { self.root.first_pane() } + + pub fn find_pane_in_direction( + &mut self, + active_pane: &View, + direction: SplitDirection, + cx: &WindowContext, + ) -> Option<&View> { + let bounding_box = self.bounding_box_for_pane(active_pane)?; + let cursor = active_pane.read(cx).pixel_position_of_cursor(cx); + let center = match cursor { + Some(cursor) if bounding_box.contains(&cursor) => cursor, + _ => bounding_box.center(), + }; + + let distance_to_next = crate::HANDLE_HITBOX_SIZE; + + let target = match direction { + SplitDirection::Left => { + Point::new(bounding_box.left() - distance_to_next.into(), center.y) + } + SplitDirection::Right => { + Point::new(bounding_box.right() + distance_to_next.into(), center.y) + } + SplitDirection::Up => { + Point::new(center.x, bounding_box.top() - distance_to_next.into()) + } + SplitDirection::Down => { + Point::new(center.x, bounding_box.bottom() + distance_to_next.into()) + } + }; + self.pane_at_pixel_position(target) + } } -#[derive(Clone)] -pub(crate) enum Member { +#[derive(Debug, Clone)] +pub enum Member { Axis(PaneAxis), Pane(View), } @@ -359,8 +391,8 @@ impl Member { } } -#[derive(Clone)] -pub(crate) struct PaneAxis { +#[derive(Debug, Clone)] +pub struct PaneAxis { pub axis: Axis, pub members: Vec, pub flexes: Arc>>, diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 28fd730e60..4687b1decd 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -777,7 +777,7 @@ pub struct ViewId { pub id: u64, } -struct FollowerState { +pub struct FollowerState { center_pane: View, dock_pane: Option>, active_view_id: Option, @@ -887,14 +887,16 @@ impl Workspace { let pane_history_timestamp = Arc::new(AtomicUsize::new(0)); let center_pane = cx.new_view(|cx| { - Pane::new( + let mut center_pane = Pane::new( weak_handle.clone(), project.clone(), pane_history_timestamp.clone(), None, NewFile.boxed_clone(), cx, - ) + ); + center_pane.set_can_split(Some(Arc::new(|_, _, _| true))); + center_pane }); cx.subscribe(¢er_pane, Self::handle_pane_event).detach(); @@ -2464,14 +2466,16 @@ impl Workspace { fn add_pane(&mut self, cx: &mut ViewContext) -> View { let pane = cx.new_view(|cx| { - Pane::new( + let mut pane = Pane::new( self.weak_handle(), self.project.clone(), self.pane_history_timestamp.clone(), None, NewFile.boxed_clone(), cx, - ) + ); + pane.set_can_split(Some(Arc::new(|_, _, _| true))); + pane }); cx.subscribe(&pane, Self::handle_pane_event).detach(); self.panes.push(pane.clone()); @@ -2955,30 +2959,9 @@ impl Workspace { direction: SplitDirection, cx: &WindowContext, ) -> Option> { - let bounding_box = self.center.bounding_box_for_pane(&self.active_pane)?; - let cursor = self.active_pane.read(cx).pixel_position_of_cursor(cx); - let center = match cursor { - Some(cursor) if bounding_box.contains(&cursor) => cursor, - _ => bounding_box.center(), - }; - - let distance_to_next = pane_group::HANDLE_HITBOX_SIZE; - - let target = match direction { - SplitDirection::Left => { - Point::new(bounding_box.left() - distance_to_next.into(), center.y) - } - SplitDirection::Right => { - Point::new(bounding_box.right() + distance_to_next.into(), center.y) - } - SplitDirection::Up => { - Point::new(center.x, bounding_box.top() - distance_to_next.into()) - } - SplitDirection::Down => { - Point::new(center.x, bounding_box.bottom() + distance_to_next.into()) - } - }; - self.center.pane_at_pixel_position(target).cloned() + self.center + .find_pane_in_direction(&self.active_pane, direction, cx) + .cloned() } pub fn swap_pane_in_direction( @@ -4591,6 +4574,10 @@ impl Workspace { let window = cx.window_handle().downcast::()?; cx.read_window(&window, |workspace, _| workspace).ok() } + + pub fn zoomed_item(&self) -> Option<&AnyWeakView> { + self.zoomed.as_ref() + } } fn leader_border_for_pane(