Files
oak-gpui/crates/terminal_view/src/terminal_panel.rs
T
Finn Evers f393138711 Fix keybind hints flickering in certain scenarios (#40927)
Closes #39172

This refactors when we resolve UI keybindings in an effort to reduce
flickering whilst painting these: Previously, we would always resolve
these upon creating the binding. This could lead to cases where the
corresponding context was not yet available and no binding could be
resolved, even if the binding was then available on the next presented
frame. Following that, on the next rerender of whatever requested this
keybinding, the keybind for that context would then be found, we would
render that and then also win a layout shift in that process, as we went
from nothing rendered to something rendered between these frames.

With these changes, this now happens less often, because we only look
for the keybinding once the context can actually be resolved in the
window.

| Before | After | 
| --- | --- |
|
https://github.com/user-attachments/assets/adebf8ac-217d-4c7f-ae5a-bab3aa0b0ee8
|
https://github.com/user-attachments/assets/70a82b4b-488f-4a9f-94d7-b6d0a49aada9
|

Also reduced cloning in the keymap editor in this process, since that
requiered changing due to this anyway.

Release Notes:

- Fixed some cases where keybinds would appear with a slight delay,
causing a flicker in the process
2025-10-22 19:52:38 +00:00

1983 lines
76 KiB
Rust

use std::{cmp, ops::ControlFlow, path::PathBuf, process::ExitStatus, sync::Arc, time::Duration};
use crate::{
TerminalView, default_working_directory,
persistence::{
SerializedItems, SerializedTerminalPanel, deserialize_terminal_panel, serialize_pane_group,
},
};
use breadcrumbs::Breadcrumbs;
use collections::HashMap;
use db::kvp::KEY_VALUE_STORE;
use futures::{channel::oneshot, future::join_all};
use gpui::{
Action, AnyView, App, AsyncApp, AsyncWindowContext, Context, Corner, Entity, EventEmitter,
ExternalPaths, FocusHandle, Focusable, IntoElement, ParentElement, Pixels, Render, Styled,
Task, WeakEntity, Window, actions,
};
use itertools::Itertools;
use project::{Fs, Project, ProjectEntryId};
use search::{BufferSearchBar, buffer_search::DivRegistrar};
use settings::{Settings, TerminalDockPosition};
use task::{RevealStrategy, RevealTarget, Shell, ShellBuilder, SpawnInTerminal, TaskId};
use terminal::{Terminal, terminal_settings::TerminalSettings};
use ui::{
ButtonLike, Clickable, ContextMenu, FluentBuilder, PopoverMenu, SplitButton, Toggleable,
Tooltip, prelude::*,
};
use util::{ResultExt, TryFutureExt};
use workspace::{
ActivateNextPane, ActivatePane, ActivatePaneDown, ActivatePaneLeft, ActivatePaneRight,
ActivatePaneUp, ActivatePreviousPane, DraggedSelection, DraggedTab, ItemId, MoveItemToPane,
MoveItemToPaneInDirection, MovePaneDown, MovePaneLeft, MovePaneRight, MovePaneUp, NewTerminal,
Pane, PaneGroup, SplitDirection, SplitDown, SplitLeft, SplitRight, SplitUp, SwapPaneDown,
SwapPaneLeft, SwapPaneRight, SwapPaneUp, ToggleZoom, Workspace,
dock::{DockPosition, Panel, PanelEvent, PanelHandle},
item::SerializableItem,
move_active_item, move_item, pane,
};
use anyhow::{Result, anyhow};
use zed_actions::assistant::InlineAssist;
const TERMINAL_PANEL_KEY: &str = "TerminalPanel";
actions!(
terminal_panel,
[
/// Toggles the terminal panel.
Toggle,
/// Toggles focus on the terminal panel.
ToggleFocus
]
);
pub fn init(cx: &mut App) {
cx.observe_new(
|workspace: &mut Workspace, _window, _: &mut Context<Workspace>| {
workspace.register_action(TerminalPanel::new_terminal);
workspace.register_action(TerminalPanel::open_terminal);
workspace.register_action(|workspace, _: &ToggleFocus, window, cx| {
if is_enabled_in_workspace(workspace, cx) {
workspace.toggle_panel_focus::<TerminalPanel>(window, cx);
}
});
workspace.register_action(|workspace, _: &Toggle, window, cx| {
if is_enabled_in_workspace(workspace, cx) {
if !workspace.toggle_panel_focus::<TerminalPanel>(window, cx) {
workspace.close_panel::<TerminalPanel>(window, cx);
}
}
});
},
)
.detach();
}
pub struct TerminalPanel {
pub(crate) active_pane: Entity<Pane>,
pub(crate) center: PaneGroup,
fs: Arc<dyn Fs>,
workspace: WeakEntity<Workspace>,
pub(crate) width: Option<Pixels>,
pub(crate) height: Option<Pixels>,
pending_serialization: Task<Option<()>>,
pending_terminals_to_add: usize,
deferred_tasks: HashMap<TaskId, Task<()>>,
assistant_enabled: bool,
assistant_tab_bar_button: Option<AnyView>,
active: bool,
}
impl TerminalPanel {
pub fn new(workspace: &Workspace, window: &mut Window, cx: &mut Context<Self>) -> Self {
let project = workspace.project();
let pane = new_terminal_pane(workspace.weak_handle(), project.clone(), false, window, cx);
let center = PaneGroup::new(pane.clone());
let terminal_panel = Self {
center,
active_pane: pane,
fs: workspace.app_state().fs.clone(),
workspace: workspace.weak_handle(),
pending_serialization: Task::ready(None),
width: None,
height: None,
pending_terminals_to_add: 0,
deferred_tasks: HashMap::default(),
assistant_enabled: false,
assistant_tab_bar_button: None,
active: false,
};
terminal_panel.apply_tab_bar_buttons(&terminal_panel.active_pane, cx);
terminal_panel
}
pub fn set_assistant_enabled(&mut self, enabled: bool, cx: &mut Context<Self>) {
self.assistant_enabled = enabled;
if enabled {
let focus_handle = self
.active_pane
.read(cx)
.active_item()
.map(|item| item.item_focus_handle(cx))
.unwrap_or(self.focus_handle(cx));
self.assistant_tab_bar_button = Some(
cx.new(move |_| InlineAssistTabBarButton { focus_handle })
.into(),
);
} else {
self.assistant_tab_bar_button = None;
}
for pane in self.center.panes() {
self.apply_tab_bar_buttons(pane, cx);
}
}
fn apply_tab_bar_buttons(&self, terminal_pane: &Entity<Pane>, cx: &mut Context<Self>) {
let assistant_tab_bar_button = self.assistant_tab_bar_button.clone();
terminal_pane.update(cx, |pane, cx| {
pane.set_render_tab_bar_buttons(cx, move |pane, window, cx| {
let split_context = pane
.active_item()
.and_then(|item| item.downcast::<TerminalView>())
.map(|terminal_view| terminal_view.read(cx).focus_handle.clone());
if !pane.has_focus(window, cx) && !pane.context_menu_focused(window, cx) {
return (None, None);
}
let focus_handle = pane.focus_handle(cx);
let right_children = h_flex()
.gap(DynamicSpacing::Base02.rems(cx))
.child(
PopoverMenu::new("terminal-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| {
let focus_handle = focus_handle.clone();
let menu = ContextMenu::build(window, cx, |menu, _, _| {
menu.context(focus_handle.clone())
.action(
"New Terminal",
workspace::NewTerminal.boxed_clone(),
)
// We want the focus to go back to terminal panel once task modal is dismissed,
// hence we focus that first. Otherwise, we'd end up without a focused element, as
// context menu will be gone the moment we spawn the modal.
.action(
"Spawn task",
zed_actions::Spawn::modal().boxed_clone(),
)
});
Some(menu)
}),
)
.children(assistant_tab_bar_button.clone())
.child(
PopoverMenu::new("terminal-pane-tab-bar-split")
.trigger_with_tooltip(
IconButton::new("terminal-pane-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.when_some(
split_context.clone(),
|menu, split_context| menu.context(split_context),
)
.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(&workspace::ToggleZoom, window, cx);
}))
.tooltip(move |_window, cx| {
Tooltip::for_action(
if zoomed { "Zoom Out" } else { "Zoom In" },
&ToggleZoom,
cx,
)
})
})
.into_any_element()
.into();
(None, right_children)
});
});
}
fn serialization_key(workspace: &Workspace) -> Option<String> {
workspace
.database_id()
.map(|id| i64::from(id).to_string())
.or(workspace.session_id())
.map(|id| format!("{:?}-{:?}", TERMINAL_PANEL_KEY, id))
}
pub async fn load(
workspace: WeakEntity<Workspace>,
mut cx: AsyncWindowContext,
) -> Result<Entity<Self>> {
let mut terminal_panel = None;
if let Some((database_id, serialization_key)) = workspace
.read_with(&cx, |workspace, _| {
workspace
.database_id()
.zip(TerminalPanel::serialization_key(workspace))
})
.ok()
.flatten()
&& let Some(serialized_panel) = cx
.background_spawn(async move { KEY_VALUE_STORE.read_kvp(&serialization_key) })
.await
.log_err()
.flatten()
.map(|panel| serde_json::from_str::<SerializedTerminalPanel>(&panel))
.transpose()
.log_err()
.flatten()
&& let Ok(serialized) = workspace
.update_in(&mut cx, |workspace, window, cx| {
deserialize_terminal_panel(
workspace.weak_handle(),
workspace.project().clone(),
database_id,
serialized_panel,
window,
cx,
)
})?
.await
{
terminal_panel = Some(serialized);
}
let terminal_panel = if let Some(panel) = terminal_panel {
panel
} else {
workspace.update_in(&mut cx, |workspace, window, cx| {
cx.new(|cx| TerminalPanel::new(workspace, window, cx))
})?
};
if let Some(workspace) = workspace.upgrade() {
workspace
.update(&mut cx, |workspace, _| {
workspace.set_terminal_provider(TerminalProvider(terminal_panel.clone()))
})
.ok();
}
// 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_in(&mut cx, |workspace, window, 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, window, cx)
})
})?;
if let Some(task) = cleanup_task {
task.await.log_err();
}
}
if let Some(workspace) = workspace.upgrade() {
let should_focus = workspace
.update_in(&mut cx, |workspace, window, cx| {
workspace.active_item(cx).is_none()
&& workspace
.is_dock_at_position_open(terminal_panel.position(window, cx), cx)
})
.unwrap_or(false);
if should_focus {
terminal_panel
.update_in(&mut cx, |panel, window, cx| {
panel.active_pane.update(cx, |pane, cx| {
pane.focus_active_item(window, cx);
});
})
.ok();
}
}
Ok(terminal_panel)
}
fn handle_pane_event(
&mut self,
pane: &Entity<Pane>,
event: &pane::Event,
window: &mut Window,
cx: &mut Context<Self>,
) {
match event {
pane::Event::ActivateItem { .. } => self.serialize(cx),
pane::Event::RemovedItem { .. } => self.serialize(cx),
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 {
self.center.first_pane().update(cx, |pane, cx| {
pane.set_zoomed(false, cx);
});
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(window);
}
}
pane::Event::ZoomIn => {
for pane in self.center.panes() {
pane.update(cx, |pane, cx| {
pane.set_zoomed(true, cx);
})
}
cx.emit(PanelEvent::ZoomIn);
cx.notify();
}
pane::Event::ZoomOut => {
for pane in self.center.panes() {
pane.update(cx, |pane, cx| {
pane.set_zoomed(false, cx);
})
}
cx.emit(PanelEvent::ZoomOut);
cx.notify();
}
pane::Event::AddItem { item } => {
if let Some(workspace) = self.workspace.upgrade() {
workspace.update(cx, |workspace, cx| {
item.added_to_pane(workspace, pane.clone(), window, cx)
})
}
self.serialize(cx);
}
&pane::Event::Split {
direction,
clone_active_item,
} => {
if clone_active_item {
let fut = self.new_pane_with_cloned_active_terminal(window, cx);
let pane = pane.clone();
cx.spawn_in(window, async move |panel, cx| {
let Some(new_pane) = fut.await else {
return;
};
panel
.update_in(cx, |panel, window, cx| {
panel.center.split(&pane, &new_pane, direction).log_err();
window.focus(&new_pane.focus_handle(cx));
})
.ok();
})
.detach();
} else {
let Some(item) = pane.update(cx, |pane, cx| pane.take_active_item(window, cx))
else {
return;
};
let Ok(project) = self
.workspace
.update(cx, |workspace, _| workspace.project().clone())
else {
return;
};
let new_pane =
new_terminal_pane(self.workspace.clone(), project, false, window, cx);
new_pane.update(cx, |pane, cx| {
pane.add_item(item, true, true, None, window, cx);
});
self.center.split(&pane, &new_pane, direction).log_err();
window.focus(&new_pane.focus_handle(cx));
}
}
pane::Event::Focus => {
self.active_pane = pane.clone();
}
pane::Event::ItemPinned | pane::Event::ItemUnpinned => {
self.serialize(cx);
}
_ => {}
}
}
fn new_pane_with_cloned_active_terminal(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) -> Task<Option<Entity<Pane>>> {
let Some(workspace) = self.workspace.upgrade() else {
return Task::ready(None);
};
let workspace = workspace.read(cx);
let database_id = workspace.database_id();
let weak_workspace = self.workspace.clone();
let project = workspace.project().clone();
let active_pane = &self.active_pane;
let terminal_view = active_pane
.read(cx)
.active_item()
.and_then(|item| item.downcast::<TerminalView>());
let working_directory = terminal_view
.as_ref()
.and_then(|terminal_view| {
terminal_view
.read(cx)
.terminal()
.read(cx)
.working_directory()
})
.or_else(|| default_working_directory(workspace, cx));
let is_zoomed = active_pane.read(cx).is_zoomed();
cx.spawn_in(window, async move |panel, cx| {
let terminal = project
.update(cx, |project, cx| match terminal_view {
Some(view) => project.clone_terminal(
&view.read(cx).terminal.clone(),
cx,
working_directory,
),
None => project.create_terminal_shell(working_directory, cx),
})
.ok()?
.await
.log_err()?;
panel
.update_in(cx, move |terminal_panel, window, cx| {
let terminal_view = Box::new(cx.new(|cx| {
TerminalView::new(
terminal.clone(),
weak_workspace.clone(),
database_id,
project.downgrade(),
window,
cx,
)
}));
let pane = new_terminal_pane(weak_workspace, project, is_zoomed, window, cx);
terminal_panel.apply_tab_bar_buttons(&pane, cx);
pane.update(cx, |pane, cx| {
pane.add_item(terminal_view, true, true, None, window, cx);
});
Some(pane)
})
.ok()
.flatten()
})
}
pub fn open_terminal(
workspace: &mut Workspace,
action: &workspace::OpenTerminal,
window: &mut Window,
cx: &mut Context<Workspace>,
) {
let Some(terminal_panel) = workspace.panel::<Self>(cx) else {
return;
};
terminal_panel
.update(cx, |panel, cx| {
panel.add_terminal_shell(
Some(action.working_directory.clone()),
RevealStrategy::Always,
window,
cx,
)
})
.detach_and_log_err(cx);
}
pub fn spawn_task(
&mut self,
task: &SpawnInTerminal,
window: &mut Window,
cx: &mut Context<Self>,
) -> Task<Result<WeakEntity<Terminal>>> {
let Some(workspace) = self.workspace.upgrade() else {
return Task::ready(Err(anyhow!("failed to read workspace")));
};
let project = workspace.read(cx).project().read(cx);
if project.is_via_collab() {
return Task::ready(Err(anyhow!("cannot spawn tasks as a guest")));
}
let remote_client = project.remote_client();
let is_windows = project.path_style(cx).is_windows();
let remote_shell = remote_client
.as_ref()
.and_then(|remote_client| remote_client.read(cx).shell());
let shell = if let Some(remote_shell) = remote_shell
&& task.shell == Shell::System
{
Shell::Program(remote_shell)
} else {
task.shell.clone()
};
let builder = ShellBuilder::new(&shell, is_windows);
let command_label = builder.command_label(task.command.as_deref().unwrap_or(""));
let (command, args) = builder.build(task.command.clone(), &task.args);
let task = SpawnInTerminal {
command_label,
command: Some(command),
args,
..task.clone()
};
if task.allow_concurrent_runs && task.use_new_terminal {
return self.spawn_in_new_terminal(task, window, cx);
}
let mut terminals_for_task = self.terminals_for_task(&task.full_label, cx);
let Some(existing) = terminals_for_task.pop() else {
return self.spawn_in_new_terminal(task, window, cx);
};
let (existing_item_index, task_pane, existing_terminal) = existing;
if task.allow_concurrent_runs {
return self.replace_terminal(
task,
task_pane,
existing_item_index,
existing_terminal,
window,
cx,
);
}
let (tx, rx) = oneshot::channel();
self.deferred_tasks.insert(
task.id.clone(),
cx.spawn_in(window, async move |terminal_panel, cx| {
wait_for_terminals_tasks(terminals_for_task, cx).await;
let task = terminal_panel.update_in(cx, |terminal_panel, window, cx| {
if task.use_new_terminal {
terminal_panel.spawn_in_new_terminal(task, window, cx)
} else {
terminal_panel.replace_terminal(
task,
task_pane,
existing_item_index,
existing_terminal,
window,
cx,
)
}
});
if let Ok(task) = task {
tx.send(task.await).ok();
}
}),
);
cx.spawn(async move |_, _| rx.await?)
}
fn spawn_in_new_terminal(
&mut self,
spawn_task: SpawnInTerminal,
window: &mut Window,
cx: &mut Context<Self>,
) -> Task<Result<WeakEntity<Terminal>>> {
let reveal = spawn_task.reveal;
let reveal_target = spawn_task.reveal_target;
match reveal_target {
RevealTarget::Center => self
.workspace
.update(cx, |workspace, cx| {
Self::add_center_terminal(workspace, window, cx, |project, cx| {
project.create_terminal_task(spawn_task, cx)
})
})
.unwrap_or_else(|e| Task::ready(Err(e))),
RevealTarget::Dock => self.add_terminal_task(spawn_task, reveal, window, cx),
}
}
/// Create a new Terminal in the current working directory or the user's home directory
fn new_terminal(
workspace: &mut Workspace,
_: &workspace::NewTerminal,
window: &mut Window,
cx: &mut Context<Workspace>,
) {
let Some(terminal_panel) = workspace.panel::<Self>(cx) else {
return;
};
terminal_panel
.update(cx, |this, cx| {
this.add_terminal_shell(
default_working_directory(workspace, cx),
RevealStrategy::Always,
window,
cx,
)
})
.detach_and_log_err(cx);
}
fn terminals_for_task(
&self,
label: &str,
cx: &mut App,
) -> Vec<(usize, Entity<Pane>, Entity<TerminalView>)> {
let Some(workspace) = self.workspace.upgrade() else {
return Vec::new();
};
let pane_terminal_views = |pane: Entity<Pane>| {
pane.read(cx)
.items()
.enumerate()
.filter_map(|(index, item)| Some((index, item.act_as::<TerminalView>(cx)?)))
.filter_map(|(index, terminal_view)| {
let task_state = terminal_view.read(cx).terminal().read(cx).task()?;
if &task_state.spawned_task.full_label == label {
Some((index, terminal_view))
} else {
None
}
})
.map(move |(index, terminal_view)| (index, pane.clone(), terminal_view))
};
self.center
.panes()
.into_iter()
.cloned()
.flat_map(pane_terminal_views)
.chain(
workspace
.read(cx)
.panes()
.iter()
.cloned()
.flat_map(pane_terminal_views),
)
.sorted_by_key(|(_, _, terminal_view)| terminal_view.entity_id())
.collect()
}
fn activate_terminal_view(
&self,
pane: &Entity<Pane>,
item_index: usize,
focus: bool,
window: &mut Window,
cx: &mut App,
) {
pane.update(cx, |pane, cx| {
pane.activate_item(item_index, true, focus, window, cx)
})
}
pub fn add_center_terminal(
workspace: &mut Workspace,
window: &mut Window,
cx: &mut Context<Workspace>,
create_terminal: impl FnOnce(
&mut Project,
&mut Context<Project>,
) -> Task<Result<Entity<Terminal>>>
+ 'static,
) -> Task<Result<WeakEntity<Terminal>>> {
if !is_enabled_in_workspace(workspace, cx) {
return Task::ready(Err(anyhow!(
"terminal not yet supported for remote projects"
)));
}
let project = workspace.project().downgrade();
cx.spawn_in(window, async move |workspace, cx| {
let terminal = project.update(cx, create_terminal)?.await?;
workspace.update_in(cx, |workspace, window, cx| {
let terminal_view = cx.new(|cx| {
TerminalView::new(
terminal.clone(),
workspace.weak_handle(),
workspace.database_id(),
workspace.project().downgrade(),
window,
cx,
)
});
workspace.add_item_to_active_pane(Box::new(terminal_view), None, true, window, cx);
})?;
Ok(terminal.downgrade())
})
}
pub fn add_terminal_task(
&mut self,
task: SpawnInTerminal,
reveal_strategy: RevealStrategy,
window: &mut Window,
cx: &mut Context<Self>,
) -> Task<Result<WeakEntity<Terminal>>> {
let workspace = self.workspace.clone();
cx.spawn_in(window, async move |terminal_panel, cx| {
if workspace.update(cx, |workspace, cx| !is_enabled_in_workspace(workspace, cx))? {
anyhow::bail!("terminal not yet supported for remote projects");
}
let pane = terminal_panel.update(cx, |terminal_panel, _| {
terminal_panel.pending_terminals_to_add += 1;
terminal_panel.active_pane.clone()
})?;
let project = workspace.read_with(cx, |workspace, _| workspace.project().clone())?;
let terminal = project
.update(cx, |project, cx| project.create_terminal_task(task, cx))?
.await?;
let result = workspace.update_in(cx, |workspace, window, cx| {
let terminal_view = Box::new(cx.new(|cx| {
TerminalView::new(
terminal.clone(),
workspace.weak_handle(),
workspace.database_id(),
workspace.project().downgrade(),
window,
cx,
)
}));
match reveal_strategy {
RevealStrategy::Always => {
workspace.focus_panel::<Self>(window, cx);
}
RevealStrategy::NoFocus => {
workspace.open_panel::<Self>(window, cx);
}
RevealStrategy::Never => {}
}
pane.update(cx, |pane, cx| {
let focus = pane.has_focus(window, cx)
|| matches!(reveal_strategy, RevealStrategy::Always);
pane.add_item(terminal_view, true, focus, None, window, cx);
});
Ok(terminal.downgrade())
})?;
terminal_panel.update(cx, |terminal_panel, cx| {
terminal_panel.pending_terminals_to_add =
terminal_panel.pending_terminals_to_add.saturating_sub(1);
terminal_panel.serialize(cx)
})?;
result
})
}
fn add_terminal_shell(
&mut self,
cwd: Option<PathBuf>,
reveal_strategy: RevealStrategy,
window: &mut Window,
cx: &mut Context<Self>,
) -> Task<Result<WeakEntity<Terminal>>> {
let workspace = self.workspace.clone();
cx.spawn_in(window, async move |terminal_panel, cx| {
if workspace.update(cx, |workspace, cx| !is_enabled_in_workspace(workspace, cx))? {
anyhow::bail!("terminal not yet supported for collaborative projects");
}
let pane = terminal_panel.update(cx, |terminal_panel, _| {
terminal_panel.pending_terminals_to_add += 1;
terminal_panel.active_pane.clone()
})?;
let project = workspace.read_with(cx, |workspace, _| workspace.project().clone())?;
let terminal = project
.update(cx, |project, cx| project.create_terminal_shell(cwd, cx))?
.await;
match terminal {
Ok(terminal) => {
let result = workspace.update_in(cx, |workspace, window, cx| {
let terminal_view = Box::new(cx.new(|cx| {
TerminalView::new(
terminal.clone(),
workspace.weak_handle(),
workspace.database_id(),
workspace.project().downgrade(),
window,
cx,
)
}));
match reveal_strategy {
RevealStrategy::Always => {
workspace.focus_panel::<Self>(window, cx);
}
RevealStrategy::NoFocus => {
workspace.open_panel::<Self>(window, cx);
}
RevealStrategy::Never => {}
}
pane.update(cx, |pane, cx| {
let focus = pane.has_focus(window, cx)
|| matches!(reveal_strategy, RevealStrategy::Always);
pane.add_item(terminal_view, true, focus, None, window, cx);
});
Ok(terminal.downgrade())
})?;
terminal_panel.update(cx, |terminal_panel, cx| {
terminal_panel.pending_terminals_to_add =
terminal_panel.pending_terminals_to_add.saturating_sub(1);
terminal_panel.serialize(cx)
})?;
result
}
Err(error) => {
pane.update_in(cx, |pane, window, cx| {
let focus = pane.has_focus(window, cx);
let failed_to_spawn = cx.new(|cx| FailedToSpawnTerminal {
error: error.to_string(),
focus_handle: cx.focus_handle(),
});
pane.add_item(Box::new(failed_to_spawn), true, focus, None, window, cx);
})?;
Err(error)
}
}
})
}
fn serialize(&mut self, cx: &mut Context<Self>) {
let height = self.height;
let width = self.width;
let Some(serialization_key) = self
.workspace
.read_with(cx, |workspace, _| {
TerminalPanel::serialization_key(workspace)
})
.ok()
.flatten()
else {
return;
};
self.pending_serialization = cx.spawn(async move |terminal_panel, cx| {
cx.background_executor()
.timer(Duration::from_millis(50))
.await;
let terminal_panel = terminal_panel.upgrade()?;
let items = terminal_panel
.update(cx, |terminal_panel, cx| {
SerializedItems::WithSplits(serialize_pane_group(
&terminal_panel.center,
&terminal_panel.active_pane,
cx,
))
})
.ok()?;
cx.background_spawn(
async move {
KEY_VALUE_STORE
.write_kvp(
serialization_key,
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: Entity<Pane>,
terminal_item_index: usize,
terminal_to_replace: Entity<TerminalView>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Task<Result<WeakEntity<Terminal>>> {
let reveal = spawn_task.reveal;
let reveal_target = spawn_task.reveal_target;
let task_workspace = self.workspace.clone();
cx.spawn_in(window, async move |terminal_panel, cx| {
let project = terminal_panel.update(cx, |this, cx| {
this.workspace
.update(cx, |workspace, _| workspace.project().clone())
})??;
let new_terminal = project
.update(cx, |project, cx| {
project.create_terminal_task(spawn_task, cx)
})?
.await?;
terminal_to_replace.update_in(cx, |terminal_to_replace, window, cx| {
terminal_to_replace.set_terminal(new_terminal.clone(), window, cx);
})?;
match reveal {
RevealStrategy::Always => match reveal_target {
RevealTarget::Center => {
task_workspace.update_in(cx, |workspace, window, cx| {
let did_activate = workspace.activate_item(
&terminal_to_replace,
true,
true,
window,
cx,
);
anyhow::ensure!(did_activate, "Failed to retrieve terminal pane");
anyhow::Ok(())
})??;
}
RevealTarget::Dock => {
terminal_panel.update_in(cx, |terminal_panel, window, cx| {
terminal_panel.activate_terminal_view(
&task_pane,
terminal_item_index,
true,
window,
cx,
)
})?;
cx.spawn(async move |cx| {
task_workspace
.update_in(cx, |workspace, window, cx| {
workspace.focus_panel::<Self>(window, cx)
})
.ok()
})
.detach();
}
},
RevealStrategy::NoFocus => match reveal_target {
RevealTarget::Center => {
task_workspace.update_in(cx, |workspace, window, cx| {
workspace.active_pane().focus_handle(cx).focus(window);
})?;
}
RevealTarget::Dock => {
terminal_panel.update_in(cx, |terminal_panel, window, cx| {
terminal_panel.activate_terminal_view(
&task_pane,
terminal_item_index,
false,
window,
cx,
)
})?;
cx.spawn(async move |cx| {
task_workspace
.update_in(cx, |workspace, window, cx| {
workspace.open_panel::<Self>(window, cx)
})
.ok()
})
.detach();
}
},
RevealStrategy::Never => {}
}
Ok(new_terminal.downgrade())
})
}
fn has_no_terminals(&self, cx: &App) -> bool {
self.active_pane.read(cx).items_len() == 0 && self.pending_terminals_to_add == 0
}
pub fn assistant_enabled(&self) -> bool {
self.assistant_enabled
}
fn is_enabled(&self, cx: &App) -> bool {
self.workspace
.upgrade()
.is_some_and(|workspace| is_enabled_in_workspace(workspace.read(cx), cx))
}
fn activate_pane_in_direction(
&mut self,
direction: SplitDirection,
window: &mut Window,
cx: &mut Context<Self>,
) {
if let Some(pane) = self
.center
.find_pane_in_direction(&self.active_pane, direction, cx)
{
window.focus(&pane.focus_handle(cx));
} else {
self.workspace
.update(cx, |workspace, cx| {
workspace.activate_pane_in_direction(direction, window, cx)
})
.ok();
}
}
fn swap_pane_in_direction(&mut self, direction: SplitDirection, cx: &mut Context<Self>) {
if let Some(to) = self
.center
.find_pane_in_direction(&self.active_pane, direction, cx)
.cloned()
{
self.center.swap(&self.active_pane, &to);
cx.notify();
}
}
fn move_pane_to_border(&mut self, direction: SplitDirection, cx: &mut Context<Self>) {
if self
.center
.move_to_border(&self.active_pane, direction)
.unwrap()
{
cx.notify();
}
}
}
fn is_enabled_in_workspace(workspace: &Workspace, cx: &App) -> bool {
workspace.project().read(cx).supports_terminal(cx)
}
pub fn new_terminal_pane(
workspace: WeakEntity<Workspace>,
project: Entity<Project>,
zoomed: bool,
window: &mut Window,
cx: &mut Context<TerminalPanel>,
) -> Entity<Pane> {
let is_local = project.read(cx).is_local();
let terminal_panel = cx.entity();
let pane = cx.new(|cx| {
let mut pane = Pane::new(
workspace.clone(),
project.clone(),
Default::default(),
None,
NewTerminal.boxed_clone(),
false,
window,
cx,
);
pane.set_zoomed(zoomed, cx);
pane.set_can_navigate(false, cx);
pane.display_nav_history_buttons(None);
pane.set_should_display_tab_bar(|_, _| true);
pane.set_zoom_out_on_close(false);
let split_closure_terminal_panel = terminal_panel.downgrade();
pane.set_can_split(Some(Arc::new(move |pane, dragged_item, _window, cx| {
if let Some(tab) = dragged_item.downcast_ref::<DraggedTab>() {
let is_current_pane = tab.pane == cx.entity();
let Some(can_drag_away) = split_closure_terminal_panel
.read_with(cx, |terminal_panel, _| {
let current_panes = terminal_panel.center.panes();
!current_panes.contains(&&tab.pane)
|| current_panes.len() > 1
|| (!is_current_pane || pane.items_len() > 1)
})
.ok()
else {
return false;
};
if can_drag_away {
let item = if is_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::<TerminalView>().is_some();
}
}
}
false
})));
let buffer_search_bar = cx.new(|cx| {
search::BufferSearchBar::new(Some(project.read(cx).languages().clone()), window, cx)
});
let breadcrumbs = cx.new(|_| Breadcrumbs::new());
pane.toolbar().update(cx, |toolbar, cx| {
toolbar.add_item(buffer_search_bar, window, cx);
toolbar.add_item(breadcrumbs, window, cx);
});
let drop_closure_project = project.downgrade();
let drop_closure_terminal_panel = terminal_panel.downgrade();
pane.set_custom_drop_handle(cx, move |pane, dropped_item, window, cx| {
let Some(project) = drop_closure_project.upgrade() else {
return ControlFlow::Break(());
};
if let Some(tab) = dropped_item.downcast_ref::<DraggedTab>() {
let this_pane = cx.entity();
let item = if tab.pane == 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::<TerminalView>().is_some() {
let source = tab.pane.clone();
let item_id_to_move = item.item_id();
let Ok(new_split_pane) = pane
.drag_split_direction()
.map(|split_direction| {
drop_closure_terminal_panel.update(cx, |terminal_panel, cx| {
let is_zoomed = if terminal_panel.active_pane == this_pane {
pane.is_zoomed()
} else {
terminal_panel.active_pane.read(cx).is_zoomed()
};
let new_pane = new_terminal_pane(
workspace.clone(),
project.clone(),
is_zoomed,
window,
cx,
);
terminal_panel.apply_tab_bar_buttons(&new_pane, cx);
terminal_panel.center.split(
&this_pane,
&new_pane,
split_direction,
)?;
anyhow::Ok(new_pane)
})
})
.transpose()
else {
return ControlFlow::Break(());
};
match new_split_pane.transpose() {
// Source pane may be the one currently updated, so defer the move.
Ok(Some(new_pane)) => cx
.spawn_in(window, async move |_, cx| {
cx.update(|window, cx| {
move_item(
&source,
&new_pane,
item_id_to_move,
new_pane.read(cx).active_item_index(),
true,
window,
cx,
);
})
.ok();
})
.detach(),
// If we drop into existing pane or current pane,
// regular pane drop handler will take care of it,
// using the right tab index for the operation.
Ok(None) => return ControlFlow::Continue(()),
err @ Err(_) => {
err.log_err();
return ControlFlow::Break(());
}
};
} else if let Some(project_path) = item.project_path(cx)
&& let Some(entry_path) = project.read(cx).absolute_path(&project_path, cx)
{
add_paths_to_terminal(pane, &[entry_path], window, cx);
}
}
} else if let Some(selection) = dropped_item.downcast_ref::<DraggedSelection>() {
let project = project.read(cx);
let paths_to_add = selection
.items()
.map(|selected_entry| selected_entry.entry_id)
.filter_map(|entry_id| project.path_for_entry(entry_id, cx))
.filter_map(|project_path| project.absolute_path(&project_path, cx))
.collect::<Vec<_>>();
if !paths_to_add.is_empty() {
add_paths_to_terminal(pane, &paths_to_add, window, cx);
}
} else if let Some(&entry_id) = dropped_item.downcast_ref::<ProjectEntryId>() {
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], window, cx);
}
} else if is_local && let Some(paths) = dropped_item.downcast_ref::<ExternalPaths>() {
add_paths_to_terminal(pane, paths.paths(), window, cx);
}
ControlFlow::Break(())
});
pane
});
cx.subscribe_in(&pane, window, TerminalPanel::handle_pane_event)
.detach();
cx.observe(&pane, |_, _, cx| cx.notify()).detach();
pane
}
async fn wait_for_terminals_tasks(
terminals_for_task: Vec<(usize, Entity<Pane>, Entity<TerminalView>)>,
cx: &mut AsyncApp,
) {
let pending_tasks = terminals_for_task.iter().filter_map(|(_, _, terminal)| {
terminal
.update(cx, |terminal_view, cx| {
terminal_view
.terminal()
.update(cx, |terminal, cx| terminal.wait_for_completed_task(cx))
})
.ok()
});
join_all(pending_tasks).await;
}
fn add_paths_to_terminal(
pane: &mut Pane,
paths: &[PathBuf],
window: &mut Window,
cx: &mut Context<Pane>,
) {
if let Some(terminal_view) = pane
.active_item()
.and_then(|item| item.downcast::<TerminalView>())
{
window.focus(&terminal_view.focus_handle(cx));
let mut new_text = paths.iter().map(|path| format!(" {path:?}")).join("");
new_text.push(' ');
terminal_view.update(cx, |terminal_view, cx| {
terminal_view.terminal().update(cx, |terminal, _| {
terminal.paste(&new_text);
});
});
}
}
struct FailedToSpawnTerminal {
error: String,
focus_handle: FocusHandle,
}
impl Focusable for FailedToSpawnTerminal {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for FailedToSpawnTerminal {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let popover_menu = PopoverMenu::new("settings-popover")
.trigger(
IconButton::new("icon-button-popover", IconName::ChevronDown)
.icon_size(IconSize::XSmall),
)
.menu(move |window, cx| {
Some(ContextMenu::build(window, cx, |context_menu, _, _| {
context_menu
.action("Open Settings", zed_actions::OpenSettings.boxed_clone())
.action(
"Edit settings.json",
zed_actions::OpenSettingsFile.boxed_clone(),
)
}))
})
.anchor(Corner::TopRight)
.offset(gpui::Point {
x: px(0.0),
y: px(2.0),
});
v_flex()
.track_focus(&self.focus_handle)
.size_full()
.p_4()
.items_center()
.justify_center()
.bg(cx.theme().colors().editor_background)
.child(
v_flex()
.max_w_112()
.items_center()
.justify_center()
.text_center()
.child(Label::new("Failed to spawn terminal"))
.child(
Label::new(self.error.to_string())
.size(LabelSize::Small)
.color(Color::Muted)
.mb_4(),
)
.child(SplitButton::new(
ButtonLike::new("open-settings-ui")
.child(Label::new("Edit Settings").size(LabelSize::Small))
.on_click(|_, window, cx| {
window.dispatch_action(zed_actions::OpenSettings.boxed_clone(), cx);
}),
popover_menu.into_any_element(),
)),
)
}
}
impl EventEmitter<()> for FailedToSpawnTerminal {}
impl workspace::Item for FailedToSpawnTerminal {
type Event = ();
fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
SharedString::new_static("Failed to spawn terminal")
}
}
impl EventEmitter<PanelEvent> for TerminalPanel {}
impl Render for TerminalPanel {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let mut registrar = DivRegistrar::new(
|panel, _, cx| {
panel
.active_pane
.read(cx)
.toolbar()
.read(cx)
.item_of_type::<BufferSearchBar>()
},
cx,
);
BufferSearchBar::register(&mut registrar);
let registrar = registrar.into_div();
self.workspace
.update(cx, |workspace, cx| {
registrar.size_full().child(self.center.render(
workspace.zoomed_item(),
&workspace::PaneRenderContext {
follower_states: &HashMap::default(),
active_call: workspace.active_call(),
active_pane: &self.active_pane,
app_state: workspace.app_state(),
project: workspace.project(),
workspace: &workspace.weak_handle(),
},
window,
cx,
))
})
.ok()
.map(|div| {
div.on_action({
cx.listener(|terminal_panel, _: &ActivatePaneLeft, window, cx| {
terminal_panel.activate_pane_in_direction(SplitDirection::Left, window, cx);
})
})
.on_action({
cx.listener(|terminal_panel, _: &ActivatePaneRight, window, cx| {
terminal_panel.activate_pane_in_direction(
SplitDirection::Right,
window,
cx,
);
})
})
.on_action({
cx.listener(|terminal_panel, _: &ActivatePaneUp, window, cx| {
terminal_panel.activate_pane_in_direction(SplitDirection::Up, window, cx);
})
})
.on_action({
cx.listener(|terminal_panel, _: &ActivatePaneDown, window, cx| {
terminal_panel.activate_pane_in_direction(SplitDirection::Down, window, cx);
})
})
.on_action(
cx.listener(|terminal_panel, _action: &ActivateNextPane, window, 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();
window.focus(&panes[next_ix].focus_handle(cx));
}
}),
)
.on_action(cx.listener(
|terminal_panel, _action: &ActivatePreviousPane, window, 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);
window.focus(&panes[prev_ix].focus_handle(cx));
}
},
))
.on_action(
cx.listener(|terminal_panel, action: &ActivatePane, window, cx| {
let panes = terminal_panel.center.panes();
if let Some(&pane) = panes.get(action.0) {
window.focus(&pane.read(cx).focus_handle(cx));
} else {
let future =
terminal_panel.new_pane_with_cloned_active_terminal(window, cx);
cx.spawn_in(window, async move |terminal_panel, cx| {
if let Some(new_pane) = future.await {
_ = terminal_panel.update_in(
cx,
|terminal_panel, window, cx| {
terminal_panel
.center
.split(
&terminal_panel.active_pane,
&new_pane,
SplitDirection::Right,
)
.log_err();
let new_pane = new_pane.read(cx);
window.focus(&new_pane.focus_handle(cx));
},
);
}
})
.detach();
}
}),
)
.on_action(cx.listener(|terminal_panel, _: &SwapPaneLeft, _, cx| {
terminal_panel.swap_pane_in_direction(SplitDirection::Left, cx);
}))
.on_action(cx.listener(|terminal_panel, _: &SwapPaneRight, _, cx| {
terminal_panel.swap_pane_in_direction(SplitDirection::Right, cx);
}))
.on_action(cx.listener(|terminal_panel, _: &SwapPaneUp, _, cx| {
terminal_panel.swap_pane_in_direction(SplitDirection::Up, cx);
}))
.on_action(cx.listener(|terminal_panel, _: &SwapPaneDown, _, cx| {
terminal_panel.swap_pane_in_direction(SplitDirection::Down, cx);
}))
.on_action(cx.listener(|terminal_panel, _: &MovePaneLeft, _, cx| {
terminal_panel.move_pane_to_border(SplitDirection::Left, cx);
}))
.on_action(cx.listener(|terminal_panel, _: &MovePaneRight, _, cx| {
terminal_panel.move_pane_to_border(SplitDirection::Right, cx);
}))
.on_action(cx.listener(|terminal_panel, _: &MovePaneUp, _, cx| {
terminal_panel.move_pane_to_border(SplitDirection::Up, cx);
}))
.on_action(cx.listener(|terminal_panel, _: &MovePaneDown, _, cx| {
terminal_panel.move_pane_to_border(SplitDirection::Down, cx);
}))
.on_action(
cx.listener(|terminal_panel, action: &MoveItemToPane, window, cx| {
let Some(&target_pane) =
terminal_panel.center.panes().get(action.destination)
else {
return;
};
move_active_item(
&terminal_panel.active_pane,
target_pane,
action.focus,
true,
window,
cx,
);
}),
)
.on_action(cx.listener(
|terminal_panel, action: &MoveItemToPaneInDirection, window, cx| {
let source_pane = &terminal_panel.active_pane;
if let Some(destination_pane) = terminal_panel
.center
.find_pane_in_direction(source_pane, action.direction, cx)
{
move_active_item(
source_pane,
destination_pane,
action.focus,
true,
window,
cx,
);
};
},
))
})
.unwrap_or_else(|| div())
}
}
impl Focusable for TerminalPanel {
fn focus_handle(&self, cx: &App) -> FocusHandle {
self.active_pane.focus_handle(cx)
}
}
impl Panel for TerminalPanel {
fn position(&self, _window: &Window, cx: &App) -> DockPosition {
match TerminalSettings::get_global(cx).dock {
TerminalDockPosition::Left => DockPosition::Left,
TerminalDockPosition::Bottom => DockPosition::Bottom,
TerminalDockPosition::Right => DockPosition::Right,
}
}
fn position_is_valid(&self, _: DockPosition) -> bool {
true
}
fn set_position(
&mut self,
position: DockPosition,
_window: &mut Window,
cx: &mut Context<Self>,
) {
settings::update_settings_file(self.fs.clone(), cx, move |settings, _| {
let dock = match position {
DockPosition::Left => TerminalDockPosition::Left,
DockPosition::Bottom => TerminalDockPosition::Bottom,
DockPosition::Right => TerminalDockPosition::Right,
};
settings.terminal.get_or_insert_default().dock = Some(dock);
});
}
fn size(&self, window: &Window, cx: &App) -> Pixels {
let settings = TerminalSettings::get_global(cx);
match self.position(window, cx) {
DockPosition::Left | DockPosition::Right => {
self.width.unwrap_or(settings.default_width)
}
DockPosition::Bottom => self.height.unwrap_or(settings.default_height),
}
}
fn set_size(&mut self, size: Option<Pixels>, window: &mut Window, cx: &mut Context<Self>) {
match self.position(window, cx) {
DockPosition::Left | DockPosition::Right => self.width = size,
DockPosition::Bottom => self.height = size,
}
cx.notify();
cx.defer_in(window, |this, _, cx| {
this.serialize(cx);
})
}
fn is_zoomed(&self, _window: &Window, cx: &App) -> bool {
self.active_pane.read(cx).is_zoomed()
}
fn set_zoomed(&mut self, zoomed: bool, _: &mut Window, cx: &mut Context<Self>) {
for pane in self.center.panes() {
pane.update(cx, |pane, cx| {
pane.set_zoomed(zoomed, cx);
})
}
cx.notify();
}
fn set_active(&mut self, active: bool, window: &mut Window, cx: &mut Context<Self>) {
let old_active = self.active;
self.active = active;
if !active || old_active == active || !self.has_no_terminals(cx) {
return;
}
cx.defer_in(window, |this, window, cx| {
let Ok(kind) = this
.workspace
.update(cx, |workspace, cx| default_working_directory(workspace, cx))
else {
return;
};
this.add_terminal_shell(kind, RevealStrategy::Always, window, cx)
.detach_and_log_err(cx)
})
}
fn icon_label(&self, _window: &Window, cx: &App) -> Option<String> {
let count = self
.center
.panes()
.into_iter()
.map(|pane| pane.read(cx).items_len())
.sum::<usize>();
if count == 0 {
None
} else {
Some(count.to_string())
}
}
fn persistent_name() -> &'static str {
"TerminalPanel"
}
fn panel_key() -> &'static str {
TERMINAL_PANEL_KEY
}
fn icon(&self, _window: &Window, cx: &App) -> Option<IconName> {
if (self.is_enabled(cx) || !self.has_no_terminals(cx))
&& TerminalSettings::get_global(cx).button
{
Some(IconName::TerminalAlt)
} else {
None
}
}
fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
Some("Terminal Panel")
}
fn toggle_action(&self) -> Box<dyn gpui::Action> {
Box::new(ToggleFocus)
}
fn pane(&self) -> Option<Entity<Pane>> {
Some(self.active_pane.clone())
}
fn activation_priority(&self) -> u32 {
1
}
}
struct TerminalProvider(Entity<TerminalPanel>);
impl workspace::TerminalProvider for TerminalProvider {
fn spawn(
&self,
task: SpawnInTerminal,
window: &mut Window,
cx: &mut App,
) -> Task<Option<Result<ExitStatus>>> {
let terminal_panel = self.0.clone();
window.spawn(cx, async move |cx| {
let terminal = terminal_panel
.update_in(cx, |terminal_panel, window, cx| {
terminal_panel.spawn_task(&task, window, cx)
})
.ok()?
.await;
match terminal {
Ok(terminal) => {
let exit_status = terminal
.read_with(cx, |terminal, cx| terminal.wait_for_completed_task(cx))
.ok()?
.await?;
Some(Ok(exit_status))
}
Err(e) => Some(Err(e)),
}
})
}
}
struct InlineAssistTabBarButton {
focus_handle: FocusHandle,
}
impl Render for InlineAssistTabBarButton {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let focus_handle = self.focus_handle.clone();
IconButton::new("terminal_inline_assistant", IconName::ZedAssistant)
.icon_size(IconSize::Small)
.on_click(cx.listener(|_, _, window, cx| {
window.dispatch_action(InlineAssist::default().boxed_clone(), cx);
}))
.tooltip(move |_window, cx| {
Tooltip::for_action_in("Inline Assist", &InlineAssist::default(), &focus_handle, cx)
})
}
}
#[cfg(test)]
mod tests {
use std::num::NonZero;
use super::*;
use gpui::{TestAppContext, UpdateGlobal as _};
use pretty_assertions::assert_eq;
use project::FakeFs;
use settings::SettingsStore;
#[gpui::test]
async fn test_spawn_an_empty_task(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, [], cx).await;
let workspace = cx.add_window(|window, cx| Workspace::test_new(project, window, cx));
let (window_handle, terminal_panel) = workspace
.update(cx, |workspace, window, cx| {
let window_handle = window.window_handle();
let terminal_panel = cx.new(|cx| TerminalPanel::new(workspace, window, cx));
(window_handle, terminal_panel)
})
.unwrap();
let task = window_handle
.update(cx, |_, window, cx| {
terminal_panel.update(cx, |terminal_panel, cx| {
terminal_panel.spawn_task(&SpawnInTerminal::default(), window, cx)
})
})
.unwrap();
let terminal = task.await.unwrap();
let expected_shell = util::get_system_shell();
terminal
.update(cx, |terminal, _| {
let task_metadata = terminal
.task()
.expect("When spawning a task, should have the task metadata")
.spawned_task
.clone();
assert_eq!(task_metadata.env, HashMap::default());
assert_eq!(task_metadata.cwd, None);
assert_eq!(task_metadata.shell, task::Shell::System);
assert_eq!(
task_metadata.command,
Some(expected_shell.clone()),
"Empty tasks should spawn a -i shell"
);
assert_eq!(task_metadata.args, Vec::<String>::new());
assert_eq!(
task_metadata.command_label, expected_shell,
"We show the shell launch for empty commands"
);
})
.unwrap();
}
#[gpui::test]
async fn test_bypass_max_tabs_limit(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, [], cx).await;
let workspace = cx.add_window(|window, cx| Workspace::test_new(project, window, cx));
let (window_handle, terminal_panel) = workspace
.update(cx, |workspace, window, cx| {
let window_handle = window.window_handle();
let terminal_panel = cx.new(|cx| TerminalPanel::new(workspace, window, cx));
(window_handle, terminal_panel)
})
.unwrap();
set_max_tabs(cx, Some(3));
for _ in 0..5 {
let task = window_handle
.update(cx, |_, window, cx| {
terminal_panel.update(cx, |panel, cx| {
panel.add_terminal_shell(None, RevealStrategy::Always, window, cx)
})
})
.unwrap();
task.await.unwrap();
}
cx.run_until_parked();
let item_count =
terminal_panel.read_with(cx, |panel, cx| panel.active_pane.read(cx).items_len());
assert_eq!(
item_count, 5,
"Terminal panel should bypass max_tabs limit and have all 5 terminals"
);
}
// A complex Unix command won't be properly parsed by the Windows terminal hence omit the test there.
#[cfg(unix)]
#[gpui::test]
async fn test_spawn_script_like_task(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, [], cx).await;
let workspace = cx.add_window(|window, cx| Workspace::test_new(project, window, cx));
let (window_handle, terminal_panel) = workspace
.update(cx, |workspace, window, cx| {
let window_handle = window.window_handle();
let terminal_panel = cx.new(|cx| TerminalPanel::new(workspace, window, cx));
(window_handle, terminal_panel)
})
.unwrap();
let user_command = r#"REPO_URL=$(git remote get-url origin | sed -e \"s/^git@\\(.*\\):\\(.*\\)\\.git$/https:\\/\\/\\1\\/\\2/\"); COMMIT_SHA=$(git log -1 --format=\"%H\" -- \"${ZED_RELATIVE_FILE}\"); echo \"${REPO_URL}/blob/${COMMIT_SHA}/${ZED_RELATIVE_FILE}#L${ZED_ROW}-$(echo $(($(wc -l <<< \"$ZED_SELECTED_TEXT\") + $ZED_ROW - 1)))\" | xclip -selection clipboard"#.to_string();
let expected_cwd = PathBuf::from("/some/work");
let task = window_handle
.update(cx, |_, window, cx| {
terminal_panel.update(cx, |terminal_panel, cx| {
terminal_panel.spawn_task(
&SpawnInTerminal {
command: Some(user_command.clone()),
cwd: Some(expected_cwd.clone()),
..SpawnInTerminal::default()
},
window,
cx,
)
})
})
.unwrap();
let terminal = task.await.unwrap();
let shell = util::get_system_shell();
terminal
.update(cx, |terminal, _| {
let task_metadata = terminal
.task()
.expect("When spawning a task, should have the task metadata")
.spawned_task
.clone();
assert_eq!(task_metadata.env, HashMap::default());
assert_eq!(task_metadata.cwd, Some(expected_cwd));
assert_eq!(task_metadata.shell, task::Shell::System);
assert_eq!(task_metadata.command, Some(shell.clone()));
assert_eq!(
task_metadata.args,
vec!["-i".to_string(), "-c".to_string(), user_command.clone(),],
"Use command should have been moved into the arguments, as we're spawning a new -i shell",
);
assert_eq!(
task_metadata.command_label,
format!("{shell} {interactive}-c '{user_command}'", interactive = if cfg!(windows) {""} else {"-i "}),
"We want to show to the user the entire command spawned");
})
.unwrap();
}
#[gpui::test]
async fn renders_error_if_default_shell_fails(cx: &mut TestAppContext) {
init_test(cx);
cx.update(|cx| {
SettingsStore::update_global(cx, |store, cx| {
store.update_user_settings(cx, |settings| {
settings.terminal.get_or_insert_default().project.shell =
Some(settings::Shell::Program("asdf".to_owned()));
});
});
});
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, [], cx).await;
let workspace = cx.add_window(|window, cx| Workspace::test_new(project, window, cx));
let (window_handle, terminal_panel) = workspace
.update(cx, |workspace, window, cx| {
let window_handle = window.window_handle();
let terminal_panel = cx.new(|cx| TerminalPanel::new(workspace, window, cx));
(window_handle, terminal_panel)
})
.unwrap();
window_handle
.update(cx, |_, window, cx| {
terminal_panel.update(cx, |terminal_panel, cx| {
terminal_panel.add_terminal_shell(None, RevealStrategy::Always, window, cx)
})
})
.unwrap()
.await
.unwrap_err();
window_handle
.update(cx, |_, _, cx| {
terminal_panel.update(cx, |terminal_panel, cx| {
assert!(
terminal_panel
.active_pane
.read(cx)
.items()
.any(|item| item.downcast::<FailedToSpawnTerminal>().is_some()),
"should spawn `FailedToSpawnTerminal` pane"
);
})
})
.unwrap();
}
fn set_max_tabs(cx: &mut TestAppContext, value: Option<usize>) {
cx.update_global(|store: &mut SettingsStore, cx| {
store.update_user_settings(cx, |settings| {
settings.workspace.max_tabs = value.map(|v| NonZero::new(v).unwrap())
});
});
}
pub fn init_test(cx: &mut TestAppContext) {
cx.update(|cx| {
let store = SettingsStore::test(cx);
cx.set_global(store);
theme::init(theme::LoadThemes::JustBase, cx);
client::init_settings(cx);
language::init(cx);
Project::init_settings(cx);
workspace::init_settings(cx);
editor::init(cx);
crate::init(cx);
});
}
}