- Fix start_agent tool mapping: route to Tool::StartAgent instead of dead-end Tool::Subagent so tool calls become executable actions - Block start_agent until child finishes: parent waits for child conversation to complete and receives full output as tool result - Fix tool result delivery: add StartAgent/StartAgentV2 cases to extract_tool_result_content so the model actually sees agent output - Support parallel agent spawning: change StartAgent action phase from Serial to Parallel, and track multiple pending agents via Vec - Mark child conversations as Success on EndTurn: emit ConversationStatus::Success when a child stream ends with no actions - Skip orchestration SSE in local mode: prevent app freeze from trying to connect to non-existent server - Remove send_message_to_agent and suggest_next_prompt from tool list: these require server infrastructure that doesn't exist in OSS mode - Add "Waiting for sub-agents..." status message while agents process - Fix child agent pane close: actually dismiss instead of re-hiding, track dismissed IDs to prevent re-creation on restart - Fix cache_miss_tokens calculation and show per-block cache stats - Add [tool-debug] logging throughout tool invocation pipeline Bump version to 1.6.0. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
7021 lines
270 KiB
Rust
7021 lines
270 KiB
Rust
use crate::ai::active_agent_views_model::ActiveAgentViewsModel;
|
|
use crate::ai::agent::api::ServerConversationToken;
|
|
use crate::ai::agent::conversation::{AIAgentHarness, AIConversation, AIConversationId};
|
|
use crate::ai::agent_conversations_model::{
|
|
AgentConversationsModel, AgentConversationsModelEvent, ConversationOrTask,
|
|
};
|
|
use crate::ai::ai_document_view::AIDocumentView;
|
|
use crate::ai::ambient_agents::AmbientAgentTaskId;
|
|
use crate::ai::blocklist::agent_view::AgentViewEntryOrigin;
|
|
use crate::ai::blocklist::history_model::CloudConversationData;
|
|
use crate::ai::blocklist::inline_action::code_diff_view::CodeDiffView;
|
|
use crate::ai::blocklist::suggested_agent_mode_workflow_modal::SuggestedAgentModeWorkflowAndId;
|
|
use crate::ai::blocklist::suggested_rule_modal::SuggestedRuleAndId;
|
|
use crate::ai::blocklist::{BlocklistAIHistoryModel, InputConfig};
|
|
use crate::ai::document::ai_document_model::{AIDocumentId, AIDocumentModel, AIDocumentVersion};
|
|
use crate::ai::execution_profiles::profiles::{AIExecutionProfilesModel, ClientProfileId};
|
|
use crate::ai::llms::LLMId;
|
|
use crate::ai::restored_conversations::RestoredAgentConversations;
|
|
use crate::auth::auth_manager::AuthManager;
|
|
use crate::auth::auth_view_modal::AuthViewVariant;
|
|
use crate::auth::AuthStateProvider;
|
|
use crate::cloud_object::Space;
|
|
#[cfg(feature = "local_fs")]
|
|
use crate::code::editor_management::CodeSource;
|
|
use crate::code::view::CodeViewAction;
|
|
use crate::code_review::comments::{AttachedReviewComment, PendingImportedReviewComment};
|
|
use crate::code_review::diff_state::DiffMode;
|
|
use crate::env_vars::EnvVarCollectionType;
|
|
use crate::notebooks::file::FileNotebookView;
|
|
use crate::pane_group::focus_state::PaneGroupFocusEvent;
|
|
use crate::pane_group::pane::get_started_pane::GetStartedPane;
|
|
use crate::pane_group::pane::welcome_pane::WelcomePane;
|
|
use crate::pane_group::pane::ActionOrigin;
|
|
use crate::quit_warning::UnsavedStateSummary;
|
|
#[cfg(target_family = "wasm")]
|
|
use crate::server::cloud_objects::update_manager::UpdateManager;
|
|
use crate::server::server_api::ServerApiProvider;
|
|
use crate::settings::{AISettings, DefaultSessionMode, PaneSettings};
|
|
use crate::settings_view::SettingsSection;
|
|
use crate::shell_indicator::ShellIndicatorType;
|
|
use crate::terminal::available_shells::{AvailableShell, AvailableShells};
|
|
#[cfg(not(target_family = "wasm"))]
|
|
use crate::terminal::cli_agent_sessions::plugin_manager::PluginModalKind;
|
|
use crate::terminal::view::inline_banner::{
|
|
ZeroStatePromptSuggestionTriggeredFrom, ZeroStatePromptSuggestionType,
|
|
};
|
|
use crate::terminal::view::load_ai_conversation::RestoredAIConversation;
|
|
use crate::undo_close::UndoCloseStack;
|
|
use crate::undo_close::UndoCloseStackEvent;
|
|
#[cfg(target_family = "wasm")]
|
|
use crate::uri::browser_url_handler::update_browser_url;
|
|
#[cfg(feature = "local_fs")]
|
|
use crate::util::openable_file_type::FileTarget;
|
|
use crate::view_components::ToastFlavor;
|
|
use crate::workflows::workflow::Workflow;
|
|
use galaxy_terminal::shell::{ShellName, ShellType};
|
|
|
|
use std::any::Any;
|
|
use std::cell::RefCell;
|
|
use std::collections::{HashMap, HashSet};
|
|
use std::ffi::OsString;
|
|
use std::path::PathBuf;
|
|
use std::rc::Rc;
|
|
use std::sync::{mpsc::SyncSender, Arc};
|
|
|
|
use itertools::Itertools;
|
|
use lazy_static::lazy_static;
|
|
|
|
use galaxy_cli::agent::Harness;
|
|
use galaxy_core::command::ExitCode;
|
|
use galaxy_core::context_flag::ContextFlag;
|
|
use galaxy_core::HostId;
|
|
use galaxy_util::path::convert_wsl_to_windows_host_path;
|
|
#[cfg(feature = "local_fs")]
|
|
use galaxy_util::path::LineAndColumnArg;
|
|
use galaxyui::elements::{
|
|
Clipped, CrossAxisAlignment, DispatchEventResult, EventHandler, Flex, MainAxisSize, Shrinkable,
|
|
Stack,
|
|
};
|
|
use galaxyui::keymap::{Context, EditableBinding, FixedBinding};
|
|
use galaxyui::notification::NotificationSendError;
|
|
use markdown_parser::FormattedTextFragment;
|
|
use parking_lot::FairMutex;
|
|
use pathfinder_geometry::rect::RectF;
|
|
use pathfinder_geometry::vector::{vec2f, Vector2F};
|
|
use serde::{Deserialize, Serialize};
|
|
use session_sharing_protocol::common::{
|
|
ParticipantId, Role, RoleRequestId, RoleRequestRejectedReason, RoleRequestResponse, SessionId,
|
|
};
|
|
use tree::DEFAULT_FLEX_VALUE;
|
|
use typed_path::TypedPath;
|
|
use url::Url;
|
|
use uuid::Uuid;
|
|
|
|
use galaxyui::windowing::WindowManager;
|
|
use galaxyui::{
|
|
elements::{ChildView, Element, ParentElement},
|
|
AppContext, Entity, EntityId, ModelHandle, TypedActionView, View, ViewHandle, WindowId,
|
|
};
|
|
use galaxyui::{SingletonEntity, ViewContext};
|
|
|
|
use crate::ai::blocklist::SerializedBlockListItem;
|
|
use crate::ai_assistant::AskAIType;
|
|
#[cfg(feature = "local_fs")]
|
|
use crate::app_state::CodePaneSnapShot;
|
|
use crate::app_state::{
|
|
self, AIFactPaneSnapshot, BranchSnapshot, EnvVarCollectionPaneSnapshot, LeafContents,
|
|
LeafSnapshot, NotebookPaneSnapshot, PaneNodeSnapshot, PaneUuid, SettingsPaneSnapshot,
|
|
TerminalPaneSnapshot, WorkflowPaneSnapshot,
|
|
};
|
|
use crate::appearance::Appearance;
|
|
use crate::banner::{Banner, BannerEvent, BannerState, BannerTextContent, DismissalType};
|
|
use crate::channel::{Channel, ChannelState};
|
|
use crate::code::view::CodeView;
|
|
use crate::drive::items::WarpDriveItemId;
|
|
use crate::drive::{CloudObjectTypeAndId, OpenGalaxyDriveObjectArgs};
|
|
use crate::features::FeatureFlag;
|
|
use crate::launch_configs::launch_config::{self, PaneMode, PaneTemplateType};
|
|
use crate::persistence::ModelEvent;
|
|
use crate::report_if_error;
|
|
use crate::resource_center::{
|
|
mark_feature_used_and_write_to_user_defaults, Tip, TipAction, TipsCompleted,
|
|
};
|
|
use crate::server::ids::{ObjectUid, SyncId};
|
|
use crate::server::telemetry::{
|
|
AnonymousUserSignupEntrypoint, PaletteSource, SharingDialogSource, TelemetryEvent,
|
|
};
|
|
use crate::session_management::SessionNavigationData;
|
|
use crate::settings_view::mcp_servers_page::MCPServersSettingsPage;
|
|
use crate::terminal::general_settings::{GeneralSettings, GeneralSettingsChangedEvent};
|
|
#[cfg(feature = "local_tty")]
|
|
use crate::terminal::local_tty;
|
|
use crate::terminal::model::session::Session;
|
|
use crate::terminal::session_settings::NewSessionSource;
|
|
use crate::terminal::session_settings::SessionSettings;
|
|
use crate::terminal::shared_session::render_util::ParticipantAvatarParams;
|
|
use crate::terminal::shared_session::role_change_modal::{
|
|
RoleChangeCloseSource, RoleChangeModal, RoleChangeModalEvent,
|
|
};
|
|
use crate::terminal::shared_session::share_modal::{ShareSessionModal, ShareSessionModalEvent};
|
|
use crate::terminal::shared_session::{self, IsSharedSessionCreator, SharedSessionActionSource};
|
|
use crate::terminal::view::ssh_file_upload::FileUploadId;
|
|
use crate::terminal::view::{
|
|
BlockNotification, ConversationRestorationInNewPaneType, ExecuteCommandEvent,
|
|
LeftPanelTargetView, SyncEvent, TerminalViewState,
|
|
};
|
|
use crate::terminal::{
|
|
MockTerminalManager, ShareBlockModal, ShareBlockModalEvent, ShellLaunchData, ShellLaunchState,
|
|
};
|
|
use crate::{cmd_or_ctrl_shift, send_telemetry_from_ctx};
|
|
use session_sharing_protocol::sharer::SessionSourceType;
|
|
use settings::Setting as _;
|
|
|
|
use crate::code::active_file::ActiveFileModel;
|
|
use crate::util::bindings::{is_binding_pty_compliant, CustomAction};
|
|
use crate::workflows::{WorkflowSelectionSource, WorkflowSource, WorkflowType};
|
|
|
|
use crate::palette::PaletteMode;
|
|
use crate::terminal::model::terminal_model::ConversationTranscriptViewerStatus;
|
|
use crate::workspace::{
|
|
self, CommandSearchOptions, PaneViewLocator, TabBarLocation, WorkspaceAction,
|
|
};
|
|
use crate::{
|
|
server::server_api::ServerApi,
|
|
terminal::{TerminalManager, TerminalModel, TerminalView},
|
|
};
|
|
|
|
mod child_agent;
|
|
pub mod focus_state;
|
|
pub mod pane;
|
|
pub mod tree;
|
|
pub mod working_directories;
|
|
|
|
use focus_state::PaneGroupFocusState;
|
|
|
|
#[cfg(test)]
|
|
#[path = "mod_tests.rs"]
|
|
mod tests;
|
|
|
|
pub use crate::code_review::CodeReviewPanelArg;
|
|
pub use pane::ai_document_pane::AIDocumentPane;
|
|
pub use pane::ai_fact_pane::AIFactPane;
|
|
pub use pane::code_diff_pane::CodeDiffPane;
|
|
pub use pane::code_pane::CodePane;
|
|
pub use pane::env_var_collection_pane::EnvVarCollectionPane;
|
|
pub use pane::environment_management_pane::EnvironmentManagementPane;
|
|
pub use pane::execution_profile_editor_pane::ExecutionProfileEditorPane;
|
|
pub use pane::file_pane::FilePane;
|
|
pub use pane::network_log_pane::NetworkLogPane;
|
|
pub use pane::notebook_pane::NotebookPane;
|
|
pub use pane::settings_pane::SettingsPane;
|
|
pub use pane::terminal_pane::TerminalPane;
|
|
pub use pane::workflow_pane::WorkflowPane;
|
|
pub use pane::PaneHeaderAction;
|
|
pub use pane::PaneHeaderCustomAction;
|
|
pub use pane::{
|
|
AnyPaneContent, BackingView, PaneConfiguration, PaneConfigurationEvent, PaneContent, PaneEvent,
|
|
PaneId, PaneView, TerminalPaneId,
|
|
};
|
|
pub use tree::{Direction, PaneData, PaneFlex, PaneNode, SplitDirection};
|
|
pub use working_directories::{WorkingDirectoriesEvent, WorkingDirectoriesModel};
|
|
|
|
use self::pane::{DetachType, PaneViewEvent};
|
|
|
|
lazy_static! {
|
|
// The value to use as the initial window bounds if we are unable to
|
|
// determine them for any reason.
|
|
static ref FALLBACK_INITIAL_WINDOW_SIZE: Vector2F = Vector2F::new(1024., 768.);
|
|
}
|
|
|
|
const MINIMUM_PANE_SIZE: f32 = 50.;
|
|
const MINIMUM_PANE_SIZE_UDI: f32 = 190.;
|
|
const KEYBOARD_RESIZE_DELTA: f32 = 10.;
|
|
|
|
fn get_minimum_pane_size(app: &AppContext) -> f32 {
|
|
use crate::settings::InputSettings;
|
|
if InputSettings::as_ref(app).is_universal_developer_input_enabled(app) {
|
|
MINIMUM_PANE_SIZE_UDI
|
|
} else {
|
|
MINIMUM_PANE_SIZE
|
|
}
|
|
}
|
|
|
|
/// Resolves a tab config `shell` value (e.g. `"pwsh"` or
|
|
/// `"/opt/homebrew/bin/pwsh"`) into an [`AvailableShell`], using the fallback
|
|
/// order expected by tab configs:
|
|
///
|
|
/// 1. If `name` contains a path separator, trust it directly so users can
|
|
/// still point at arbitrary binaries.
|
|
/// 2. Otherwise look up by command name in the already-discovered
|
|
/// [`AvailableShells`]. Its shell discovery supplements the process `PATH`
|
|
/// with well-known install locations (e.g. `/opt/homebrew/bin` on macOS,
|
|
/// MSYS2/WSL on Windows) that a raw `PATH` lookup would miss when Warp is
|
|
/// launched outside an interactive shell.
|
|
/// 3. As a final fallback, perform a plain `PATH` lookup via
|
|
/// [`AvailableShell::try_from`] in case the user put something exotic in
|
|
/// `shell`.
|
|
#[cfg(feature = "local_tty")]
|
|
fn resolve_tab_config_shell(name: &str, ctx: &AppContext) -> Option<AvailableShell> {
|
|
if name.contains(std::path::MAIN_SEPARATOR) {
|
|
return AvailableShell::try_from(name).ok();
|
|
}
|
|
|
|
if let Some(matched) = AvailableShells::as_ref(ctx).find_by_command_name(name) {
|
|
return Some(matched);
|
|
}
|
|
|
|
AvailableShell::try_from(name).ok()
|
|
}
|
|
const WARP_SHELL_COMPATIBILITY_DOCS: &str =
|
|
"https://docs.warp.dev/getting-started/supported-shells";
|
|
// Default minimum width for a newly created Agent Mode pane so that it is legible. Called "default"
|
|
// because this value may be too large for small windows. In that case, we fall back to 50% of the
|
|
// window width.
|
|
pub const AGENT_MODE_PANE_DEFAULT_MINIMUM_WIDTH: f32 = 400.;
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub enum ActivationReason {
|
|
Click,
|
|
Hover,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub enum PaneGroupAction {
|
|
Add(Direction),
|
|
Remove(PaneId),
|
|
RemoveActive,
|
|
Activate(PaneId, ActivationReason),
|
|
ResizeMove(Vector2F),
|
|
StartResizing(DraggedBorder),
|
|
Move {
|
|
id: PaneId,
|
|
target_pane_id: PaneId,
|
|
direction: Direction,
|
|
},
|
|
EndResizing,
|
|
ResizeLeft,
|
|
ResizeRight,
|
|
ResizeUp,
|
|
ResizeDown,
|
|
NavigatePrev,
|
|
NavigateNext,
|
|
NavigateLeft,
|
|
NavigateRight,
|
|
NavigateUp,
|
|
NavigateDown,
|
|
ToggleMaximizePane,
|
|
HandleFocusChange,
|
|
FocusTerminalView(EntityId),
|
|
}
|
|
#[derive(PartialEq)]
|
|
enum PaneRemovalReason {
|
|
// This pane is being removed from the pane group because it is being moved to another tab or becoming a tab of its own
|
|
Move,
|
|
// This pane is being removed because it is being closed
|
|
Close,
|
|
}
|
|
|
|
pub fn init(app: &mut AppContext) {
|
|
use galaxyui::keymap::macros::*;
|
|
app.register_binding_validator::<PaneGroup>(is_binding_pty_compliant);
|
|
|
|
self::pane::init(app);
|
|
|
|
app.register_fixed_bindings([
|
|
// Also create the navigation shortcuts with `meta` in place of `alt`, to accommodate
|
|
// the "Left Option is Meta" and "Right Option is Meta" settings
|
|
FixedBinding::new(
|
|
"cmdorctrl-meta-left",
|
|
PaneGroupAction::NavigateLeft,
|
|
id!("PaneGroup") & !id!("PaneGroup_PaneMaximized") & !id!("PaneGroup_PaneDragging"),
|
|
),
|
|
FixedBinding::new(
|
|
"cmdorctrl-meta-right",
|
|
PaneGroupAction::NavigateRight,
|
|
id!("PaneGroup") & !id!("PaneGroup_PaneMaximized") & !id!("PaneGroup_PaneDragging"),
|
|
),
|
|
FixedBinding::new(
|
|
"cmdorctrl-meta-up",
|
|
PaneGroupAction::NavigateUp,
|
|
id!("PaneGroup") & !id!("PaneGroup_PaneMaximized") & !id!("PaneGroup_PaneDragging"),
|
|
),
|
|
FixedBinding::new(
|
|
"cmdorctrl-meta-down",
|
|
PaneGroupAction::NavigateDown,
|
|
id!("PaneGroup") & !id!("PaneGroup_PaneMaximized") & !id!("PaneGroup_PaneDragging"),
|
|
),
|
|
]);
|
|
|
|
app.register_editable_bindings([
|
|
EditableBinding::new(
|
|
"pane_group:close_current_session",
|
|
"Close Current Session",
|
|
PaneGroupAction::RemoveActive,
|
|
)
|
|
.with_custom_action(CustomAction::CloseCurrentSession)
|
|
.with_context_predicate(id!("PaneGroup")),
|
|
EditableBinding::new(
|
|
"pane_group:add_left",
|
|
"Split pane left",
|
|
PaneGroupAction::Add(Direction::Left),
|
|
)
|
|
.with_context_predicate(id!("PaneGroup") & !id!("PaneGroup_PaneDragging"))
|
|
.with_custom_action(CustomAction::SplitPaneLeft)
|
|
.with_enabled(|| ContextFlag::CreateNewSession.is_enabled()),
|
|
EditableBinding::new(
|
|
"pane_group:add_up",
|
|
"Split pane up",
|
|
PaneGroupAction::Add(Direction::Up),
|
|
)
|
|
.with_context_predicate(id!("PaneGroup") & !id!("PaneGroup_PaneDragging"))
|
|
.with_custom_action(CustomAction::SplitPaneUp)
|
|
.with_enabled(|| ContextFlag::CreateNewSession.is_enabled()),
|
|
EditableBinding::new(
|
|
"pane_group:navigate_left",
|
|
"Switch panes left",
|
|
PaneGroupAction::NavigateLeft,
|
|
)
|
|
.with_context_predicate(
|
|
id!("PaneGroup") & !id!("PaneGroup_PaneMaximized") & !id!("PaneGroup_PaneDragging"),
|
|
)
|
|
.with_key_binding("cmdorctrl-alt-left"),
|
|
EditableBinding::new(
|
|
"pane_group:navigate_right",
|
|
"Switch panes right",
|
|
PaneGroupAction::NavigateRight,
|
|
)
|
|
.with_context_predicate(
|
|
id!("PaneGroup") & !id!("PaneGroup_PaneMaximized") & !id!("PaneGroup_PaneDragging"),
|
|
)
|
|
.with_key_binding("cmdorctrl-alt-right"),
|
|
EditableBinding::new(
|
|
"pane_group:navigate_up",
|
|
"Switch panes up",
|
|
PaneGroupAction::NavigateUp,
|
|
)
|
|
.with_context_predicate(
|
|
id!("PaneGroup") & !id!("PaneGroup_PaneMaximized") & !id!("PaneGroup_PaneDragging"),
|
|
)
|
|
.with_key_binding("cmdorctrl-alt-up"),
|
|
EditableBinding::new(
|
|
"pane_group:navigate_down",
|
|
"Switch panes down",
|
|
PaneGroupAction::NavigateDown,
|
|
)
|
|
.with_context_predicate(
|
|
id!("PaneGroup") & !id!("PaneGroup_PaneMaximized") & !id!("PaneGroup_PaneDragging"),
|
|
)
|
|
.with_key_binding("cmdorctrl-alt-down"),
|
|
]);
|
|
|
|
// Register bindings to resize a pane. We only set bindings on Mac because there isn't an
|
|
// equivalent binding on Linux/Windows that makes sense here. This matches the behavior of
|
|
// VSCode.
|
|
app.register_editable_bindings([
|
|
EditableBinding::new(
|
|
"pane_group:resize_left",
|
|
"Resize pane > Move divider left",
|
|
PaneGroupAction::ResizeLeft,
|
|
)
|
|
.with_context_predicate(
|
|
id!("PaneGroup") & !id!("PaneGroup_PaneMaximized") & !id!("PaneGroup_PaneDragging"),
|
|
)
|
|
.with_mac_key_binding("cmd-ctrl-left"),
|
|
EditableBinding::new(
|
|
"pane_group:resize_right",
|
|
"Resize pane > Move divider right",
|
|
PaneGroupAction::ResizeRight,
|
|
)
|
|
.with_context_predicate(
|
|
id!("PaneGroup") & !id!("PaneGroup_PaneMaximized") & !id!("PaneGroup_PaneDragging"),
|
|
)
|
|
.with_mac_key_binding("cmd-ctrl-right"),
|
|
EditableBinding::new(
|
|
"pane_group:resize_up",
|
|
"Resize pane > Move divider up",
|
|
PaneGroupAction::ResizeUp,
|
|
)
|
|
.with_context_predicate(
|
|
id!("PaneGroup") & !id!("PaneGroup_PaneMaximized") & !id!("PaneGroup_PaneDragging"),
|
|
)
|
|
.with_mac_key_binding("cmd-ctrl-up"),
|
|
EditableBinding::new(
|
|
"pane_group:resize_down",
|
|
"Resize pane > Move divider down",
|
|
PaneGroupAction::ResizeDown,
|
|
)
|
|
.with_context_predicate(
|
|
id!("PaneGroup") & !id!("PaneGroup_PaneMaximized") & !id!("PaneGroup_PaneDragging"),
|
|
)
|
|
.with_mac_key_binding("cmd-ctrl-down"),
|
|
]);
|
|
|
|
app.register_editable_bindings([
|
|
EditableBinding::new(
|
|
"pane_group:add_down",
|
|
"Split pane down",
|
|
PaneGroupAction::Add(Direction::Down),
|
|
)
|
|
.with_context_predicate(id!("PaneGroup") & !id!("PaneGroup_PaneDragging"))
|
|
.with_custom_action(CustomAction::SplitPaneDown)
|
|
.with_enabled(|| ContextFlag::CreateNewSession.is_enabled()),
|
|
EditableBinding::new(
|
|
"pane_group:add_right",
|
|
"Split pane right",
|
|
PaneGroupAction::Add(Direction::Right),
|
|
)
|
|
.with_context_predicate(id!("PaneGroup") & !id!("PaneGroup_PaneDragging"))
|
|
.with_custom_action(CustomAction::SplitPaneRight)
|
|
.with_enabled(|| ContextFlag::CreateNewSession.is_enabled()),
|
|
EditableBinding::new(
|
|
"pane_group:toggle_maximize_pane",
|
|
"Toggle Maximize Active Pane",
|
|
PaneGroupAction::ToggleMaximizePane,
|
|
)
|
|
.with_context_predicate(id!("PaneGroup") & !id!("PaneGroup_PaneDragging"))
|
|
.with_custom_action(CustomAction::ToggleMaximizePane),
|
|
]);
|
|
|
|
if ChannelState::channel() == Channel::Integration {
|
|
// Hack: Add explicit bindings for the tests, since the tests' injected
|
|
// keypresses won't trigger Mac menu items. Unfortunately we can't use
|
|
// cfg[test] because we are a separate process!
|
|
app.register_fixed_bindings([FixedBinding::new(
|
|
cmd_or_ctrl_shift("w"),
|
|
PaneGroupAction::RemoveActive,
|
|
id!("PaneGroup"),
|
|
)]);
|
|
}
|
|
}
|
|
|
|
pub enum Event {
|
|
AppStateChanged,
|
|
Escape,
|
|
Exited {
|
|
add_to_undo_stack: bool,
|
|
},
|
|
LeftPanelToggled {
|
|
is_open: bool,
|
|
},
|
|
ExecuteCommand(ExecuteCommandEvent),
|
|
PaneTitleUpdated,
|
|
SendNotification {
|
|
notification: BlockNotification,
|
|
pane_id: PaneId,
|
|
},
|
|
OpenSettings(SettingsSection),
|
|
OpenAutoReloadModal {
|
|
purchased_credits: i32,
|
|
},
|
|
AskAIAssistant(AskAIType),
|
|
/// Pass input sync event up from underlying TerminalViews
|
|
/// to the Workspace to sync throughout the window.
|
|
SyncInput(SyncEvent),
|
|
/// Event needs to be propagated up to WorkspaceView where the show command search panel function lives.
|
|
ShowCommandSearch(CommandSearchOptions),
|
|
/// Event used to propagate a state change for one of the terminal views
|
|
/// inside this pane group.
|
|
TerminalViewStateChanged,
|
|
/// Event used to propagate guided onboarding tutorial completion to the workspace.
|
|
OnboardingTutorialCompleted,
|
|
// Tell the workspace to open the workflow modal.
|
|
OpenWorkflowModalWithCommand(String),
|
|
// Tell the workspace to open the workflow for edit.
|
|
OpenCloudWorkflowForEdit(SyncId),
|
|
// Tell the workspace to open the share dialog for the given drive object. The share dialog will
|
|
// open in the index. If the invitee email is provided, it will be added to the share dialog.
|
|
OpenDriveObjectShareDialog {
|
|
cloud_object_type_and_id: CloudObjectTypeAndId,
|
|
invitee_email: Option<String>,
|
|
source: SharingDialogSource,
|
|
},
|
|
// Tell the workspace to open the workflow modal with an unsaved workflow.
|
|
OpenWorkflowModalWithTemporary(Box<Workflow>),
|
|
OpenPromptEditor,
|
|
OpenAgentToolbarEditor,
|
|
OpenCLIAgentToolbarEditor,
|
|
/// tell the workspace to open a file within Warp.
|
|
OpenFileInWarp {
|
|
/// The file path to open.
|
|
path: PathBuf,
|
|
/// The session that the path was opened from.
|
|
session: Arc<Session>,
|
|
},
|
|
OpenGalaxyDriveLink {
|
|
open_warp_drive_args: OpenGalaxyDriveObjectArgs,
|
|
},
|
|
#[cfg(feature = "local_fs")]
|
|
OpenCodeInWarp {
|
|
source: CodeSource,
|
|
layout: crate::util::file::external_editor::settings::EditorLayout,
|
|
line_col: Option<LineAndColumnArg>,
|
|
},
|
|
#[cfg(feature = "local_fs")]
|
|
PreviewCodeInWarp {
|
|
source: CodeSource,
|
|
},
|
|
OpenCodeDiff {
|
|
view: ViewHandle<CodeDiffView>,
|
|
},
|
|
OpenCodeReviewPane(CodeReviewPanelArg),
|
|
ToggleCodeReviewPane(CodeReviewPanelArg),
|
|
/// Tell the workspace to run a workflow in the active tab's active session.
|
|
RunWorkflow {
|
|
workflow: Arc<WorkflowType>,
|
|
workflow_source: WorkflowSource,
|
|
workflow_selection_source: WorkflowSelectionSource,
|
|
argument_override: Option<HashMap<String, String>>,
|
|
},
|
|
/// Invoke env var from pane
|
|
InvokeEnvVarCollection {
|
|
env_var_collection: Arc<EnvVarCollectionType>,
|
|
in_subshell: bool,
|
|
},
|
|
CloseSharedSessionPaneRequested {
|
|
pane_id: PaneId,
|
|
},
|
|
/// Dirty the workspace so the tab indicator shows.
|
|
MaximizePaneToggled,
|
|
/// A remote server resolved the repo root for a session in this pane group.
|
|
RemoteRepoNavigated {
|
|
host_id: HostId,
|
|
indexed_path: String,
|
|
},
|
|
/// Refresh the workspace-level active session state.
|
|
ActiveSessionChanged,
|
|
FocusPaneGroup,
|
|
FocusPane {
|
|
pane_to_focus: PaneId,
|
|
},
|
|
FocusPaneInWorkspace {
|
|
locator: PaneViewLocator,
|
|
},
|
|
ViewInWarpDrive(WarpDriveItemId),
|
|
MoveToSpace {
|
|
cloud_object_type_and_id: CloudObjectTypeAndId,
|
|
space: Space,
|
|
},
|
|
PaneFocused,
|
|
DroppedOnTabBar {
|
|
origin: ActionOrigin,
|
|
pane_id: PaneId,
|
|
},
|
|
/// Switches the focus to the specified tab and moves the given
|
|
/// pane_id into the tab as a hidden pane. This will insert it into the pane
|
|
/// group, but it will not yet render it
|
|
SwitchTabFocusAndMovePane {
|
|
tab_idx: usize,
|
|
pane_id: PaneId,
|
|
/// The axis used for the destination tab's temporary hidden-pane
|
|
/// preview while a cross-tab pane drag is hovering that tab.
|
|
hidden_pane_preview_direction: Direction,
|
|
},
|
|
/// Updates the hovered tab index which will change what preview indicator is displayed
|
|
/// as a header is dragged
|
|
UpdateHoveredTabIndex {
|
|
tab_hover_index: TabBarHoverIndex,
|
|
},
|
|
/// Clears the hovered tab index so it no longer appears as highlighted drop target
|
|
ClearHoveredTabIndex,
|
|
OpenGalaxyDriveObjectInPane(ObjectUid),
|
|
OpenSuggestedAgentModeWorkflowModal {
|
|
workflow_and_id: SuggestedAgentModeWorkflowAndId,
|
|
},
|
|
OpenSuggestedRuleModal {
|
|
rule_and_id: SuggestedRuleAndId,
|
|
},
|
|
OpenAIFactCollection {
|
|
/// If set, open the fact collection to the specific rule.
|
|
sync_id: Option<SyncId>,
|
|
},
|
|
AnonymousUserSignup,
|
|
/// Request that the workspace open the command palette.
|
|
OpenPalette {
|
|
mode: PaletteMode,
|
|
source: PaletteSource,
|
|
query: Option<String>,
|
|
},
|
|
/// A terminal pane SSHed into a remote host has initiated a file upload
|
|
/// using a local session.
|
|
FileUploadCommand {
|
|
upload_id: FileUploadId,
|
|
command: String,
|
|
remote_pane_id: TerminalPaneId,
|
|
local_pane_id: TerminalPaneId,
|
|
},
|
|
/// A local terminal pane managing a file upload is requesting a password.
|
|
FileUploadPasswordPending {
|
|
local_pane_id: TerminalPaneId,
|
|
},
|
|
/// A local terminal pane managing a file upload has completed its task.
|
|
FileUploadFinished {
|
|
local_pane_id: TerminalPaneId,
|
|
exit_code: ExitCode,
|
|
},
|
|
OpenFileUploadSession {
|
|
remote_pane_id: TerminalPaneId,
|
|
upload_id: FileUploadId,
|
|
},
|
|
TerminateFileUploadSession {
|
|
remote_pane_id: TerminalPaneId,
|
|
upload_id: FileUploadId,
|
|
},
|
|
ShowToast {
|
|
message: String,
|
|
flavor: ToastFlavor,
|
|
pane_id: Option<PaneId>,
|
|
},
|
|
SignupAnonymousUser {
|
|
entrypoint: AnonymousUserSignupEntrypoint,
|
|
},
|
|
OpenThemeChooser,
|
|
InvalidatedActiveConversation,
|
|
OpenConversationHistory,
|
|
OpenMCPSettingsPage {
|
|
page: Option<MCPServersSettingsPage>,
|
|
},
|
|
OpenAddPromptPane {
|
|
/// The initial prompt body content.
|
|
initial_content: Option<String>,
|
|
},
|
|
OpenAddRulePane,
|
|
OpenEnvironmentManagementPane,
|
|
OpenFilesPalette {
|
|
source: PaletteSource,
|
|
},
|
|
ToggleLeftPanel {
|
|
target_view: LeftPanelTargetView,
|
|
force_open: bool,
|
|
},
|
|
#[cfg(feature = "local_fs")]
|
|
OpenFileWithTarget {
|
|
path: PathBuf,
|
|
target: FileTarget,
|
|
line_col: Option<LineAndColumnArg>,
|
|
},
|
|
/// File was renamed in the file tree
|
|
#[cfg(feature = "local_fs")]
|
|
FileRenamed {
|
|
old_path: PathBuf,
|
|
new_path: PathBuf,
|
|
},
|
|
/// File was deleted in the file tree
|
|
#[cfg(feature = "local_fs")]
|
|
FileDeleted {
|
|
path: PathBuf,
|
|
},
|
|
OpenAgentProfileEditor {
|
|
profile_id: ClientProfileId,
|
|
},
|
|
RepoChanged,
|
|
AttachPathAsContext {
|
|
path: PathBuf,
|
|
},
|
|
AttachPlanAsContext {
|
|
ai_document_id: AIDocumentId,
|
|
},
|
|
CDToDirectory {
|
|
path: PathBuf,
|
|
},
|
|
OpenDirectoryInNewTab {
|
|
path: PathBuf,
|
|
},
|
|
InsertCodeReviewComments {
|
|
repo_path: PathBuf,
|
|
comments: Vec<PendingImportedReviewComment>,
|
|
diff_mode: DiffMode,
|
|
open_code_review: Option<CodeReviewPanelArg>,
|
|
},
|
|
OpenCodeReviewPaneAndScrollToComment {
|
|
open_code_review: CodeReviewPanelArg,
|
|
comment: AttachedReviewComment,
|
|
diff_mode: DiffMode,
|
|
},
|
|
ImportAllCodeReviewComments {
|
|
open_code_review: CodeReviewPanelArg,
|
|
comments: Vec<AttachedReviewComment>,
|
|
diff_mode: DiffMode,
|
|
},
|
|
RunTabConfigSkill {
|
|
path: PathBuf,
|
|
},
|
|
/// Request to open LSP logs in a terminal pane
|
|
OpenLspLogs {
|
|
log_path: PathBuf,
|
|
},
|
|
ShowCloudAgentCapacityModal {
|
|
variant: crate::workspace::view::cloud_agent_capacity_modal::CloudAgentCapacityModalVariant,
|
|
},
|
|
FreeTierLimitCheckTriggered,
|
|
#[cfg(not(target_family = "wasm"))]
|
|
OpenPluginInstructionsPane(crate::terminal::CLIAgent, PluginModalKind),
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum TabBarHoverIndex {
|
|
BeforeTab(usize),
|
|
OverTab(usize),
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub struct DraggedBorder {
|
|
border_id: EntityId,
|
|
direction: SplitDirection,
|
|
previous_mouse_location: Vector2F,
|
|
}
|
|
|
|
/// Options that can be set when adding a new local terminal pane.
|
|
#[derive(Debug, Default, Clone)]
|
|
pub struct NewTerminalOptions {
|
|
/// The particular shell to spawn (if not the default).
|
|
pub shell: Option<AvailableShell>,
|
|
/// An initial working directory for the shell process.
|
|
pub initial_directory: Option<PathBuf>,
|
|
/// Additional environment variables to set in the terminal shell process.
|
|
pub env_vars: HashMap<OsString, OsString>,
|
|
/// If true, do not show the Code Mode homepage UX.
|
|
pub hide_homepage: bool,
|
|
/// Whether or not to start sharing the terminal session as soon as it's ready.
|
|
pub is_shared_session_creator: IsSharedSessionCreator,
|
|
/// The AI conversation to restore when the terminal is created.
|
|
pub conversation_restoration: Option<ConversationRestorationInNewPaneType>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum DefaultSessionModeBehavior {
|
|
Apply,
|
|
Ignore,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum NewPaneVisibility {
|
|
Visible,
|
|
HiddenForMove,
|
|
HiddenForChildAgent,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
struct AddPaneOptions {
|
|
direction: Direction,
|
|
base_pane_id: Option<PaneId>,
|
|
focus_new_pane: bool,
|
|
visibility: NewPaneVisibility,
|
|
emit_app_state_changed: bool,
|
|
}
|
|
|
|
impl NewTerminalOptions {
|
|
/// Return new options with the initial directory set to `path`.
|
|
pub fn with_initial_directory(mut self, path: impl Into<PathBuf>) -> Self {
|
|
self.initial_directory = Some(path.into());
|
|
self
|
|
}
|
|
|
|
/// Returns new options with the initial directory set to `path`. If `path` is None,
|
|
/// the initial directory is cleared.
|
|
pub fn with_initial_directory_opt(mut self, path: Option<PathBuf>) -> Self {
|
|
self.initial_directory = path;
|
|
self
|
|
}
|
|
|
|
/// Returns new options with the homepage hidden.
|
|
pub fn with_homepage_hidden(mut self) -> Self {
|
|
self.hide_homepage = true;
|
|
self
|
|
}
|
|
}
|
|
|
|
/// The possible layouts of a pane group.
|
|
#[derive(Debug)]
|
|
pub enum PanesLayout {
|
|
SingleTerminal(Box<NewTerminalOptions>),
|
|
Snapshot(Box<PaneNodeSnapshot>),
|
|
Template(PaneTemplateType),
|
|
AmbientAgent,
|
|
}
|
|
|
|
impl Default for PanesLayout {
|
|
fn default() -> Self {
|
|
Self::SingleTerminal(Box::default())
|
|
}
|
|
}
|
|
|
|
/// The potential locations where a pane can be dropped, either the tab bar, pane group, or elsewhere in the
|
|
/// app.
|
|
#[derive(Clone, Debug, Copy, Serialize, Deserialize)]
|
|
pub enum PaneDragDropLocation {
|
|
TabBar(TabBarLocation),
|
|
PaneGroup(PaneId),
|
|
Other,
|
|
}
|
|
|
|
pub struct PaneGroup {
|
|
tips_completed: ModelHandle<TipsCompleted>,
|
|
user_default_shell_unsupported_banner_model_handle: ModelHandle<BannerState>,
|
|
model_event_sender: Option<SyncSender<ModelEvent>>,
|
|
panes: PaneData,
|
|
/// Centralized focus state model. Panes subscribe to this to derive their split pane state.
|
|
focus_state: ModelHandle<focus_state::PaneGroupFocusState>,
|
|
pane_history: Vec<PaneId>,
|
|
/// Mapping from pane IDs to their contents.
|
|
pane_contents: HashMap<PaneId, Box<dyn AnyPaneContent>>,
|
|
|
|
server_api: Arc<ServerApi>,
|
|
|
|
/// The terminal session with an open share block modal. Only terminal panes use the share block modal.
|
|
terminal_with_open_share_block_modal: Option<TerminalPaneId>,
|
|
|
|
// We are only holding one instance of share modal view in the pane group and
|
|
// update it with the correct terminal model and size info when triggered by
|
|
// the context menu event.
|
|
share_block_modal: ViewHandle<ShareBlockModal>,
|
|
dragged_border: Option<DraggedBorder>,
|
|
user_default_shell_changed_banner: ViewHandle<Banner<PaneGroupAction>>,
|
|
|
|
/// If there is an open share session modal, the pane ID of its terminal. Only terminal panes
|
|
/// use the share session modal. `None` if no share session modal is open.
|
|
terminal_with_open_share_session_modal: Option<TerminalPaneId>,
|
|
share_session_modal: ViewHandle<ShareSessionModal>,
|
|
|
|
/// If there is a shared session role change modal open, this is the `TerminalPaneId` of the relevant session. Modal is opened whenever a shared session participant attempts to change a
|
|
/// role. For a viewer when they request a role. For a sharer when they receive a role request,
|
|
/// or when they attempt to grant a role.
|
|
terminal_with_shared_session_role_change_modal_open: Option<TerminalPaneId>,
|
|
/// Parent modal that holds views to role request/response and role grant modals.
|
|
shared_session_role_change_modal: ViewHandle<RoleChangeModal>,
|
|
/// Model that tracks the currently active file.
|
|
active_file_model: ModelHandle<ActiveFileModel>,
|
|
/// If there is an open summarization cancel dialog, the terminal pane ID where summarization is active.
|
|
terminal_with_open_summarization_dialog: Option<TerminalPaneId>,
|
|
|
|
/// Pane with an open environment setup mode selector modal (rendered at tab level).
|
|
pane_with_open_environment_setup_mode_selector: Option<PaneId>,
|
|
/// Pane with an open agent-assisted environment modal (rendered at tab level).
|
|
pane_with_open_agent_assisted_environment_modal: Option<PaneId>,
|
|
|
|
/// If the left panel is open for this pane group
|
|
pub left_panel_open: bool,
|
|
/// If the right panel is open for this pane group
|
|
pub right_panel_open: bool,
|
|
/// If the right panel is maximized
|
|
pub is_right_panel_maximized: bool,
|
|
|
|
/// Ambient agent panes whose task data was not yet cached at restoration time.
|
|
/// Entries are removed as each task's data arrives and the pane is replaced.
|
|
pending_ambient_agent_conversation_restorations: HashMap<AmbientAgentTaskId, PaneId>,
|
|
|
|
/// Maps child agent conversation IDs to their hidden pane IDs, so they can
|
|
/// be revealed from the parent's status card.
|
|
child_agent_panes: HashMap<AIConversationId, PaneId>,
|
|
|
|
/// Child agent conversations that the user has explicitly closed.
|
|
/// Prevents them from being re-created on app restart.
|
|
dismissed_child_agents: HashSet<AIConversationId>,
|
|
|
|
/// Tab-level custom title set via the rename-tab flow.
|
|
custom_title: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
|
|
pub enum PaneState {
|
|
/// This pane is currently focused.
|
|
Focused,
|
|
/// This pane is not focused.
|
|
Unfocused,
|
|
// In split pane with one pane maximized.
|
|
Maximized,
|
|
}
|
|
|
|
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
|
|
pub enum SplitPaneState {
|
|
InSplitPane(PaneState),
|
|
NotInSplitPane,
|
|
}
|
|
|
|
// Helper to group together certain structs necessary to instantiate a new terminal view.
|
|
#[derive(Clone)]
|
|
pub struct TerminalViewResources {
|
|
pub tips_completed: ModelHandle<TipsCompleted>,
|
|
pub server_api: Arc<ServerApi>,
|
|
pub model_event_sender: Option<SyncSender<ModelEvent>>,
|
|
}
|
|
|
|
impl SplitPaneState {
|
|
pub fn is_in_split_pane(&self) -> bool {
|
|
match self {
|
|
SplitPaneState::InSplitPane(_) => true,
|
|
SplitPaneState::NotInSplitPane => false,
|
|
}
|
|
}
|
|
|
|
/// Is the focused pane.
|
|
pub fn is_focused(&self) -> bool {
|
|
match self {
|
|
SplitPaneState::InSplitPane(state) => match state {
|
|
PaneState::Focused | PaneState::Maximized => true,
|
|
PaneState::Unfocused => false,
|
|
},
|
|
SplitPaneState::NotInSplitPane => true,
|
|
}
|
|
}
|
|
|
|
/// Is in split pane and is the focused pane.
|
|
pub fn is_focused_pane(&self) -> bool {
|
|
match self {
|
|
SplitPaneState::InSplitPane(state) => match state {
|
|
PaneState::Focused | PaneState::Maximized => true,
|
|
PaneState::Unfocused => false,
|
|
},
|
|
SplitPaneState::NotInSplitPane => false,
|
|
}
|
|
}
|
|
|
|
pub fn is_maximized(&self) -> bool {
|
|
matches!(self, SplitPaneState::InSplitPane(PaneState::Maximized))
|
|
}
|
|
}
|
|
|
|
/// Helper for reconstructing focus state when restoring a pane tree.
|
|
/// Focus/active state is stored per-leaf, and must be bubbled up the tree.
|
|
#[derive(Default)]
|
|
struct InitialFocus {
|
|
focused_pane: Option<PaneId>,
|
|
active_session: Option<TerminalPaneId>,
|
|
}
|
|
|
|
impl InitialFocus {
|
|
fn merge(&mut self, other: InitialFocus) {
|
|
if self.focused_pane.is_some() {
|
|
if other.focused_pane.is_some() {
|
|
log::error!("Restored pane tree has more than one focused pane");
|
|
}
|
|
} else {
|
|
self.focused_pane = other.focused_pane;
|
|
}
|
|
|
|
if self.active_session.is_some() {
|
|
if other.active_session.is_some() {
|
|
log::error!("Restored pane tree has more than one active session");
|
|
}
|
|
} else {
|
|
self.active_session = other.active_session;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Helper for retrieving leftmost pane id when restoring a pane tree.
|
|
/// Pane ID is stored per-leaf, and must be bubbled up the tree.
|
|
struct LeftmostPaneId {
|
|
pane_id: PaneId,
|
|
session_id: TerminalPaneId,
|
|
}
|
|
|
|
/// The [`InitialLayoutCallback`] provides state to pane group constructors
|
|
/// to build the initial layout of the pane group. Specifically, it provides
|
|
/// - resources ([`TerminalViewResources`]) to help construct terminal views,
|
|
/// - a mutable mapping from [`PaneId`] to [`AnyPaneContent`],
|
|
/// - a mutable list of [`PaneId`]s representing the pane history,
|
|
/// - the view bounds, and
|
|
/// - the mutable view context of the [`PaneGroup`].
|
|
/// It expects a return type of [`(PaneData, InitialFocus)`].
|
|
type InitialLayoutCallback = Box<
|
|
dyn FnOnce(
|
|
TerminalViewResources,
|
|
&mut HashMap<PaneId, Box<dyn AnyPaneContent>>,
|
|
&mut Vec<PaneId>,
|
|
RectF,
|
|
&mut ViewContext<PaneGroup>,
|
|
) -> (PaneData, InitialFocus),
|
|
>;
|
|
|
|
/// The restoration path for an ambient agent pane.
|
|
enum AmbientRestoreKind {
|
|
/// Active shared session
|
|
SharedSession { session_id: SessionId },
|
|
/// Conversation data isn't loaded yet — show a loading pane and
|
|
/// defer the real restoration to the pending-restoration subscription
|
|
/// (which waits for the data to be loaded async).
|
|
PendingRestoration { task_id: AmbientAgentTaskId },
|
|
/// If there's no task ID to restore, we open a fresh cloud mode pane
|
|
/// (this is a valid state from when a user quits with an empty cloud mode pane).
|
|
NewCloudConversation,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum AIDocumentPaneVisibilityAction {
|
|
/// Ensure the requested AI document pane is visible.
|
|
///
|
|
/// If the requested pane is already open, this will keep it open.
|
|
Open,
|
|
/// Toggle visibility of the requested AI document pane.
|
|
///
|
|
/// If the requested pane is open, this will close it. Otherwise it will open it.
|
|
Toggle,
|
|
}
|
|
|
|
impl PaneGroup {
|
|
/// Executes the provided callback for each TerminalView contained within
|
|
/// this pane group.
|
|
pub fn for_all_terminal_panes(
|
|
&mut self,
|
|
mut callback: impl FnMut(&mut TerminalView, &mut ViewContext<TerminalView>),
|
|
ctx: &mut ViewContext<Self>,
|
|
) {
|
|
for pane_id in self.pane_contents.keys() {
|
|
if let Some(terminal_view) = self.terminal_view_from_pane_id(*pane_id, ctx) {
|
|
terminal_view.update(ctx, &mut callback);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Executes the provided callback for each CodeView contained within
|
|
/// this pane group.
|
|
pub fn for_all_code_panes(
|
|
&mut self,
|
|
mut callback: impl FnMut(&mut CodeView, &mut ViewContext<CodeView>),
|
|
ctx: &mut ViewContext<Self>,
|
|
) {
|
|
for pane_id in self.pane_contents.keys() {
|
|
if let Some(code_view) = self.code_view_from_pane_id(*pane_id, ctx) {
|
|
code_view.update(ctx, &mut callback);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn terminal_pane_ids(&self) -> impl Iterator<Item = PaneId> + '_ {
|
|
self.pane_contents.keys().filter_map(|pane_id| {
|
|
if pane_id.is_terminal_pane() {
|
|
Some(*pane_id)
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
}
|
|
|
|
/// Returns true if this pane group contains any terminal panes.
|
|
pub fn has_terminal_panes(&self) -> bool {
|
|
self.pane_contents
|
|
.keys()
|
|
.any(|pane_id| pane_id.is_terminal_pane())
|
|
}
|
|
|
|
/// Returns true if this pane group contains any code panes.
|
|
pub fn has_code_panes(&self) -> bool {
|
|
self.pane_contents
|
|
.keys()
|
|
.any(|pane_id| pane_id.is_code_pane())
|
|
}
|
|
|
|
pub fn active_file_model(&self) -> &ModelHandle<ActiveFileModel> {
|
|
&self.active_file_model
|
|
}
|
|
|
|
/// Returns true iff one of the terminal panes in this group is being shared.
|
|
pub fn is_terminal_pane_being_shared(&self, ctx: &AppContext) -> bool {
|
|
self.number_of_shared_sessions(ctx) > 0
|
|
}
|
|
|
|
pub fn smart_split_direction(
|
|
&self,
|
|
ctx: &mut ViewContext<Self>,
|
|
split_ratio: f32,
|
|
) -> Direction {
|
|
let size = self.size(ctx);
|
|
// The new width if split horizontally.
|
|
let new_width = size.x() / (self.num_splits_at_root(SplitDirection::Horizontal) + 1) as f32;
|
|
let new_height = size.y();
|
|
|
|
if new_width / new_height > split_ratio {
|
|
Direction::Left
|
|
} else {
|
|
Direction::Up
|
|
}
|
|
}
|
|
|
|
/// Total size of the pane group.
|
|
pub fn size(&self, ctx: &mut ViewContext<Self>) -> Vector2F {
|
|
self.panes.root.pane_size(ctx)
|
|
}
|
|
|
|
/// Number of splits at the root node in the given axis.
|
|
pub fn num_splits_at_root(&self, axis: SplitDirection) -> usize {
|
|
self.panes.root.num_splits_in_direction(axis)
|
|
}
|
|
|
|
/// Send a Sync Input event to the TerminalView with EntityId pane_id.
|
|
pub fn send_sync_event_to_session(
|
|
&self,
|
|
terminal_pane_id: TerminalPaneId,
|
|
sync_event: &SyncEvent,
|
|
ctx: &mut ViewContext<Self>,
|
|
) {
|
|
if let Some(pane_view) = self.terminal_view_from_pane_id(terminal_pane_id, ctx) {
|
|
pane_view.update(ctx, |terminal_view, ctx| {
|
|
terminal_view.receive_sync_input_event(sync_event, ctx);
|
|
});
|
|
}
|
|
}
|
|
|
|
fn handle_pane_view_event(
|
|
&mut self,
|
|
pane_id: PaneId,
|
|
event: &PaneViewEvent,
|
|
ctx: &mut ViewContext<PaneGroup>,
|
|
) {
|
|
if self.pane_contents.contains_key(&pane_id) {
|
|
match event {
|
|
PaneViewEvent::MovePaneWithinPaneGroup {
|
|
target_id,
|
|
direction,
|
|
} => {
|
|
ctx.emit(Event::ClearHoveredTabIndex);
|
|
self.move_pane(pane_id, *target_id, *direction, ctx);
|
|
}
|
|
PaneViewEvent::DroppedOnTabBar { origin } => {
|
|
ctx.emit(Event::DroppedOnTabBar {
|
|
origin: *origin,
|
|
pane_id,
|
|
});
|
|
ctx.emit(Event::ClearHoveredTabIndex);
|
|
}
|
|
PaneViewEvent::DraggedOntoTabBar {
|
|
origin,
|
|
tab_hover_index,
|
|
hidden_pane_preview_direction,
|
|
} => {
|
|
if matches!(origin, ActionOrigin::Pane) {
|
|
// Clear hidden closed panes since dragging invalidates undo functionality
|
|
self.clear_hidden_closed_panes(ctx);
|
|
|
|
match tab_hover_index {
|
|
TabBarHoverIndex::BeforeTab(_) => {
|
|
self.hide_pane_for_move(pane_id, ctx);
|
|
}
|
|
TabBarHoverIndex::OverTab(tab_idx) => {
|
|
self.panes.clear_hidden_panes_from_move();
|
|
ctx.emit(Event::SwitchTabFocusAndMovePane {
|
|
tab_idx: *tab_idx,
|
|
pane_id,
|
|
hidden_pane_preview_direction: *hidden_pane_preview_direction,
|
|
})
|
|
}
|
|
};
|
|
}
|
|
|
|
ctx.emit(Event::UpdateHoveredTabIndex {
|
|
tab_hover_index: *tab_hover_index,
|
|
})
|
|
}
|
|
|
|
PaneViewEvent::PaneDraggedOutsideTabBarOrPaneGroup => {
|
|
// If we drag outside of the tab bar or pane group, ensure that there
|
|
// is no hidden pane
|
|
self.panes.clear_hidden_panes_from_move();
|
|
// Also clear hidden closed panes since dragging invalidates undo functionality
|
|
self.clear_hidden_closed_panes(ctx);
|
|
ctx.emit(Event::ClearHoveredTabIndex);
|
|
ctx.notify();
|
|
ctx.emit(Event::TerminalViewStateChanged);
|
|
ctx.emit(Event::AppStateChanged);
|
|
}
|
|
PaneViewEvent::PaneDragEnded => {
|
|
self.focus_pane_by_id(pane_id, ctx);
|
|
ctx.emit(Event::TerminalViewStateChanged);
|
|
ctx.notify();
|
|
}
|
|
PaneViewEvent::PaneHeaderClicked => {
|
|
self.focus_pane_by_id(pane_id, ctx);
|
|
ctx.emit(Event::TerminalViewStateChanged);
|
|
ctx.notify();
|
|
}
|
|
}
|
|
} else {
|
|
log::warn!("Session {pane_id:?} not found");
|
|
}
|
|
}
|
|
|
|
/// Send a Sync Input event to every pane in this pane group.
|
|
pub fn send_sync_event_to_panes(&self, sync_event: &SyncEvent, ctx: &mut ViewContext<Self>) {
|
|
for terminal_pane_id in self
|
|
.panes_of::<TerminalPane>()
|
|
.map(|p| p.terminal_pane_id())
|
|
{
|
|
self.send_sync_event_to_session(terminal_pane_id, sync_event, ctx);
|
|
}
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn pane_tree_from_template(
|
|
root: PaneTemplateType,
|
|
resources: TerminalViewResources,
|
|
ctx: &mut ViewContext<PaneGroup>,
|
|
pane_contents: &mut HashMap<PaneId, Box<dyn AnyPaneContent>>,
|
|
is_left_pane: bool,
|
|
user_default_shell_unsupported_banner_model_handle: ModelHandle<BannerState>,
|
|
view_size: Vector2F,
|
|
model_event_sender: Option<SyncSender<ModelEvent>>,
|
|
) -> (PaneData, InitialFocus) {
|
|
let (leftmost_pane_id, pane_data, initial_focus) =
|
|
PaneGroup::pane_tree_from_template_recursive(
|
|
root,
|
|
resources,
|
|
ctx,
|
|
pane_contents,
|
|
is_left_pane,
|
|
user_default_shell_unsupported_banner_model_handle,
|
|
view_size,
|
|
model_event_sender,
|
|
);
|
|
if initial_focus.focused_pane.is_some() && initial_focus.active_session.is_some() {
|
|
(pane_data, initial_focus)
|
|
} else {
|
|
let initial_focus = leftmost_pane_id
|
|
.as_ref()
|
|
.map(|val| InitialFocus {
|
|
focused_pane: Some(val.pane_id),
|
|
active_session: Some(val.session_id),
|
|
})
|
|
.unwrap_or_default();
|
|
(pane_data, initial_focus)
|
|
}
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn pane_tree_from_template_recursive(
|
|
root: PaneTemplateType,
|
|
resources: TerminalViewResources,
|
|
ctx: &mut ViewContext<PaneGroup>,
|
|
pane_contents: &mut HashMap<PaneId, Box<dyn AnyPaneContent>>,
|
|
is_left_pane: bool,
|
|
user_default_shell_unsupported_banner_model_handle: ModelHandle<BannerState>,
|
|
view_size: Vector2F,
|
|
model_event_sender: Option<SyncSender<ModelEvent>>,
|
|
) -> (Option<LeftmostPaneId>, PaneData, InitialFocus) {
|
|
match root {
|
|
PaneTemplateType::PaneTemplate {
|
|
cwd,
|
|
commands,
|
|
is_focused,
|
|
pane_mode,
|
|
shell,
|
|
} => {
|
|
let uuid = Uuid::new_v4();
|
|
|
|
#[cfg(feature = "local_tty")]
|
|
let chosen_shell: Option<AvailableShell> = shell
|
|
.as_deref()
|
|
.and_then(|name| resolve_tab_config_shell(name, ctx));
|
|
#[cfg(not(feature = "local_tty"))]
|
|
let chosen_shell: Option<AvailableShell> = {
|
|
let _ = shell;
|
|
None
|
|
};
|
|
|
|
let (view, terminal_manager) = match pane_mode {
|
|
PaneMode::Cloud => {
|
|
Self::create_ambient_agent_terminal(resources, view_size, ctx)
|
|
}
|
|
PaneMode::Terminal | PaneMode::Agent => PaneGroup::create_session(
|
|
// Use cwd from the template iff such path exists, otherwise None
|
|
// TODO(CORE-3187): On Windows, support WSL directory restoration.
|
|
Some(cwd).filter(|p| p.exists()),
|
|
HashMap::new(),
|
|
IsSharedSessionCreator::No,
|
|
resources,
|
|
None,
|
|
None, // no conversation restoration for launch config
|
|
user_default_shell_unsupported_banner_model_handle,
|
|
view_size,
|
|
model_event_sender.clone(),
|
|
chosen_shell,
|
|
None,
|
|
ctx,
|
|
),
|
|
};
|
|
|
|
// Runs saved commands on start (terminal and agent modes only).
|
|
if !commands.is_empty() && !matches!(pane_mode, PaneMode::Cloud) {
|
|
let exec = commands.iter().map(|cmd| &cmd.exec).join(" && ");
|
|
view.update(ctx, |terminal, ctx| {
|
|
terminal.set_pending_command(exec.as_str(), ctx);
|
|
});
|
|
}
|
|
|
|
// Agent mode: enter the agent view. When setup commands are
|
|
// pending (e.g. worktree creation), defer entry until they
|
|
// complete so they run in terminal mode.
|
|
if matches!(pane_mode, PaneMode::Agent) {
|
|
if commands.is_empty() {
|
|
view.update(ctx, |terminal_view, ctx| {
|
|
terminal_view.enter_agent_view_for_new_conversation(
|
|
None,
|
|
AgentViewEntryOrigin::Input {
|
|
was_prompt_autodetected: false,
|
|
},
|
|
ctx,
|
|
);
|
|
});
|
|
} else {
|
|
view.update(ctx, |terminal_view, _| {
|
|
terminal_view.set_enter_agent_view_after_pending_commands();
|
|
});
|
|
}
|
|
}
|
|
|
|
let pane_data = TerminalPane::new(
|
|
uuid.as_bytes().to_vec(),
|
|
terminal_manager,
|
|
view,
|
|
model_event_sender,
|
|
ctx,
|
|
);
|
|
|
|
let terminal_pane_id = pane_data.terminal_pane_id();
|
|
let pane_id = terminal_pane_id.into();
|
|
pane_contents.insert(pane_id, Box::new(pane_data));
|
|
|
|
let is_focused = is_focused.unwrap_or_default();
|
|
let focus = InitialFocus {
|
|
focused_pane: is_focused.then_some(pane_id),
|
|
active_session: is_focused.then_some(terminal_pane_id),
|
|
};
|
|
|
|
let leftmost_pane_id = is_left_pane.then_some(LeftmostPaneId {
|
|
pane_id,
|
|
session_id: terminal_pane_id,
|
|
});
|
|
(leftmost_pane_id, PaneData::new(pane_id), focus)
|
|
}
|
|
PaneTemplateType::PaneBranchTemplate {
|
|
split_direction,
|
|
panes,
|
|
} => {
|
|
let mut len = 0;
|
|
let mut nodes = Vec::new();
|
|
let mut focus = InitialFocus::default();
|
|
let mut leftmost_pane_id = None;
|
|
let pane_flex = 1. / panes.len() as f32;
|
|
|
|
let num_children = panes.len() as f32;
|
|
let total_divider_size = tree::get_divider_thickness() * (num_children - 1.);
|
|
let view_size = match split_direction {
|
|
launch_config::SplitDirection::Vertical => vec2f(
|
|
view_size.x(),
|
|
(view_size.y() - total_divider_size) / num_children,
|
|
),
|
|
launch_config::SplitDirection::Horizontal => vec2f(
|
|
(view_size.x() - total_divider_size) / num_children,
|
|
view_size.y(),
|
|
),
|
|
};
|
|
|
|
for (idx, node) in panes.iter().enumerate() {
|
|
let (child_leftmost_pane_id, child, child_focus) =
|
|
PaneGroup::pane_tree_from_template_recursive(
|
|
node.clone(),
|
|
resources.clone(),
|
|
ctx,
|
|
pane_contents,
|
|
// Focus and activate the leftmost pane of the entire tree.
|
|
is_left_pane && idx == 0,
|
|
user_default_shell_unsupported_banner_model_handle.clone(),
|
|
view_size,
|
|
model_event_sender.clone(),
|
|
);
|
|
len += child.len();
|
|
nodes.push((PaneFlex(pane_flex), child.root));
|
|
|
|
focus.merge(child_focus);
|
|
leftmost_pane_id = leftmost_pane_id.or(child_leftmost_pane_id);
|
|
}
|
|
(
|
|
leftmost_pane_id,
|
|
PaneData::new_branch(split_direction.into(), nodes, len),
|
|
focus,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Restores the pane tree with the given snapshot. This returns the restored
|
|
/// pane tree structure as well as the focus state.
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn restore_pane_tree(
|
|
root: PaneNodeSnapshot,
|
|
block_lists: Arc<HashMap<PaneUuid, Vec<SerializedBlockListItem>>>,
|
|
resources: TerminalViewResources,
|
|
ctx: &mut ViewContext<PaneGroup>,
|
|
pane_contents: &mut HashMap<PaneId, Box<dyn AnyPaneContent>>,
|
|
user_default_shell_unsupported_banner_model_handle: ModelHandle<BannerState>,
|
|
view_size: Vector2F,
|
|
model_event_sender: Option<SyncSender<ModelEvent>>,
|
|
deferred_panes: &mut Vec<(PaneId, LeafSnapshot)>,
|
|
pending_ambient_restorations: &mut Vec<(AmbientAgentTaskId, PaneId)>,
|
|
) -> anyhow::Result<(PaneData, InitialFocus)> {
|
|
match root {
|
|
PaneNodeSnapshot::Leaf(leaf) => Self::restore_pane_leaf(
|
|
leaf,
|
|
block_lists,
|
|
resources,
|
|
ctx,
|
|
pane_contents,
|
|
user_default_shell_unsupported_banner_model_handle,
|
|
view_size,
|
|
model_event_sender,
|
|
deferred_panes,
|
|
pending_ambient_restorations,
|
|
),
|
|
PaneNodeSnapshot::Branch(pane) => {
|
|
let mut len = 0;
|
|
let mut nodes = Vec::new();
|
|
let mut focus = InitialFocus::default();
|
|
|
|
let num_children = pane.children.len() as f32;
|
|
let total_divider_size = tree::get_divider_thickness() * (num_children - 1.);
|
|
let view_size = match pane.direction {
|
|
app_state::SplitDirection::Vertical => vec2f(
|
|
view_size.x(),
|
|
(view_size.y() - total_divider_size) / num_children,
|
|
),
|
|
app_state::SplitDirection::Horizontal => vec2f(
|
|
(view_size.x() - total_divider_size) / num_children,
|
|
view_size.y(),
|
|
),
|
|
};
|
|
|
|
for (flex, node) in pane.children {
|
|
match PaneGroup::restore_pane_tree(
|
|
node,
|
|
block_lists.clone(),
|
|
resources.clone(),
|
|
ctx,
|
|
pane_contents,
|
|
user_default_shell_unsupported_banner_model_handle.clone(),
|
|
view_size,
|
|
model_event_sender.clone(),
|
|
deferred_panes,
|
|
pending_ambient_restorations,
|
|
) {
|
|
Ok((child, child_focus)) => {
|
|
len += child.len();
|
|
nodes.push((flex.into(), child.root));
|
|
|
|
focus.merge(child_focus);
|
|
}
|
|
Err(err) => {
|
|
log::warn!("Unable to restore child pane: {err:#}");
|
|
}
|
|
}
|
|
}
|
|
|
|
if nodes.is_empty() {
|
|
anyhow::bail!("All child panes were invalid");
|
|
}
|
|
|
|
let axis = pane.direction;
|
|
Ok((PaneData::new_branch(axis.into(), nodes, len), focus))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Restores a single leaf pane from a snapshot.
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn restore_pane_leaf(
|
|
leaf: LeafSnapshot,
|
|
block_lists: Arc<HashMap<PaneUuid, Vec<SerializedBlockListItem>>>,
|
|
resources: TerminalViewResources,
|
|
ctx: &mut ViewContext<PaneGroup>,
|
|
pane_contents: &mut HashMap<PaneId, Box<dyn AnyPaneContent>>,
|
|
user_default_shell_unsupported_banner_model_handle: ModelHandle<BannerState>,
|
|
view_size: Vector2F,
|
|
model_event_sender: Option<SyncSender<ModelEvent>>,
|
|
#[cfg_attr(not(feature = "local_fs"), allow(unused_variables, clippy::ptr_arg))]
|
|
deferred_panes: &mut Vec<(PaneId, LeafSnapshot)>,
|
|
pending_ambient_restorations: &mut Vec<(AmbientAgentTaskId, PaneId)>,
|
|
) -> anyhow::Result<(PaneData, InitialFocus)> {
|
|
let custom_vertical_tabs_title = leaf.custom_vertical_tabs_title.clone();
|
|
let result = match leaf.contents {
|
|
LeafContents::AIDocument(_) => {
|
|
// Defer AI document pane restoration until after terminal panes are restored.
|
|
// We do this because the terminal view seeds the AIDocumentModel as part of
|
|
// conversation restoration, and the AIDocumentView requires the data to already
|
|
// exist in the AIDocumentModel. In practice, this will work most of the time
|
|
// because the AIDocumentView is usually in the same tab as the terminal view containing
|
|
// the conversation data.
|
|
// TODO (roland): this is not ideal. If the AIDocumentView is moved to an earlier tab
|
|
// than the terminal view with the data, the data won't exist when the AIDocumentView is restored. Right now
|
|
// the AIDocumentView handles this case and renders with an empty buffer until the data is restored.
|
|
// But if the AIDocumentView is leftover after the terminal view containing the conversation
|
|
// is closed, the data would never be loaded because the conversation is never restored.
|
|
let pane_id = PaneId::deferred_placeholder_pane_id();
|
|
let is_focused = leaf.is_focused;
|
|
deferred_panes.push((pane_id, leaf));
|
|
let focus = InitialFocus {
|
|
focused_pane: is_focused.then_some(pane_id),
|
|
active_session: None,
|
|
};
|
|
Ok((PaneData::new(pane_id), focus))
|
|
}
|
|
LeafContents::Terminal(terminal_snapshot) => {
|
|
let uuid = PaneUuid(terminal_snapshot.uuid.clone());
|
|
let block_list = block_lists.get(&uuid);
|
|
|
|
let chosen_shell = terminal_snapshot
|
|
.shell_launch_data
|
|
.as_ref()
|
|
.and_then(|shell| {
|
|
if FeatureFlag::ShellSelector.is_enabled() {
|
|
AvailableShells::as_ref(ctx).get_from_shell_launch_data(shell)
|
|
} else {
|
|
None
|
|
}
|
|
});
|
|
|
|
let raw_cwd = terminal_snapshot.cwd.clone();
|
|
let startup_directory = terminal_snapshot
|
|
.cwd
|
|
.map(PathBuf::from)
|
|
.filter(|path| path.is_dir());
|
|
|
|
log::info!(
|
|
"[session-restore] pane=terminal raw_cwd={raw_cwd:?} \
|
|
resolved_startup_dir={startup_directory:?} \
|
|
conversations_to_restore={} \
|
|
active_conversation_id={:?} \
|
|
has_shell_launch_data={} \
|
|
has_block_list={} is_active={}",
|
|
terminal_snapshot.conversation_ids_to_restore.len(),
|
|
terminal_snapshot.active_conversation_id,
|
|
terminal_snapshot.shell_launch_data.is_some(),
|
|
block_list.is_some(),
|
|
terminal_snapshot.is_active,
|
|
);
|
|
|
|
// Filter conversation IDs to only include those that have task messages
|
|
// and are not entirely passive (ignored suggestions).
|
|
// This prevents showing the "Previous session" banner when there's nothing to restore
|
|
// and avoids restoring passive code diffs that the user never acted on.
|
|
let filtered_conversation_ids: Vec<AIConversationId> = terminal_snapshot
|
|
.conversation_ids_to_restore
|
|
.iter()
|
|
.filter(|&conversation_id| {
|
|
RestoredAgentConversations::handle(ctx).read(ctx, |store, _| {
|
|
store
|
|
.get_conversation(conversation_id)
|
|
.is_some_and(|persisted_conv| {
|
|
// Filter conversations that contain no tasks.
|
|
if persisted_conv.all_tasks().next().is_none() {
|
|
return false;
|
|
}
|
|
|
|
// Filter conversations that are entirely passive.
|
|
!persisted_conv.is_entirely_passive()
|
|
})
|
|
})
|
|
})
|
|
.copied()
|
|
.collect();
|
|
|
|
let conversation_restoration = vec1::Vec1::try_from_vec(filtered_conversation_ids)
|
|
.ok()
|
|
.map(
|
|
|conversation_ids| ConversationRestorationInNewPaneType::Startup {
|
|
conversation_ids,
|
|
active_conversation_id: terminal_snapshot.active_conversation_id,
|
|
},
|
|
);
|
|
let (terminal_view, terminal_manager) = PaneGroup::create_session(
|
|
startup_directory,
|
|
HashMap::new(),
|
|
IsSharedSessionCreator::No,
|
|
resources,
|
|
block_list,
|
|
conversation_restoration,
|
|
user_default_shell_unsupported_banner_model_handle,
|
|
view_size,
|
|
model_event_sender.clone(),
|
|
chosen_shell,
|
|
terminal_snapshot.input_config,
|
|
ctx,
|
|
);
|
|
|
|
let terminal_view_id = terminal_view.id();
|
|
|
|
let pane_data = TerminalPane::new(
|
|
uuid.0,
|
|
terminal_manager,
|
|
terminal_view,
|
|
model_event_sender,
|
|
ctx,
|
|
);
|
|
|
|
let terminal_pane_id = pane_data.terminal_pane_id();
|
|
let pane_id = terminal_pane_id.into();
|
|
pane_contents.insert(pane_id, Box::new(pane_data));
|
|
|
|
if let Some(llm_override) = &terminal_snapshot.llm_model_override {
|
|
if let Ok(llm_id) = serde_json::from_str::<LLMId>(llm_override) {
|
|
log::info!("Selecting base agent model {llm_id} (from terminal snapshot)");
|
|
crate::ai::llms::LLMPreferences::handle(ctx).update(
|
|
ctx,
|
|
|llm_prefs, ctx| {
|
|
llm_prefs.update_preferred_agent_mode_llm(
|
|
&llm_id,
|
|
terminal_view_id,
|
|
ctx,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
if let Some(active_profile_sync_id) = &terminal_snapshot.active_profile_id {
|
|
log::info!(
|
|
"Attempting to restore active_profile '{active_profile_sync_id}' for terminal {terminal_view_id:?}"
|
|
);
|
|
|
|
let profiles_model = AIExecutionProfilesModel::as_ref(ctx);
|
|
|
|
if let Some(profile_id) =
|
|
profiles_model.get_profile_id_by_sync_id(active_profile_sync_id)
|
|
{
|
|
AIExecutionProfilesModel::handle(ctx).update(ctx, |profiles_model, ctx| {
|
|
profiles_model.set_active_profile(terminal_view_id, profile_id, ctx);
|
|
});
|
|
log::info!(
|
|
"Restored active profile {profile_id:?} for terminal {terminal_view_id:?}"
|
|
);
|
|
} else {
|
|
log::warn!(
|
|
"Failed to restore active profile for terminal {terminal_view_id:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
let focus = InitialFocus {
|
|
focused_pane: leaf.is_focused.then_some(pane_id),
|
|
active_session: terminal_snapshot.is_active.then_some(terminal_pane_id),
|
|
};
|
|
|
|
Ok((PaneData::new(pane_id), focus))
|
|
}
|
|
LeafContents::Notebook(snapshot) => {
|
|
let pane: Box<dyn AnyPaneContent + 'static> = match snapshot {
|
|
NotebookPaneSnapshot::CloudNotebook {
|
|
notebook_id,
|
|
settings,
|
|
} => Box::new(NotebookPane::restore(notebook_id, &settings, ctx)?),
|
|
NotebookPaneSnapshot::LocalFileNotebook { path } => Box::new(FilePane::new(
|
|
path,
|
|
None,
|
|
#[cfg(feature = "local_fs")]
|
|
None,
|
|
ctx,
|
|
)),
|
|
};
|
|
|
|
let pane_id = pane.as_pane().id();
|
|
pane_contents.insert(pane_id, pane);
|
|
let focus = InitialFocus {
|
|
focused_pane: leaf.is_focused.then_some(pane_id),
|
|
active_session: None,
|
|
};
|
|
|
|
Ok((PaneData::new(pane_id), focus))
|
|
}
|
|
#[cfg(feature = "local_fs")]
|
|
LeafContents::Code(snapshot) => {
|
|
let CodePaneSnapShot::Local {
|
|
tabs,
|
|
active_tab_index,
|
|
source,
|
|
} = snapshot;
|
|
|
|
let Some(source) = source.filter(|s: &CodeSource| s.is_restorable()) else {
|
|
return Err(anyhow::anyhow!(
|
|
"Skipping code pane with non-restorable source"
|
|
));
|
|
};
|
|
|
|
let code_view = ctx.add_typed_action_view(move |ctx| {
|
|
CodeView::restore(&tabs, active_tab_index, source, ctx)
|
|
});
|
|
let pane = CodePane::from_view(code_view, ctx);
|
|
let pane_id = pane.id();
|
|
pane_contents.insert(pane_id, Box::new(pane));
|
|
let focus = InitialFocus {
|
|
focused_pane: leaf.is_focused.then_some(pane_id),
|
|
active_session: None,
|
|
};
|
|
Ok((PaneData::new(pane_id), focus))
|
|
}
|
|
#[cfg(not(feature = "local_fs"))]
|
|
LeafContents::Code(_) => Err(anyhow::anyhow!(
|
|
"Code pane restoration not supported on this platform"
|
|
)),
|
|
LeafContents::EnvVarCollection(snapshot) => {
|
|
let pane: Box<dyn AnyPaneContent + 'static> = match snapshot {
|
|
EnvVarCollectionPaneSnapshot::CloudEnvVarCollection {
|
|
env_var_collection_id,
|
|
} => Box::new(EnvVarCollectionPane::restore(env_var_collection_id, ctx)?),
|
|
};
|
|
|
|
let pane_id = pane.as_pane().id();
|
|
pane_contents.insert(pane_id, pane);
|
|
let focus = InitialFocus {
|
|
focused_pane: leaf.is_focused.then_some(pane_id),
|
|
active_session: None,
|
|
};
|
|
|
|
Ok((PaneData::new(pane_id), focus))
|
|
}
|
|
LeafContents::Workflow(snapshot) => {
|
|
let pane: Box<dyn AnyPaneContent + 'static> = match snapshot {
|
|
WorkflowPaneSnapshot::CloudWorkflow {
|
|
workflow_id,
|
|
settings,
|
|
} => Box::new(WorkflowPane::restore(workflow_id, settings, ctx)?),
|
|
};
|
|
|
|
let pane_id = pane.as_pane().id();
|
|
pane_contents.insert(pane_id, pane);
|
|
let focus = InitialFocus {
|
|
focused_pane: leaf.is_focused.then_some(pane_id),
|
|
active_session: None,
|
|
};
|
|
|
|
Ok((PaneData::new(pane_id), focus))
|
|
}
|
|
LeafContents::Settings(snapshot) => {
|
|
let pane: Box<dyn AnyPaneContent + 'static> = match snapshot {
|
|
SettingsPaneSnapshot::Local {
|
|
current_page,
|
|
search_query,
|
|
} => Box::new(SettingsPane::new(
|
|
current_page,
|
|
search_query.as_deref(),
|
|
ctx.window_id(),
|
|
ctx,
|
|
)),
|
|
};
|
|
|
|
let pane_id = pane.as_pane().id();
|
|
pane_contents.insert(pane_id, pane);
|
|
let focus = InitialFocus {
|
|
focused_pane: leaf.is_focused.then_some(pane_id),
|
|
active_session: None,
|
|
};
|
|
Ok((PaneData::new(pane_id), focus))
|
|
}
|
|
LeafContents::AIFact(snapshot) => {
|
|
if !FeatureFlag::AIRules.is_enabled() {
|
|
return Err(anyhow::anyhow!("AI fact pane not enabled"));
|
|
}
|
|
let pane: Box<dyn AnyPaneContent + 'static> = match snapshot {
|
|
AIFactPaneSnapshot::Personal => Box::new(AIFactPane::new(ctx)),
|
|
};
|
|
let pane_id = pane.as_pane().id();
|
|
pane_contents.insert(pane_id, pane);
|
|
let focus = InitialFocus {
|
|
focused_pane: leaf.is_focused.then_some(pane_id),
|
|
active_session: None,
|
|
};
|
|
Ok((PaneData::new(pane_id), focus))
|
|
}
|
|
LeafContents::AmbientAgent(snapshot) => {
|
|
let task_data = snapshot.task_id.map(|task_id| {
|
|
let task = AgentConversationsModel::handle(ctx).update(ctx, |model, ctx| {
|
|
model.get_or_async_fetch_task_data(&task_id, ctx)
|
|
});
|
|
(task_id, task)
|
|
});
|
|
|
|
let restore_kind = match &task_data {
|
|
Some((_, Some(task))) => {
|
|
let item = ConversationOrTask::Task(task);
|
|
match item.get_open_action(None, ctx) {
|
|
Some(WorkspaceAction::OpenAmbientAgentSession {
|
|
session_id, ..
|
|
}) => AmbientRestoreKind::SharedSession { session_id },
|
|
// Transcript viewer and other non-session actions depend on conversation metadata from
|
|
// BlocklistAIHistoryModel, which is loaded asynchronously.
|
|
// Defer to the pending-restoration handler so it can retry once that metadata arrives.
|
|
_ => task_data
|
|
.as_ref()
|
|
.map(|(tid, _)| AmbientRestoreKind::PendingRestoration {
|
|
task_id: *tid,
|
|
})
|
|
.unwrap_or(AmbientRestoreKind::NewCloudConversation),
|
|
}
|
|
}
|
|
Some((task_id, None)) => {
|
|
AmbientRestoreKind::PendingRestoration { task_id: *task_id }
|
|
}
|
|
None => AmbientRestoreKind::NewCloudConversation,
|
|
};
|
|
|
|
let mut pending_task: Option<AmbientAgentTaskId> = None;
|
|
let (terminal_view, terminal_manager) = match restore_kind {
|
|
AmbientRestoreKind::SharedSession { session_id } => {
|
|
Self::create_shared_session_viewer(session_id, resources, view_size, ctx)
|
|
}
|
|
AmbientRestoreKind::PendingRestoration { task_id } => {
|
|
let (view, manager) = Self::create_loading_terminal_manager_and_view(
|
|
resources,
|
|
view_size,
|
|
ctx.window_id(),
|
|
ctx,
|
|
);
|
|
pending_task = Some(task_id);
|
|
(view, manager)
|
|
}
|
|
AmbientRestoreKind::NewCloudConversation => {
|
|
Self::create_ambient_agent_terminal(resources, view_size, ctx)
|
|
}
|
|
};
|
|
|
|
let pane_data = TerminalPane::new(
|
|
snapshot.uuid,
|
|
terminal_manager,
|
|
terminal_view,
|
|
model_event_sender,
|
|
ctx,
|
|
);
|
|
let terminal_pane_id = pane_data.terminal_pane_id();
|
|
let pane_id = terminal_pane_id.into();
|
|
pane_contents.insert(pane_id, Box::new(pane_data));
|
|
|
|
if let Some(task_id) = pending_task {
|
|
// Defer restoration to after the task data is loaded.
|
|
pending_ambient_restorations.push((task_id, pane_id));
|
|
}
|
|
|
|
let focus = InitialFocus {
|
|
focused_pane: leaf.is_focused.then_some(pane_id),
|
|
active_session: None,
|
|
};
|
|
Ok((PaneData::new(pane_id), focus))
|
|
}
|
|
LeafContents::CodeReview(_) => {
|
|
Err(anyhow::anyhow!("Code review panes are no longer supported"))
|
|
}
|
|
LeafContents::ExecutionProfileEditor => {
|
|
// We don't yet support restoring execution profile editor panes.
|
|
Err(anyhow::anyhow!(
|
|
"Can't restore execution profile editor panes"
|
|
))
|
|
}
|
|
LeafContents::NetworkLog => {
|
|
// Network log panes are intentionally not restored. Two
|
|
// reasons:
|
|
//
|
|
// 1. The in-memory log starts empty on each launch, so a
|
|
// restored pane would display a blank editor anyway.
|
|
// 2. More importantly, persisting the pane's contents would
|
|
// effectively regress back to a persisted, on-disk
|
|
// network log (via the SQLite app-state database) on app
|
|
// shutdown, defeating the purpose of moving the log off
|
|
// disk in the first place.
|
|
//
|
|
// `save_pane_state` in `persistence/sqlite.rs` skips network
|
|
// log panes entirely, so reaching this arm indicates a
|
|
// programmer error on the persistence side. Users reopen the
|
|
// pane on demand via Privacy settings or the keybinding.
|
|
Err(anyhow::anyhow!(
|
|
"Network log pane should not have been persisted, as it cannot be restored"
|
|
))
|
|
}
|
|
LeafContents::GetStarted => {
|
|
if !FeatureFlag::GetStartedTab.is_enabled() {
|
|
Err(anyhow::anyhow!("GetStarted pane not supported"))
|
|
} else {
|
|
let pane: Box<dyn AnyPaneContent + 'static> =
|
|
Box::new(GetStartedPane::new(ctx));
|
|
let pane_id = pane.as_pane().id();
|
|
pane_contents.insert(pane_id, pane);
|
|
let focus = InitialFocus {
|
|
focused_pane: leaf.is_focused.then_some(pane_id),
|
|
active_session: None,
|
|
};
|
|
Ok((PaneData::new(pane_id), focus))
|
|
}
|
|
}
|
|
LeafContents::Welcome { startup_directory } => {
|
|
if !FeatureFlag::WelcomeTab.is_enabled() {
|
|
Err(anyhow::anyhow!("Welcome pane not supported"))
|
|
} else {
|
|
let pane: Box<dyn AnyPaneContent + 'static> =
|
|
Box::new(WelcomePane::new(startup_directory, ctx));
|
|
let pane_id = pane.as_pane().id();
|
|
pane_contents.insert(pane_id, pane);
|
|
let focus = InitialFocus {
|
|
focused_pane: leaf.is_focused.then_some(pane_id),
|
|
active_session: None,
|
|
};
|
|
Ok((PaneData::new(pane_id), focus))
|
|
}
|
|
}
|
|
LeafContents::EnvironmentManagement(_) => {
|
|
// Environment management panes are not restored from persistence.
|
|
// They are opened on-demand via workspace actions.
|
|
Err(anyhow::anyhow!(
|
|
"Environment management panes are not restored"
|
|
))
|
|
}
|
|
};
|
|
|
|
if let (Ok((pane_data, _)), Some(title)) = (&result, custom_vertical_tabs_title.as_deref())
|
|
{
|
|
if let PaneNode::Leaf(pane_id) = &pane_data.root {
|
|
if let Some(pane) = pane_contents.get(pane_id) {
|
|
pane.as_pane()
|
|
.pane_configuration()
|
|
.update(ctx, |configuration, ctx| {
|
|
configuration.set_custom_vertical_tabs_title(title, ctx);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
result
|
|
}
|
|
|
|
#[cfg_attr(not(feature = "local_fs"), allow(unused_variables, unused_mut))]
|
|
fn process_deferred_panes(
|
|
deferred_panes: Vec<(PaneId, LeafSnapshot)>,
|
|
mut result: (PaneData, InitialFocus),
|
|
pane_contents: &mut HashMap<PaneId, Box<dyn AnyPaneContent>>,
|
|
ctx: &mut ViewContext<Self>,
|
|
) -> (PaneData, InitialFocus) {
|
|
for (placeholder_id, leaf) in deferred_panes {
|
|
let custom_vertical_tabs_title = leaf.custom_vertical_tabs_title.clone();
|
|
match leaf.contents {
|
|
LeafContents::AIDocument(aidocument_snapshot) => {
|
|
match aidocument_snapshot {
|
|
crate::app_state::AIDocumentPaneSnapshot::Local {
|
|
document_id,
|
|
version,
|
|
content,
|
|
title,
|
|
} => {
|
|
// Parse the document_id from string to AIDocumentId
|
|
let doc_id = match AIDocumentId::try_from(document_id.as_str()) {
|
|
Ok(id) => id,
|
|
Err(err) => {
|
|
log::warn!("Failed to parse AI document ID: {err:#}");
|
|
continue;
|
|
}
|
|
};
|
|
|
|
// Apply persisted SQLite content on top of conversation-restored
|
|
// content. This handles user edits that weren't part of the
|
|
// conversation, and the cross-tab edge case where conversation
|
|
// restoration hasn't run yet.
|
|
if let Some(persisted_content) = &content {
|
|
AIDocumentModel::handle(ctx).update(ctx, |model, ctx| {
|
|
model.apply_persisted_content(
|
|
doc_id,
|
|
persisted_content,
|
|
title.as_deref(),
|
|
ctx,
|
|
);
|
|
});
|
|
}
|
|
|
|
let doc_version = AIDocumentVersion(version as usize);
|
|
|
|
let document_view = ctx.add_typed_action_view(|view_ctx| {
|
|
AIDocumentView::new(doc_id, doc_version, view_ctx)
|
|
});
|
|
|
|
// Create the AIDocumentPane
|
|
let pane: Box<dyn AnyPaneContent + 'static> =
|
|
Box::new(AIDocumentPane::new(document_view.clone(), ctx));
|
|
|
|
let real_id = pane.as_pane().id();
|
|
result.0.replace_pane(placeholder_id, real_id, false);
|
|
if result.1.focused_pane == Some(placeholder_id) {
|
|
result.1.focused_pane = Some(real_id);
|
|
}
|
|
if let Some(title) = custom_vertical_tabs_title.as_deref() {
|
|
pane.as_pane().pane_configuration().update(
|
|
ctx,
|
|
|configuration, ctx| {
|
|
configuration.set_custom_vertical_tabs_title(title, ctx);
|
|
},
|
|
);
|
|
}
|
|
pane_contents.insert(real_id, pane);
|
|
}
|
|
}
|
|
}
|
|
_ => {
|
|
// Ignore other pane types in deferred processing
|
|
}
|
|
}
|
|
}
|
|
|
|
result
|
|
}
|
|
|
|
pub fn snapshot_for_node(&self, app: &AppContext, node: &PaneNode) -> PaneNodeSnapshot {
|
|
match node {
|
|
PaneNode::Branch(branch) => {
|
|
let children: Vec<_> = branch
|
|
.nodes
|
|
.iter()
|
|
.filter_map(|(flex, node)| {
|
|
if let PaneNode::Leaf(pane_id) = node {
|
|
if self.panes.is_hidden_closed_pane(pane_id) {
|
|
// Don't snapshot hidden panes (undo, move, job,
|
|
// child agent, etc.). Child agent panes are
|
|
// recreated from the history model on startup.
|
|
return None;
|
|
}
|
|
}
|
|
Some((
|
|
app_state::PaneFlex(flex.0),
|
|
self.snapshot_for_node(app, node),
|
|
))
|
|
})
|
|
.collect();
|
|
|
|
PaneNodeSnapshot::Branch(BranchSnapshot {
|
|
direction: branch.axis().into(),
|
|
children,
|
|
})
|
|
}
|
|
PaneNode::Leaf(pane_id) => {
|
|
let contents = match self.pane_contents.get(pane_id) {
|
|
Some(pane) => pane.as_pane().snapshot(app),
|
|
None => {
|
|
// Create a new pane uuid if we have a bug where we didn't save it
|
|
// properly. This approach will allow us to keep the uniqueness constraints
|
|
// intact so we don't fail to save the snapshot.
|
|
log::error!("Failed to get session data for pane, so used a new uuid");
|
|
LeafContents::Terminal(TerminalPaneSnapshot {
|
|
uuid: Uuid::new_v4().as_bytes().to_vec(),
|
|
cwd: None,
|
|
is_active: pane_id.as_terminal_pane_id() == self.active_session_id(app),
|
|
is_read_only: false,
|
|
shell_launch_data: None,
|
|
input_config: Some(InputConfig::new(app)),
|
|
llm_model_override: None,
|
|
active_profile_id: None,
|
|
conversation_ids_to_restore: Vec::new(),
|
|
active_conversation_id: None,
|
|
})
|
|
}
|
|
};
|
|
let custom_vertical_tabs_title = self.pane_contents.get(pane_id).and_then(|pane| {
|
|
pane.as_pane()
|
|
.pane_configuration()
|
|
.as_ref(app)
|
|
.custom_vertical_tabs_title()
|
|
.map(str::to_owned)
|
|
});
|
|
PaneNodeSnapshot::Leaf(LeafSnapshot {
|
|
is_focused: *pane_id == self.focused_pane_id(app),
|
|
custom_vertical_tabs_title,
|
|
contents,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Find the PaneId for a given TerminalView EntityId if it exists within this PaneGroup.
|
|
pub fn find_pane_id_for_terminal_view(
|
|
&self,
|
|
terminal_view_id: EntityId,
|
|
ctx: &AppContext,
|
|
) -> Option<PaneId> {
|
|
for pane_id in self.pane_contents.keys() {
|
|
if let Some(terminal_pane) = self.downcast_pane_by_id::<TerminalPane>(*pane_id) {
|
|
if terminal_pane.terminal_view(ctx).id() == terminal_view_id {
|
|
return Some(*pane_id);
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
pub fn focused_pane_id(&self, ctx: &AppContext) -> PaneId {
|
|
self.focus_state
|
|
.read(ctx, |state, _| state.focused_pane_id())
|
|
}
|
|
|
|
pub fn active_session_id(&self, ctx: &AppContext) -> Option<TerminalPaneId> {
|
|
self.focus_state
|
|
.read(ctx, |state, _| state.active_session_id())
|
|
}
|
|
|
|
pub fn focus_state_handle(&self) -> ModelHandle<PaneGroupFocusState> {
|
|
self.focus_state.clone()
|
|
}
|
|
|
|
pub fn snapshot(&self, app: &AppContext) -> PaneNodeSnapshot {
|
|
self.snapshot_for_node(app, &self.panes.root)
|
|
}
|
|
|
|
fn panes_of<T: Any>(&self) -> impl Iterator<Item = &'_ T> {
|
|
self.pane_contents
|
|
.values()
|
|
.filter_map(|contents| contents.as_any().downcast_ref::<T>())
|
|
}
|
|
|
|
/// Checks if any TerminalView within this pane group matches the given ID.
|
|
pub fn contains_terminal_view(&self, terminal_view_id: EntityId, ctx: &AppContext) -> bool {
|
|
self.panes_of::<TerminalPane>()
|
|
.any(|pane| pane.terminal_view(ctx).id() == terminal_view_id)
|
|
}
|
|
/// Iterate over the code editors in this pane group.
|
|
pub fn code_panes<'a>(
|
|
&'a self,
|
|
app: &'a AppContext,
|
|
) -> impl Iterator<Item = (PaneId, ViewHandle<CodeView>)> + 'a {
|
|
self.panes_of::<CodePane>()
|
|
.map(move |pane| (pane.id(), pane.file_view(app)))
|
|
}
|
|
|
|
pub fn ai_document_panes(&self) -> impl Iterator<Item = PaneId> + '_ {
|
|
self.panes_of::<AIDocumentPane>().map(|pane| pane.id())
|
|
}
|
|
|
|
fn visible_ai_document_panes(&self, ctx: &AppContext) -> Vec<(PaneId, AIDocumentId)> {
|
|
self.panes_of::<AIDocumentPane>()
|
|
.filter(|pane| !self.is_pane_hidden_for_close(pane.id()))
|
|
.map(|pane| {
|
|
let document_view = pane.document_view(ctx);
|
|
(pane.id(), *document_view.as_ref(ctx).document_id())
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn close_panes(&mut self, pane_ids: Vec<PaneId>, ctx: &mut ViewContext<Self>) {
|
|
for pane_id in pane_ids {
|
|
self.close_pane(pane_id, ctx);
|
|
}
|
|
}
|
|
|
|
/// Checks if this pane group contains a visible AI document pane with the given document ID.
|
|
pub fn contains_ai_document(&self, document_id: &AIDocumentId, ctx: &AppContext) -> bool {
|
|
self.panes_of::<AIDocumentPane>()
|
|
.filter(|pane| !self.is_pane_hidden_for_close(pane.id()))
|
|
.any(|pane| *pane.document_view(ctx).as_ref(ctx).document_id() == *document_id)
|
|
}
|
|
|
|
/// Closes all visible AI document panes that are *not* for `document_id`, then applies the
|
|
/// requested `action` to the pane for `document_id`.
|
|
///
|
|
/// This enforces the UI invariant that only one AI document pane should be visible at a time.
|
|
fn set_ai_document_pane_visibility(
|
|
&mut self,
|
|
conversation_id: AIConversationId,
|
|
document_id: AIDocumentId,
|
|
document_version: AIDocumentVersion,
|
|
action: AIDocumentPaneVisibilityAction,
|
|
ctx: &mut ViewContext<Self>,
|
|
) {
|
|
// Snapshot currently-visible AI document panes so we can make decisions without
|
|
// mutating the pane tree mid-iteration.
|
|
let visible_panes = self.visible_ai_document_panes(ctx);
|
|
|
|
// Is the requested document already visible?
|
|
let is_target_visible = visible_panes
|
|
.iter()
|
|
.any(|(_, visible_document_id)| *visible_document_id == document_id);
|
|
|
|
// Decide which panes to close and whether we should open the target pane.
|
|
let (pane_ids_to_close, should_open_target) = if is_target_visible {
|
|
match action {
|
|
// Keep the requested document open; close any other AI document panes.
|
|
AIDocumentPaneVisibilityAction::Open => (
|
|
visible_panes
|
|
.into_iter()
|
|
.filter(|(_, visible_document_id)| *visible_document_id != document_id)
|
|
.map(|(pane_id, _)| pane_id)
|
|
.collect(),
|
|
false,
|
|
),
|
|
// Toggle semantics: if the requested document is already visible, close it (and
|
|
// close any other AI document panes as well).
|
|
AIDocumentPaneVisibilityAction::Toggle => (
|
|
visible_panes
|
|
.into_iter()
|
|
.map(|(pane_id, _)| pane_id)
|
|
.collect(),
|
|
false,
|
|
),
|
|
}
|
|
} else {
|
|
// The requested document isn't visible. Regardless of action, close any open AI document
|
|
// panes first (replacement semantics), then open the requested document.
|
|
(
|
|
visible_panes
|
|
.into_iter()
|
|
.map(|(pane_id, _)| pane_id)
|
|
.collect(),
|
|
true,
|
|
)
|
|
};
|
|
|
|
self.close_panes(pane_ids_to_close, ctx);
|
|
|
|
if !should_open_target {
|
|
return;
|
|
}
|
|
|
|
// Find terminal view via document -> conversation -> terminal view.
|
|
let terminal_view = BlocklistAIHistoryModel::as_ref(ctx)
|
|
.terminal_view_id_for_conversation(&conversation_id)
|
|
.and_then(|terminal_view_id| {
|
|
// Find the pane containing this terminal view.
|
|
self.pane_contents.keys().find_map(|pane_id| {
|
|
self.terminal_view_from_pane_id(*pane_id, ctx)
|
|
.filter(|tv| tv.id() == terminal_view_id)
|
|
})
|
|
});
|
|
|
|
// Unmaximize the current pane first so the new document pane is visible.
|
|
if self.is_focused_pane_maximized(ctx) {
|
|
self.toggle_maximize_pane(ctx);
|
|
}
|
|
|
|
// Construct and show the document pane.
|
|
let document_view = ctx
|
|
.add_typed_action_view(|ctx| AIDocumentView::new(document_id, document_version, ctx));
|
|
|
|
document_view.update(ctx, |view, _| {
|
|
view.set_original_terminal_view(terminal_view.clone());
|
|
});
|
|
let pane = AIDocumentPane::new(document_view, ctx);
|
|
|
|
self.add_pane_with_direction(Direction::Right, pane, false, ctx);
|
|
}
|
|
|
|
/// Closes any other ai document panes, and opens the specified document_id.
|
|
pub fn open_ai_document_pane(
|
|
&mut self,
|
|
conversation_id: AIConversationId,
|
|
document_id: AIDocumentId,
|
|
document_version: AIDocumentVersion,
|
|
ctx: &mut ViewContext<Self>,
|
|
) {
|
|
self.set_ai_document_pane_visibility(
|
|
conversation_id,
|
|
document_id,
|
|
document_version,
|
|
AIDocumentPaneVisibilityAction::Open,
|
|
ctx,
|
|
);
|
|
}
|
|
|
|
pub fn close_all_ai_document_panes(&mut self, ctx: &mut ViewContext<Self>) {
|
|
let pane_ids: Vec<_> = self
|
|
.visible_ai_document_panes(ctx)
|
|
.into_iter()
|
|
.map(|(pane_id, _)| pane_id)
|
|
.collect();
|
|
self.close_panes(pane_ids, ctx);
|
|
}
|
|
|
|
pub fn toggle_ai_document_pane(
|
|
&mut self,
|
|
conversation_id: AIConversationId,
|
|
document_id: AIDocumentId,
|
|
document_version: AIDocumentVersion,
|
|
ctx: &mut ViewContext<Self>,
|
|
) {
|
|
self.set_ai_document_pane_visibility(
|
|
conversation_id,
|
|
document_id,
|
|
document_version,
|
|
AIDocumentPaneVisibilityAction::Toggle,
|
|
ctx,
|
|
);
|
|
}
|
|
|
|
pub fn has_active_code_pane_with_unsaved_changes(&self, ctx: &AppContext) -> bool {
|
|
self.focused_pane_id(ctx).is_code_pane()
|
|
&& self
|
|
.pane_contents
|
|
.get(&self.focused_pane_id(ctx))
|
|
.and_then(|content| content.as_any().downcast_ref::<CodePane>())
|
|
.map(|pane| {
|
|
pane.file_view(ctx)
|
|
.as_ref(ctx)
|
|
.active_tab_has_unsaved_changes(ctx)
|
|
})
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
/// Returns the selected text from the focused pane, or `None` if there is no selection or the selection is empty.
|
|
pub fn selected_text_from_focused_pane(&self, ctx: &AppContext) -> Option<String> {
|
|
let focused_pane_id = self.focused_pane_id(ctx);
|
|
|
|
#[cfg(feature = "local_fs")]
|
|
{
|
|
// If the focused pane is a code pane, return the selected text from the code view.
|
|
if focused_pane_id.is_code_pane() {
|
|
let text = self
|
|
.downcast_pane_by_id::<CodePane>(focused_pane_id)
|
|
.and_then(|pane| pane.file_view(ctx).as_ref(ctx).selected_text(ctx));
|
|
// If the text is not empty and does not contain a newline, return early.
|
|
if text.as_ref().is_some_and(|t| !t.is_empty()) {
|
|
return text;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Finds the active pane type outof (NotebookPane, AIDocumentPane, TerminalPane)
|
|
// and extracts selected text from it.
|
|
let text = if let Some(pane) = self.downcast_pane_by_id::<NotebookPane>(focused_pane_id) {
|
|
pane.notebook_view(ctx).as_ref(ctx).selected_text(ctx)
|
|
} else if let Some(pane) = self.downcast_pane_by_id::<AIDocumentPane>(focused_pane_id) {
|
|
pane.document_view(ctx).as_ref(ctx).selected_text(ctx)
|
|
} else if let Some(terminal_view) = self.terminal_view_from_pane_id(focused_pane_id, ctx) {
|
|
// NOTE: We currently don't have a way to track recency of selection events.
|
|
// In lieu of this, we prefer selections to the input editor over the terminal view.
|
|
// TODO(vkodithala): Once we have a way to track recency of selection events, we should use that instead.
|
|
terminal_view
|
|
.as_ref(ctx)
|
|
.selected_text_from_input(ctx)
|
|
.or_else(|| terminal_view.as_ref(ctx).selected_text(ctx))
|
|
} else {
|
|
None
|
|
};
|
|
|
|
text.filter(|text: &String| !text.is_empty())
|
|
}
|
|
|
|
/// Iterate over the terminal sessions in this pane group.
|
|
pub fn pane_sessions<'a>(
|
|
&'a self,
|
|
pane_group_id: EntityId,
|
|
window_id: WindowId,
|
|
app: &'a AppContext,
|
|
) -> impl Iterator<Item = SessionNavigationData> + 'a {
|
|
self.panes_of::<TerminalPane>()
|
|
.map(move |pane| pane.session_navigation_data(pane_group_id, window_id, app))
|
|
}
|
|
|
|
/// Send prompt change bindkey events to all terminal sessions in this pane group. This
|
|
/// is used for intra-session prompt switching between Warp prompt and PS1.
|
|
#[cfg_attr(not(feature = "local_tty"), allow(unused_variables))]
|
|
pub fn send_prompt_change_bindkey_to_all_sessions(
|
|
&self,
|
|
honor_ps1: bool,
|
|
app: &mut AppContext,
|
|
) {
|
|
self.panes_of::<TerminalPane>()
|
|
.for_each(move |session_data| {
|
|
#[cfg(feature = "local_tty")]
|
|
{
|
|
session_data
|
|
.terminal_manager(app)
|
|
.update(app, |terminal_manager, ctx| {
|
|
if let Some(manager) = terminal_manager
|
|
.as_any()
|
|
.downcast_ref::<local_tty::TerminalManager>()
|
|
{
|
|
if honor_ps1 {
|
|
manager.send_switch_to_ps1_bindkey(ctx);
|
|
} else {
|
|
manager.send_switch_to_warp_prompt_bindkey(ctx);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
// TODO: Potentially handle remote_tty and mock TerminalManager cases here as well?
|
|
});
|
|
}
|
|
|
|
/// Returns the most recent state across this pane group's terminal views.
|
|
pub fn most_recent_pane_state(&self, ctx: &AppContext) -> TerminalViewState {
|
|
let (_, most_recent_state) = self
|
|
.pane_contents
|
|
.iter()
|
|
.filter_map(|(pane_id, pane_content)| {
|
|
// Skip panes that are hidden for undo close
|
|
if self.panes.is_hidden_closed_pane(pane_id) {
|
|
return None;
|
|
}
|
|
|
|
// Only consider terminal panes
|
|
pane_content.as_any().downcast_ref::<TerminalPane>()
|
|
})
|
|
.filter_map(|session_data| {
|
|
let state_change = session_data.terminal_view(ctx).as_ref(ctx).current_state();
|
|
(!matches!(state_change.state, TerminalViewState::Normal)).then_some(state_change)
|
|
})
|
|
.fold(
|
|
(None, TerminalViewState::Normal),
|
|
|(timestamp, current_state), state_change| {
|
|
if timestamp < Some(state_change.timestamp) {
|
|
(Some(state_change.timestamp), state_change.state)
|
|
} else {
|
|
(timestamp, current_state)
|
|
}
|
|
},
|
|
);
|
|
|
|
most_recent_state
|
|
}
|
|
|
|
fn open_share_session_modal(
|
|
&mut self,
|
|
terminal_pane_id: TerminalPaneId,
|
|
open_source: SharedSessionActionSource,
|
|
ctx: &mut ViewContext<Self>,
|
|
) {
|
|
let Some(terminal_view) = self.terminal_view_from_pane_id(terminal_pane_id, ctx) else {
|
|
log::warn!("Tried to open share session modal for non-existent terminal pane");
|
|
return;
|
|
};
|
|
|
|
if AuthStateProvider::as_ref(ctx)
|
|
.get()
|
|
.is_anonymous_or_logged_out()
|
|
{
|
|
AuthManager::handle(ctx).update(ctx, |auth_manager, ctx| {
|
|
auth_manager.attempt_login_gated_feature(
|
|
"Share Session",
|
|
AuthViewVariant::ShareRequirementCloseable,
|
|
ctx,
|
|
)
|
|
});
|
|
return;
|
|
}
|
|
|
|
self.share_session_modal.update(ctx, |modal, ctx| {
|
|
modal.open(
|
|
terminal_pane_id,
|
|
open_source,
|
|
terminal_view.as_ref(ctx).model.clone(),
|
|
terminal_view.id(),
|
|
ctx,
|
|
);
|
|
});
|
|
self.terminal_with_open_share_session_modal = Some(terminal_pane_id);
|
|
ctx.focus(&self.share_session_modal);
|
|
ctx.notify();
|
|
}
|
|
|
|
fn open_share_session_denied_modal(
|
|
&mut self,
|
|
terminal_pane_id: TerminalPaneId,
|
|
ctx: &mut ViewContext<Self>,
|
|
) {
|
|
self.share_session_modal.update(ctx, |modal, ctx| {
|
|
modal.open_denied(terminal_pane_id, ctx);
|
|
});
|
|
self.terminal_with_open_share_session_modal = Some(terminal_pane_id);
|
|
ctx.focus(&self.share_session_modal);
|
|
ctx.notify();
|
|
}
|
|
|
|
/// Closes the share session modal if it is open. Does nothing otherwise. Does not change
|
|
/// which element is focused.
|
|
fn close_share_session_modal(&mut self, ctx: &mut ViewContext<Self>) {
|
|
let Some(terminal_pane_id) = self.terminal_with_open_share_session_modal.take() else {
|
|
return;
|
|
};
|
|
|
|
if let Some(terminal_view) = self.terminal_view_from_pane_id(terminal_pane_id, ctx) {
|
|
terminal_view.update(ctx, |view, ctx| {
|
|
view.set_show_pane_accent_border(false, ctx)
|
|
});
|
|
}
|
|
ctx.notify();
|
|
}
|
|
|
|
fn handle_share_session_modal_event(
|
|
&mut self,
|
|
event: &ShareSessionModalEvent,
|
|
ctx: &mut ViewContext<Self>,
|
|
) {
|
|
match event {
|
|
ShareSessionModalEvent::Close => {
|
|
let Some(terminal_pane_id) = self.terminal_with_open_share_session_modal.take()
|
|
else {
|
|
return;
|
|
};
|
|
|
|
if let Some(pane) = self.focused_pane_content(ctx) {
|
|
pane.focus(ctx);
|
|
}
|
|
|
|
if let Some(terminal_view) = self.terminal_view_from_pane_id(terminal_pane_id, ctx)
|
|
{
|
|
terminal_view.update(ctx, |view, ctx| {
|
|
view.set_show_pane_accent_border(false, ctx)
|
|
});
|
|
}
|
|
ctx.notify();
|
|
}
|
|
ShareSessionModalEvent::StartSharing {
|
|
terminal_pane_id,
|
|
scrollback_type,
|
|
source,
|
|
} => {
|
|
self.terminal_with_open_share_session_modal = None;
|
|
ctx.notify();
|
|
|
|
let Some(terminal_view) = self.terminal_view_from_pane_id(*terminal_pane_id, ctx)
|
|
else {
|
|
return;
|
|
};
|
|
|
|
terminal_view.update(ctx, |view, ctx| {
|
|
view.attempt_to_share_session(
|
|
*scrollback_type,
|
|
Some(*source),
|
|
SessionSourceType::default(),
|
|
false,
|
|
ctx,
|
|
);
|
|
});
|
|
}
|
|
ShareSessionModalEvent::Upgrade => {
|
|
self.terminal_with_open_share_session_modal = None;
|
|
if let Some(pane) = self.focused_pane_content(ctx) {
|
|
pane.focus(ctx);
|
|
}
|
|
ctx.emit(Event::OpenSettings(SettingsSection::Appearance));
|
|
ctx.notify();
|
|
|
|
send_telemetry_from_ctx!(TelemetryEvent::SharedSessionModalUpgradePressed, ctx);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn open_shared_session_viewer_request_modal(
|
|
&mut self,
|
|
terminal_pane_id: TerminalPaneId,
|
|
role: Role,
|
|
ctx: &mut ViewContext<Self>,
|
|
) {
|
|
let Some(terminal_view) = self.terminal_view_from_pane_id(terminal_pane_id, ctx) else {
|
|
log::warn!("Tried to open role request modal for non-existent terminal pane");
|
|
return;
|
|
};
|
|
|
|
let Some(presence_manager) =
|
|
terminal_view.read(ctx, |view, _| view.shared_session_presence_manager())
|
|
else {
|
|
log::warn!("Tried to open role request modal for non-existent presence manager");
|
|
return;
|
|
};
|
|
|
|
let Some(sharer) = presence_manager.as_ref(ctx).get_sharer() else {
|
|
log::warn!("Tried to open role request modal with non-existent sharer");
|
|
return;
|
|
};
|
|
|
|
let display_name = sharer.info.profile_data.display_name.clone();
|
|
self.shared_session_role_change_modal
|
|
.update(ctx, |modal, ctx| {
|
|
modal.open_for_viewer_request(terminal_pane_id, display_name, role, ctx);
|
|
});
|
|
|
|
self.terminal_with_shared_session_role_change_modal_open = Some(terminal_pane_id);
|
|
ctx.focus(&self.shared_session_role_change_modal);
|
|
ctx.notify();
|
|
}
|
|
|
|
/// If modal is already open, we update it with the new role request
|
|
fn open_shared_session_sharer_response_modal(
|
|
&mut self,
|
|
terminal_pane_id: TerminalPaneId,
|
|
viewer_id: ParticipantId,
|
|
role_request_id: RoleRequestId,
|
|
role: Role,
|
|
ctx: &mut ViewContext<Self>,
|
|
) {
|
|
let Some(terminal_view) = self.terminal_view_from_pane_id(terminal_pane_id, ctx) else {
|
|
log::warn!("Tried to open role request modal for non-existent terminal pane");
|
|
return;
|
|
};
|
|
|
|
let Some(presence_manager) =
|
|
terminal_view.read(ctx, |view, _| view.shared_session_presence_manager())
|
|
else {
|
|
log::warn!("Tried to open role request modal for non-existent presence manager");
|
|
return;
|
|
};
|
|
|
|
let Some(participant) = presence_manager.as_ref(ctx).get_participant(&viewer_id) else {
|
|
log::warn!("Tried to open role request modal with non-existent participant");
|
|
return;
|
|
};
|
|
|
|
let params = ParticipantAvatarParams::new(participant, false);
|
|
let firebase_uid = participant.info.profile_data.firebase_uid.clone();
|
|
self.shared_session_role_change_modal
|
|
.update(ctx, |modal, ctx| {
|
|
modal.open_for_sharer_response(
|
|
terminal_pane_id,
|
|
viewer_id,
|
|
firebase_uid,
|
|
role_request_id,
|
|
params,
|
|
role,
|
|
ctx,
|
|
);
|
|
});
|
|
|
|
self.terminal_with_shared_session_role_change_modal_open = Some(terminal_pane_id);
|
|
ctx.focus(&self.shared_session_role_change_modal);
|
|
ctx.notify();
|
|
}
|
|
|
|
fn open_shared_session_sharer_grant_modal(
|
|
&mut self,
|
|
terminal_pane_id: TerminalPaneId,
|
|
participant_id: ParticipantId,
|
|
ctx: &mut ViewContext<Self>,
|
|
) {
|
|
self.shared_session_role_change_modal
|
|
.update(ctx, |modal, ctx| {
|
|
modal.open_for_sharer_grant(terminal_pane_id, participant_id, ctx);
|
|
});
|
|
self.terminal_with_shared_session_role_change_modal_open = Some(terminal_pane_id);
|
|
ctx.focus(&self.shared_session_role_change_modal);
|
|
ctx.notify();
|
|
}
|
|
|
|
/// Closes the parent shared session role change modal if it is open. Does nothing otherwise.
|
|
fn close_shared_session_role_change_modal(
|
|
&mut self,
|
|
source: RoleChangeCloseSource,
|
|
ctx: &mut ViewContext<Self>,
|
|
) {
|
|
let Some(terminal_pane_id) = self
|
|
.terminal_with_shared_session_role_change_modal_open
|
|
.take()
|
|
else {
|
|
return;
|
|
};
|
|
|
|
let should_close_modal = self
|
|
.shared_session_role_change_modal
|
|
.update(ctx, |modal, ctx| {
|
|
match source {
|
|
RoleChangeCloseSource::ViewerRequest => modal.close_for_viewer_request(ctx),
|
|
RoleChangeCloseSource::SharerResponse => modal.close_for_sharer_response(ctx),
|
|
RoleChangeCloseSource::SharerGrant => modal.close_for_sharer_grant(ctx),
|
|
}
|
|
|
|
modal.all_child_modals_are_closed()
|
|
});
|
|
|
|
if should_close_modal {
|
|
if let Some(terminal_view) = self.terminal_view_from_pane_id(terminal_pane_id, ctx) {
|
|
terminal_view.update(ctx, |view, ctx| {
|
|
view.set_show_pane_accent_border(false, ctx)
|
|
});
|
|
}
|
|
if let Some(pane) = self.focused_pane_content(ctx) {
|
|
pane.focus(ctx);
|
|
}
|
|
}
|
|
ctx.notify();
|
|
}
|
|
|
|
fn remove_shared_session_role_request(
|
|
&mut self,
|
|
role_request_id: RoleRequestId,
|
|
ctx: &mut ViewContext<Self>,
|
|
) {
|
|
self.shared_session_role_change_modal
|
|
.update(ctx, |modal, ctx| {
|
|
modal.remove_role_request(role_request_id, ctx);
|
|
});
|
|
}
|
|
|
|
fn set_shared_session_role_change_modal_request_id(
|
|
&mut self,
|
|
role_request_id: RoleRequestId,
|
|
ctx: &mut ViewContext<Self>,
|
|
) {
|
|
self.shared_session_role_change_modal
|
|
.update(ctx, |modal, _| {
|
|
modal.set_role_request_id(role_request_id);
|
|
});
|
|
}
|
|
|
|
fn handle_shared_session_role_change_modal_event(
|
|
&mut self,
|
|
event: &RoleChangeModalEvent,
|
|
ctx: &mut ViewContext<Self>,
|
|
) {
|
|
match event {
|
|
RoleChangeModalEvent::CancelRequest {
|
|
terminal_pane_id,
|
|
role_request_id,
|
|
} => {
|
|
self.close_shared_session_role_change_modal(
|
|
RoleChangeCloseSource::ViewerRequest,
|
|
ctx,
|
|
);
|
|
if let Some(terminal_view) = self.terminal_view_from_pane_id(*terminal_pane_id, ctx)
|
|
{
|
|
terminal_view.update(ctx, |view, ctx| {
|
|
view.cancel_shared_session_role_request(role_request_id.clone(), ctx)
|
|
});
|
|
}
|
|
ctx.notify();
|
|
}
|
|
RoleChangeModalEvent::ApproveRequest {
|
|
terminal_pane_id,
|
|
participant_id,
|
|
role_request_id,
|
|
role,
|
|
} => {
|
|
let response = RoleRequestResponse::Approved { new_role: *role };
|
|
|
|
if let Some(terminal_view) = self.terminal_view_from_pane_id(*terminal_pane_id, ctx)
|
|
{
|
|
terminal_view.update(ctx, |view, ctx| {
|
|
view.respond_to_shared_session_role_request(
|
|
participant_id.clone(),
|
|
role_request_id.clone(),
|
|
response,
|
|
ctx,
|
|
);
|
|
});
|
|
}
|
|
ctx.notify();
|
|
}
|
|
RoleChangeModalEvent::DenyRequest {
|
|
terminal_pane_id,
|
|
participant_id,
|
|
role_request_id,
|
|
} => {
|
|
let response = RoleRequestResponse::Rejected {
|
|
reason: RoleRequestRejectedReason::RejectedBySharer,
|
|
};
|
|
if let Some(terminal_view) = self.terminal_view_from_pane_id(*terminal_pane_id, ctx)
|
|
{
|
|
terminal_view.update(ctx, |view, ctx| {
|
|
view.respond_to_shared_session_role_request(
|
|
participant_id.clone(),
|
|
role_request_id.clone(),
|
|
response,
|
|
ctx,
|
|
);
|
|
});
|
|
}
|
|
ctx.notify();
|
|
}
|
|
RoleChangeModalEvent::Close { source } => {
|
|
self.close_shared_session_role_change_modal(*source, ctx)
|
|
}
|
|
RoleChangeModalEvent::CancelGrant => {
|
|
self.close_shared_session_role_change_modal(
|
|
RoleChangeCloseSource::SharerGrant,
|
|
ctx,
|
|
);
|
|
send_telemetry_from_ctx!(
|
|
TelemetryEvent::SharerCancelledGrantRole {
|
|
role: Role::Executor
|
|
},
|
|
ctx
|
|
);
|
|
}
|
|
RoleChangeModalEvent::GrantRole {
|
|
terminal_pane_id,
|
|
participant_id,
|
|
dont_show_again,
|
|
} => {
|
|
if *dont_show_again {
|
|
if let Err(e) = SessionSettings::handle(ctx).update(ctx, |settings, ctx| {
|
|
settings
|
|
.should_confirm_shared_session_edit_access
|
|
.set_value(false, ctx)
|
|
}) {
|
|
log::error!(
|
|
"Failed to set should_confirm_shared_session_edit_access setting to false: {e}"
|
|
);
|
|
}
|
|
send_telemetry_from_ctx!(TelemetryEvent::SharerGrantModalDontShowAgain, ctx);
|
|
}
|
|
|
|
let Some(terminal_view) = self.terminal_view_from_pane_id(*terminal_pane_id, ctx)
|
|
else {
|
|
log::error!("Tried to grant role for non existent terminal pane");
|
|
return;
|
|
};
|
|
|
|
let role_request_id = terminal_view.read(ctx, |view, ctx| {
|
|
view.shared_session_presence_manager().and_then(|manager| {
|
|
manager
|
|
.as_ref(ctx)
|
|
.get_role_request(participant_id)
|
|
.cloned()
|
|
})
|
|
});
|
|
|
|
// If participant has a pending role request, we respond to it here instead of in the role request modal
|
|
if let Some(role_request_id) = role_request_id {
|
|
terminal_view.update(ctx, |view, ctx| {
|
|
let response = RoleRequestResponse::Approved {
|
|
new_role: Role::Executor,
|
|
};
|
|
view.respond_to_shared_session_role_request(
|
|
participant_id.clone(),
|
|
role_request_id.clone(),
|
|
response,
|
|
ctx,
|
|
);
|
|
});
|
|
self.remove_shared_session_role_request(role_request_id.clone(), ctx);
|
|
// Otherwise, just update their role
|
|
} else {
|
|
terminal_view.update(ctx, |view, ctx| {
|
|
view.update_role(participant_id.clone(), Role::Executor, ctx)
|
|
});
|
|
}
|
|
|
|
self.close_shared_session_role_change_modal(
|
|
RoleChangeCloseSource::SharerGrant,
|
|
ctx,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn new_internal(
|
|
tips_completed: ModelHandle<TipsCompleted>,
|
|
user_default_shell_unsupported_banner_model_handle: ModelHandle<BannerState>,
|
|
server_api: Arc<ServerApi>,
|
|
model_event_sender: Option<SyncSender<ModelEvent>>,
|
|
initial_layout_callback: InitialLayoutCallback,
|
|
ctx: &mut ViewContext<Self>,
|
|
) -> Self {
|
|
let windowing_state = WindowManager::handle(ctx);
|
|
ctx.observe(&windowing_state, Self::handle_windowing_state_update);
|
|
|
|
let mut pane_contents = HashMap::new();
|
|
|
|
let resources = TerminalViewResources {
|
|
tips_completed: tips_completed.clone(),
|
|
server_api: server_api.clone(),
|
|
model_event_sender: model_event_sender.clone(),
|
|
};
|
|
|
|
let view_bounds = Self::estimated_view_bounds(ctx);
|
|
|
|
let mut pane_history = Vec::new();
|
|
|
|
let (pane_data, initial_focus) = initial_layout_callback(
|
|
resources,
|
|
&mut pane_contents,
|
|
&mut pane_history,
|
|
view_bounds,
|
|
ctx,
|
|
);
|
|
|
|
let focused_pane = initial_focus
|
|
.focused_pane
|
|
.or_else(|| pane_contents.keys().min().copied())
|
|
.expect("At least one pane should have been created");
|
|
|
|
let active_session_id = initial_focus.active_session.or_else(|| {
|
|
pane_contents
|
|
.keys()
|
|
.filter_map(|id| id.as_terminal_pane_id())
|
|
.min()
|
|
});
|
|
|
|
let in_split_pane = pane_data.visible_pane_count() > 1;
|
|
let focus_state = ctx.add_model(|_| {
|
|
focus_state::PaneGroupFocusState::new(focused_pane, active_session_id, in_split_pane)
|
|
});
|
|
ctx.subscribe_to_model(&focus_state, |me, _, event, ctx| {
|
|
me.handle_focus_state_event(event, ctx);
|
|
});
|
|
|
|
let block_client = ServerApiProvider::as_ref(ctx).get_block_client();
|
|
let share_modal =
|
|
ctx.add_typed_action_view(|ctx| ShareBlockModal::new(None, block_client, ctx));
|
|
ctx.subscribe_to_view(&share_modal, move |me, _, event, ctx| {
|
|
me.handle_share_block_modal_event(event, ctx);
|
|
});
|
|
|
|
ctx.subscribe_to_model(&PaneSettings::handle(ctx), |_, _, _, ctx| {
|
|
ctx.notify();
|
|
});
|
|
|
|
let user_default_shell_changed_banner = ctx.add_typed_action_view(|_| {
|
|
Banner::<PaneGroupAction>::new_permanently_dismissible(
|
|
BannerTextContent::formatted_text(vec![
|
|
FormattedTextFragment::plain_text(
|
|
"Galaxy doesn't currently support your default shell, falling back to zsh. ",
|
|
),
|
|
FormattedTextFragment::hyperlink("Learn more", WARP_SHELL_COMPATIBILITY_DOCS),
|
|
]),
|
|
)
|
|
});
|
|
|
|
ctx.subscribe_to_model(&GeneralSettings::handle(ctx), |me, _, event, ctx| {
|
|
if matches!(
|
|
event,
|
|
GeneralSettingsChangedEvent::UserDefaultShellUnsupportedBannerState { .. }
|
|
) {
|
|
me.user_default_shell_unsupported_banner_model_handle
|
|
.update(ctx, |banner_state, ctx| {
|
|
*banner_state = *GeneralSettings::as_ref(ctx)
|
|
.user_default_shell_unsupported_banner_state;
|
|
ctx.notify();
|
|
})
|
|
}
|
|
});
|
|
|
|
ctx.subscribe_to_view(&user_default_shell_changed_banner, |me, _, event, ctx| {
|
|
me.handle_user_default_shell_changed_banner_event(event, ctx);
|
|
});
|
|
ctx.observe(
|
|
&user_default_shell_unsupported_banner_model_handle,
|
|
|_, _, ctx| {
|
|
ctx.notify();
|
|
},
|
|
);
|
|
|
|
let share_session_modal = ctx.add_typed_action_view(ShareSessionModal::new);
|
|
ctx.subscribe_to_view(&share_session_modal, |me, _, event, ctx| {
|
|
me.handle_share_session_modal_event(event, ctx);
|
|
});
|
|
|
|
let shared_session_role_change_modal = ctx.add_view(RoleChangeModal::new);
|
|
ctx.subscribe_to_view(&shared_session_role_change_modal, |me, _, event, ctx| {
|
|
me.handle_shared_session_role_change_modal_event(event, ctx);
|
|
});
|
|
|
|
ctx.subscribe_to_model(&UndoCloseStack::handle(ctx), |me, _, event, ctx| {
|
|
let UndoCloseStackEvent::DiscardPane(pane_id) = event;
|
|
me.discard_pane(*pane_id, ctx);
|
|
});
|
|
|
|
let active_file_model = ctx.add_model(|_| ActiveFileModel::new());
|
|
|
|
let mut pane_group = Self {
|
|
tips_completed,
|
|
user_default_shell_unsupported_banner_model_handle,
|
|
model_event_sender,
|
|
panes: pane_data,
|
|
focus_state,
|
|
pane_history,
|
|
pane_contents,
|
|
server_api,
|
|
terminal_with_open_share_block_modal: None,
|
|
share_block_modal: share_modal,
|
|
dragged_border: None,
|
|
user_default_shell_changed_banner,
|
|
terminal_with_open_share_session_modal: None,
|
|
share_session_modal,
|
|
terminal_with_shared_session_role_change_modal_open: None,
|
|
shared_session_role_change_modal,
|
|
active_file_model,
|
|
terminal_with_open_summarization_dialog: None,
|
|
pane_with_open_environment_setup_mode_selector: None,
|
|
pane_with_open_agent_assisted_environment_modal: None,
|
|
right_panel_open: false,
|
|
left_panel_open: false,
|
|
is_right_panel_maximized: false,
|
|
pending_ambient_agent_conversation_restorations: HashMap::new(),
|
|
child_agent_panes: HashMap::new(),
|
|
dismissed_child_agents: HashSet::new(),
|
|
custom_title: None,
|
|
};
|
|
|
|
// Notify any restored panes that they belong to this pane group.
|
|
pane_group.reattach_panes(ctx);
|
|
if FeatureFlag::DragTabsToWindows.is_enabled() {
|
|
pane_group.focus(ctx);
|
|
}
|
|
ctx.notify();
|
|
|
|
// Recreate hidden child agent panes for any child conversations
|
|
// discovered via the parent→child index. Child panes are excluded
|
|
// from snapshots and always rebuilt here on startup.
|
|
let pane_ids: Vec<PaneId> = pane_group.pane_contents.keys().copied().collect();
|
|
for pane_id in pane_ids {
|
|
pane_group.create_missing_child_agent_panes(pane_id, ctx);
|
|
}
|
|
|
|
pane_group
|
|
}
|
|
|
|
/// Startup restoration: for each parent conversation on this pane, creates
|
|
/// hidden child panes for any children not yet tracked in `child_agent_panes`.
|
|
/// Child panes are excluded from snapshots; children are discovered via the
|
|
/// `children_by_parent` index on the history model and their conversation
|
|
/// data is taken from `RestoredAgentConversations`.
|
|
fn create_missing_child_agent_panes(&mut self, pane_id: PaneId, ctx: &mut ViewContext<Self>) {
|
|
let Some(terminal_pane) = self
|
|
.pane_contents
|
|
.get(&pane_id)
|
|
.and_then(|c| c.as_any().downcast_ref::<TerminalPane>())
|
|
else {
|
|
return;
|
|
};
|
|
let terminal_view_id = terminal_pane.terminal_view(ctx).id();
|
|
|
|
// Collect child IDs from both in-memory conversations and the startup
|
|
// index. The index covers children that haven't been loaded into
|
|
// conversations_by_id yet (because their pane was not snapshotted).
|
|
let mut children_to_create: HashSet<AIConversationId> = HashSet::new();
|
|
let history_model = BlocklistAIHistoryModel::as_ref(ctx);
|
|
for conversation in history_model.all_live_conversations_for_terminal_view(terminal_view_id)
|
|
{
|
|
if conversation.is_child_agent_conversation() {
|
|
continue;
|
|
}
|
|
let parent_id = conversation.id();
|
|
|
|
// Check in-memory children (live conversations).
|
|
for child in history_model.child_conversations_of(parent_id) {
|
|
let child_id = child.id();
|
|
if !self.child_agent_panes.contains_key(&child_id)
|
|
&& !self.dismissed_child_agents.contains(&child_id)
|
|
{
|
|
children_to_create.insert(child_id);
|
|
}
|
|
}
|
|
|
|
// Check the startup index for children not yet in memory.
|
|
for &child_id in history_model.child_conversation_ids_of(&parent_id) {
|
|
if !self.child_agent_panes.contains_key(&child_id)
|
|
&& !self.dismissed_child_agents.contains(&child_id)
|
|
{
|
|
children_to_create.insert(child_id);
|
|
}
|
|
}
|
|
}
|
|
|
|
// TODO(QUALITY-378): Lazily restore child conversations/panes on demand (for example, on
|
|
// reveal/message) instead of eagerly materializing every child pane at startup.
|
|
let created_any = !children_to_create.is_empty();
|
|
for child_id in children_to_create {
|
|
// Try in-memory first, then fall back to RestoredAgentConversations.
|
|
let child_conversation = BlocklistAIHistoryModel::as_ref(ctx)
|
|
.conversation(&child_id)
|
|
.cloned()
|
|
.or_else(|| {
|
|
RestoredAgentConversations::handle(ctx)
|
|
.update(ctx, |store, _| store.take_conversation(&child_id))
|
|
});
|
|
let Some(child_conversation) = child_conversation else {
|
|
log::warn!("Child conversation {child_id:?} not found in memory or restored store");
|
|
continue;
|
|
};
|
|
self.create_hidden_child_agent_pane(child_conversation, pane_id, ctx);
|
|
}
|
|
|
|
if created_any {
|
|
self.focus_pane(pane_id, true, ctx);
|
|
}
|
|
}
|
|
|
|
/// Creates a hidden child agent pane for an existing child conversation,
|
|
/// restoring the conversation and tracking it in `child_agent_panes`.
|
|
fn create_hidden_child_agent_pane(
|
|
&mut self,
|
|
child_conversation: AIConversation,
|
|
parent_pane_id: PaneId,
|
|
ctx: &mut ViewContext<Self>,
|
|
) {
|
|
let child_id = child_conversation.id();
|
|
let new_pane_id =
|
|
self.insert_terminal_pane_hidden_for_child_agent(parent_pane_id, HashMap::new(), ctx);
|
|
|
|
if let Some(new_terminal_view) = self.terminal_view_from_pane_id(new_pane_id, ctx) {
|
|
new_terminal_view.update(ctx, |terminal_view, ctx| {
|
|
terminal_view.restore_conversation_after_view_creation(
|
|
RestoredAIConversation::new(child_conversation),
|
|
true,
|
|
ctx,
|
|
);
|
|
terminal_view.enter_agent_view(
|
|
None,
|
|
Some(child_id),
|
|
AgentViewEntryOrigin::ChildAgent,
|
|
ctx,
|
|
);
|
|
});
|
|
|
|
self.child_agent_panes.insert(child_id, new_pane_id.into());
|
|
} else {
|
|
log::error!("Failed to get terminal view for child agent pane {child_id:?}");
|
|
self.discard_pane(new_pane_id.into(), ctx);
|
|
}
|
|
}
|
|
|
|
/// Helper that creates the initial [`PaneData`] and [`InitialFocus`] given a terminal view.
|
|
/// This is a common case in creating a new pane group with a single terminal session.
|
|
fn terminal_pane_data(
|
|
uuid: Vec<u8>,
|
|
view: ViewHandle<TerminalView>,
|
|
terminal_manager: ModelHandle<Box<dyn TerminalManager>>,
|
|
model_event_sender: Option<SyncSender<ModelEvent>>,
|
|
pane_contents: &mut HashMap<PaneId, Box<dyn AnyPaneContent>>,
|
|
pane_history: &mut Vec<PaneId>,
|
|
ctx: &mut ViewContext<Self>,
|
|
) -> (PaneData, InitialFocus) {
|
|
let pane_data = TerminalPane::new(uuid, terminal_manager, view, model_event_sender, ctx);
|
|
let terminal_pane_id = pane_data.terminal_pane_id();
|
|
let pane_id = terminal_pane_id.into();
|
|
pane_contents.insert(pane_id, Box::new(pane_data));
|
|
pane_history.push(pane_id);
|
|
let focus = InitialFocus {
|
|
focused_pane: Some(pane_id),
|
|
active_session: Some(terminal_pane_id),
|
|
};
|
|
(PaneData::new(pane_id), focus)
|
|
}
|
|
|
|
fn create_cloud_mode_terminal(
|
|
resources: TerminalViewResources,
|
|
view_bounds_size: Vector2F,
|
|
ctx: &mut ViewContext<Self>,
|
|
) -> (
|
|
ViewHandle<TerminalView>,
|
|
ModelHandle<Box<dyn TerminalManager>>,
|
|
) {
|
|
let window_id = ctx.window_id();
|
|
crate::terminal::view::ambient_agent::create_cloud_mode_view(
|
|
resources,
|
|
view_bounds_size,
|
|
window_id,
|
|
ctx,
|
|
)
|
|
}
|
|
|
|
/// Helper to create the terminal manager and view for an ambient agent pane.
|
|
fn create_ambient_agent_terminal(
|
|
resources: TerminalViewResources,
|
|
view_bounds_size: Vector2F,
|
|
ctx: &mut ViewContext<Self>,
|
|
) -> (
|
|
ViewHandle<TerminalView>,
|
|
ModelHandle<Box<dyn TerminalManager>>,
|
|
) {
|
|
let (terminal_view, terminal_manager) =
|
|
Self::create_cloud_mode_terminal(resources, view_bounds_size, ctx);
|
|
|
|
terminal_view.update(ctx, |view, ctx| {
|
|
view.enter_ambient_agent_setup(None, ctx);
|
|
});
|
|
|
|
(terminal_view, terminal_manager)
|
|
}
|
|
|
|
/// Stores the pending ambient agent restorations, triggers async fetches for
|
|
/// their task data, and sets up a single long-lived subscription that will
|
|
/// process each pane as its task data arrives.
|
|
fn register_pending_ambient_restorations(
|
|
&mut self,
|
|
pending: Vec<(AmbientAgentTaskId, PaneId)>,
|
|
ctx: &mut ViewContext<Self>,
|
|
) {
|
|
for (task_id, _) in &pending {
|
|
AgentConversationsModel::handle(ctx).update(ctx, |model, ctx| {
|
|
model.get_or_async_fetch_task_data(task_id, ctx);
|
|
});
|
|
}
|
|
|
|
self.pending_ambient_agent_conversation_restorations = pending.into_iter().collect();
|
|
|
|
let conversations_model = AgentConversationsModel::handle(ctx);
|
|
ctx.subscribe_to_model(&conversations_model, |me, _, event, ctx| {
|
|
me.handle_pending_ambient_restoration_event(event, ctx);
|
|
});
|
|
}
|
|
|
|
/// Subscription handler that processes pending ambient agent pane restorations
|
|
/// whenever task data is updated or conversations finish loading.
|
|
fn handle_pending_ambient_restoration_event(
|
|
&mut self,
|
|
event: &AgentConversationsModelEvent,
|
|
ctx: &mut ViewContext<Self>,
|
|
) {
|
|
if !matches!(
|
|
event,
|
|
AgentConversationsModelEvent::TasksUpdated
|
|
| AgentConversationsModelEvent::ConversationsLoaded
|
|
) {
|
|
return;
|
|
}
|
|
|
|
if self
|
|
.pending_ambient_agent_conversation_restorations
|
|
.is_empty()
|
|
{
|
|
return;
|
|
}
|
|
|
|
let ready_tasks: Vec<_> = self
|
|
.pending_ambient_agent_conversation_restorations
|
|
.keys()
|
|
.filter(|task_id| {
|
|
AgentConversationsModel::as_ref(ctx)
|
|
.get_task_data(task_id)
|
|
.is_some()
|
|
})
|
|
.copied()
|
|
.collect();
|
|
|
|
let resources = TerminalViewResources {
|
|
tips_completed: self.tips_completed.clone(),
|
|
server_api: self.server_api.clone(),
|
|
model_event_sender: self.model_event_sender.clone(),
|
|
};
|
|
let view_size = Self::estimated_view_bounds(ctx).size();
|
|
|
|
for task_id in ready_tasks {
|
|
let Some(pane_id) = self
|
|
.pending_ambient_agent_conversation_restorations
|
|
.remove(&task_id)
|
|
else {
|
|
continue;
|
|
};
|
|
let Some(task) = AgentConversationsModel::as_ref(ctx).get_task_data(&task_id) else {
|
|
continue;
|
|
};
|
|
|
|
let item = ConversationOrTask::Task(&task);
|
|
match item.get_open_action(None, ctx) {
|
|
Some(WorkspaceAction::OpenAmbientAgentSession {
|
|
session_id,
|
|
task_id,
|
|
}) => {
|
|
let (view, terminal_manager) = Self::create_shared_session_viewer(
|
|
session_id,
|
|
resources.clone(),
|
|
view_size,
|
|
ctx,
|
|
);
|
|
let new_pane = TerminalPane::new(
|
|
Uuid::new_v4().as_bytes().to_vec(),
|
|
terminal_manager,
|
|
view,
|
|
self.model_event_sender.clone(),
|
|
ctx,
|
|
);
|
|
self.replace_pane(pane_id, new_pane, false, ctx);
|
|
|
|
AgentConversationsModel::handle(ctx).update(ctx, |model, ctx| {
|
|
model.mark_task_as_manually_opened(task_id, ctx);
|
|
});
|
|
}
|
|
Some(WorkspaceAction::OpenConversationTranscriptViewer {
|
|
conversation_id,
|
|
ambient_agent_task_id,
|
|
}) => {
|
|
let loaded =
|
|
self.terminal_view_from_pane_id(pane_id, ctx)
|
|
.is_some_and(|target_view| {
|
|
Self::fetch_and_load_transcript(
|
|
target_view,
|
|
conversation_id,
|
|
ambient_agent_task_id,
|
|
ctx,
|
|
)
|
|
});
|
|
if !loaded {
|
|
self.pending_ambient_agent_conversation_restorations
|
|
.insert(task_id, pane_id);
|
|
}
|
|
}
|
|
_ => {
|
|
self.replace_pane_with_new_cloud_conversation(pane_id, ctx);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Fetches conversation data and loads it into the given transcript viewer.
|
|
///
|
|
/// Returns `true` if the conversation metadata was found and the async load
|
|
/// was kicked off, or `false` if the metadata isn't available yet (caller
|
|
/// should defer and retry later).
|
|
fn fetch_and_load_transcript(
|
|
target_view: ViewHandle<TerminalView>,
|
|
server_conversation_token: ServerConversationToken,
|
|
ambient_agent_task_id: Option<AmbientAgentTaskId>,
|
|
ctx: &mut ViewContext<Self>,
|
|
) -> bool {
|
|
if let Some(task_id) = ambient_agent_task_id {
|
|
AgentConversationsModel::handle(ctx).update(ctx, |model, ctx| {
|
|
model.mark_task_as_manually_opened(task_id, ctx);
|
|
});
|
|
}
|
|
|
|
let history_model_handle = BlocklistAIHistoryModel::handle(ctx);
|
|
let ai_conversation_id = history_model_handle
|
|
.as_ref(ctx)
|
|
.find_conversation_id_by_server_token(&server_conversation_token);
|
|
|
|
let Some(ai_conversation_id) = ai_conversation_id else {
|
|
return false;
|
|
};
|
|
|
|
let future = history_model_handle
|
|
.as_ref(ctx)
|
|
.load_conversation_data(ai_conversation_id, ctx);
|
|
ctx.spawn(future, move |group, conversation, ctx| {
|
|
if let Some(conversation) = conversation {
|
|
group.load_data_into_transcript_viewer(target_view, conversation, ctx);
|
|
} else if let Some(pane_id) =
|
|
group.find_pane_id_for_terminal_view(target_view.id(), ctx)
|
|
{
|
|
log::error!(
|
|
"Failed to restore ambient agent pane, replacing with new cloud conversation"
|
|
);
|
|
group.replace_pane_with_new_cloud_conversation(pane_id, ctx);
|
|
}
|
|
});
|
|
true
|
|
}
|
|
|
|
/// Replaces a pane with a new cloud conversation.
|
|
fn replace_pane_with_new_cloud_conversation(
|
|
&mut self,
|
|
pane_id: PaneId,
|
|
ctx: &mut ViewContext<Self>,
|
|
) {
|
|
let resources = TerminalViewResources {
|
|
tips_completed: self.tips_completed.clone(),
|
|
server_api: self.server_api.clone(),
|
|
model_event_sender: self.model_event_sender.clone(),
|
|
};
|
|
let view_size = Self::estimated_view_bounds(ctx).size();
|
|
let (view, terminal_manager) =
|
|
Self::create_ambient_agent_terminal(resources, view_size, ctx);
|
|
let new_pane = TerminalPane::new(
|
|
Uuid::new_v4().as_bytes().to_vec(),
|
|
terminal_manager,
|
|
view,
|
|
self.model_event_sender.clone(),
|
|
ctx,
|
|
);
|
|
self.replace_pane(pane_id, new_pane, false, ctx);
|
|
}
|
|
|
|
/// Initial layout for a [`PaneGroup`] with a single ambient agent pane.
|
|
fn initial_ambient_agent_pane(
|
|
resources: TerminalViewResources,
|
|
view_bounds: RectF,
|
|
model_event_sender: Option<SyncSender<ModelEvent>>,
|
|
pane_contents: &mut HashMap<PaneId, Box<dyn AnyPaneContent>>,
|
|
pane_history: &mut Vec<PaneId>,
|
|
ctx: &mut ViewContext<Self>,
|
|
) -> (PaneData, InitialFocus) {
|
|
let uuid = Uuid::new_v4();
|
|
|
|
let (terminal_view, terminal_manager) =
|
|
Self::create_ambient_agent_terminal(resources, view_bounds.size(), ctx);
|
|
|
|
Self::terminal_pane_data(
|
|
uuid.into_bytes().to_vec(),
|
|
terminal_view,
|
|
terminal_manager,
|
|
model_event_sender,
|
|
pane_contents,
|
|
pane_history,
|
|
ctx,
|
|
)
|
|
}
|
|
|
|
/// Initial layout for a [`PaneGroup`] with a single terminal pane.
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn initial_single_terminal_pane(
|
|
options: NewTerminalOptions,
|
|
resources: TerminalViewResources,
|
|
unsupported_banner_model_handle: ModelHandle<BannerState>,
|
|
view_bounds: RectF,
|
|
model_event_sender: Option<SyncSender<ModelEvent>>,
|
|
pane_contents: &mut HashMap<PaneId, Box<dyn AnyPaneContent>>,
|
|
pane_history: &mut Vec<PaneId>,
|
|
ctx: &mut ViewContext<Self>,
|
|
) -> (PaneData, InitialFocus) {
|
|
let (view, terminal_manager) = PaneGroup::create_session(
|
|
options.initial_directory,
|
|
options.env_vars,
|
|
options.is_shared_session_creator,
|
|
resources,
|
|
None,
|
|
options.conversation_restoration,
|
|
unsupported_banner_model_handle,
|
|
view_bounds.size(),
|
|
model_event_sender.clone(),
|
|
options.shell,
|
|
None,
|
|
ctx,
|
|
);
|
|
let uuid = Uuid::new_v4();
|
|
|
|
let pane_data = TerminalPane::new(
|
|
uuid.as_bytes().to_vec(),
|
|
terminal_manager,
|
|
view,
|
|
model_event_sender,
|
|
ctx,
|
|
);
|
|
let terminal_pane_id = pane_data.terminal_pane_id();
|
|
let pane_id = terminal_pane_id.into();
|
|
pane_contents.insert(pane_id, Box::new(pane_data));
|
|
pane_history.push(pane_id);
|
|
let focus = InitialFocus {
|
|
focused_pane: Some(pane_id),
|
|
active_session: Some(terminal_pane_id),
|
|
};
|
|
(PaneData::new(pane_id), focus)
|
|
}
|
|
|
|
/// Constructs a new [`PaneGroup`] with a layout that adheres
|
|
/// to the specification of the provided [`PanesLayout`].
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn new_with_panes_layout(
|
|
tips_completed: ModelHandle<TipsCompleted>,
|
|
user_default_shell_unsupported_banner_model_handle: ModelHandle<BannerState>,
|
|
server_api: Arc<ServerApi>,
|
|
panes_layout: PanesLayout,
|
|
block_lists: Arc<HashMap<PaneUuid, Vec<SerializedBlockListItem>>>,
|
|
model_event_sender: Option<SyncSender<ModelEvent>>,
|
|
ctx: &mut ViewContext<Self>,
|
|
) -> Self {
|
|
let unsupported_banner_model_handle =
|
|
user_default_shell_unsupported_banner_model_handle.clone();
|
|
let model_event_sender_clone = model_event_sender.clone();
|
|
|
|
// Shared container so pending ambient restorations collected inside the
|
|
// layout closure can be accessed after `new_internal` returns.
|
|
let pending_ambient = Rc::new(RefCell::new(Vec::new()));
|
|
let pending_ambient_for_closure = pending_ambient.clone();
|
|
|
|
let initial_layout = move |resources,
|
|
pane_contents: &mut HashMap<PaneId, Box<dyn AnyPaneContent>>,
|
|
pane_history: &mut Vec<PaneId>,
|
|
view_bounds: RectF,
|
|
ctx: &mut ViewContext<Self>| {
|
|
match panes_layout {
|
|
PanesLayout::Template(template) => Self::pane_tree_from_template(
|
|
template,
|
|
resources,
|
|
ctx,
|
|
pane_contents,
|
|
true, // initialize as the leftmost pane
|
|
unsupported_banner_model_handle,
|
|
view_bounds.size(),
|
|
model_event_sender_clone,
|
|
),
|
|
PanesLayout::Snapshot(panes_snapshot) => {
|
|
let mut deferred_panes = Vec::new();
|
|
let mut pending_restorations = Vec::new();
|
|
let result = Self::restore_pane_tree(
|
|
*panes_snapshot,
|
|
block_lists,
|
|
resources.clone(),
|
|
ctx,
|
|
pane_contents,
|
|
unsupported_banner_model_handle.clone(),
|
|
view_bounds.size(),
|
|
model_event_sender_clone.clone(),
|
|
&mut deferred_panes,
|
|
&mut pending_restorations,
|
|
)
|
|
.unwrap_or_else(|err| {
|
|
log::warn!("Error restoring pane tree: {err:#}");
|
|
Self::initial_single_terminal_pane(
|
|
NewTerminalOptions::default(),
|
|
resources,
|
|
unsupported_banner_model_handle,
|
|
view_bounds,
|
|
model_event_sender_clone,
|
|
pane_contents,
|
|
pane_history,
|
|
ctx,
|
|
)
|
|
});
|
|
|
|
*pending_ambient_for_closure.borrow_mut() = pending_restorations;
|
|
|
|
Self::process_deferred_panes(deferred_panes, result, pane_contents, ctx)
|
|
}
|
|
PanesLayout::SingleTerminal(options) => Self::initial_single_terminal_pane(
|
|
*options,
|
|
resources,
|
|
unsupported_banner_model_handle,
|
|
view_bounds,
|
|
model_event_sender_clone,
|
|
pane_contents,
|
|
pane_history,
|
|
ctx,
|
|
),
|
|
PanesLayout::AmbientAgent => Self::initial_ambient_agent_pane(
|
|
resources,
|
|
view_bounds,
|
|
model_event_sender_clone,
|
|
pane_contents,
|
|
pane_history,
|
|
ctx,
|
|
),
|
|
}
|
|
};
|
|
|
|
let mut pane_group = Self::new_internal(
|
|
tips_completed,
|
|
user_default_shell_unsupported_banner_model_handle,
|
|
server_api,
|
|
model_event_sender.clone(),
|
|
Box::new(initial_layout),
|
|
ctx,
|
|
);
|
|
|
|
// The closure has now run — register any pending ambient restorations
|
|
// that need to wait for task data from the server.
|
|
let pending = pending_ambient.take();
|
|
if !pending.is_empty() {
|
|
pane_group.register_pending_ambient_restorations(pending, ctx);
|
|
}
|
|
|
|
pane_group
|
|
}
|
|
|
|
pub fn new_from_existing_pane(
|
|
pane: Box<dyn AnyPaneContent>,
|
|
tips_completed: ModelHandle<TipsCompleted>,
|
|
user_default_shell_unsupported_banner_model_handle: ModelHandle<BannerState>,
|
|
server_api: Arc<ServerApi>,
|
|
model_event_sender: Option<SyncSender<ModelEvent>>,
|
|
ctx: &mut ViewContext<Self>,
|
|
) -> Self {
|
|
let pane_id = pane.as_pane().id();
|
|
let initial_layout = move |_,
|
|
pane_contents: &mut HashMap<PaneId, Box<dyn AnyPaneContent>>,
|
|
pane_history: &mut Vec<PaneId>,
|
|
_: RectF,
|
|
|
|
_: &mut ViewContext<Self>| {
|
|
pane_contents.insert(pane_id, pane);
|
|
pane_history.push(pane_id);
|
|
let initial_focus = InitialFocus {
|
|
focused_pane: Some(pane_id),
|
|
active_session: pane_id.as_terminal_pane_id(),
|
|
};
|
|
(PaneData::new(pane_id), initial_focus)
|
|
};
|
|
Self::new_internal(
|
|
tips_completed,
|
|
user_default_shell_unsupported_banner_model_handle,
|
|
server_api,
|
|
model_event_sender,
|
|
Box::new(initial_layout),
|
|
ctx,
|
|
)
|
|
}
|
|
|
|
pub fn new_for_shared_session_viewer(
|
|
session_id: SessionId,
|
|
tips_completed: ModelHandle<TipsCompleted>,
|
|
user_default_shell_unsupported_banner_model_handle: ModelHandle<BannerState>,
|
|
server_api: Arc<ServerApi>,
|
|
model_event_sender: Option<SyncSender<ModelEvent>>,
|
|
ctx: &mut ViewContext<Self>,
|
|
) -> Self {
|
|
let model_event_sender_clone = model_event_sender.clone();
|
|
let initial_layout = move |resources,
|
|
pane_contents: &mut HashMap<PaneId, Box<dyn AnyPaneContent>>,
|
|
pane_history: &mut Vec<PaneId>,
|
|
view_bounds: RectF,
|
|
ctx: &mut ViewContext<Self>| {
|
|
let (view, terminal_manager) = PaneGroup::create_shared_session_viewer(
|
|
session_id,
|
|
resources,
|
|
view_bounds.size(),
|
|
ctx,
|
|
);
|
|
|
|
Self::terminal_pane_data(
|
|
Uuid::new_v4().as_bytes().to_vec(),
|
|
view,
|
|
terminal_manager,
|
|
model_event_sender_clone,
|
|
pane_contents,
|
|
pane_history,
|
|
ctx,
|
|
)
|
|
};
|
|
Self::new_internal(
|
|
tips_completed,
|
|
user_default_shell_unsupported_banner_model_handle,
|
|
server_api,
|
|
model_event_sender,
|
|
Box::new(initial_layout),
|
|
ctx,
|
|
)
|
|
}
|
|
|
|
/// Create a new pane group for a view-only cloud conversation.
|
|
pub fn new_for_conversation_transcript_viewer(
|
|
conversation: AIConversation,
|
|
ambient_agent_task_id: Option<AmbientAgentTaskId>,
|
|
tips_completed: ModelHandle<TipsCompleted>,
|
|
user_default_shell_unsupported_banner_model_handle: ModelHandle<BannerState>,
|
|
server_api: Arc<ServerApi>,
|
|
model_event_sender: Option<SyncSender<ModelEvent>>,
|
|
ctx: &mut ViewContext<Self>,
|
|
) -> Self {
|
|
let model_event_sender_clone = model_event_sender.clone();
|
|
let initial_layout = move |resources,
|
|
pane_contents: &mut HashMap<PaneId, Box<dyn AnyPaneContent>>,
|
|
pane_history: &mut Vec<PaneId>,
|
|
view_bounds: RectF,
|
|
ctx: &mut ViewContext<Self>| {
|
|
let (view, terminal_manager) = PaneGroup::create_conversation_viewer(
|
|
conversation.clone(),
|
|
ambient_agent_task_id,
|
|
resources,
|
|
view_bounds.size(),
|
|
ctx,
|
|
);
|
|
|
|
Self::terminal_pane_data(
|
|
Uuid::new_v4().as_bytes().to_vec(),
|
|
view,
|
|
terminal_manager,
|
|
model_event_sender_clone,
|
|
pane_contents,
|
|
pane_history,
|
|
ctx,
|
|
)
|
|
};
|
|
Self::new_internal(
|
|
tips_completed,
|
|
user_default_shell_unsupported_banner_model_handle,
|
|
server_api,
|
|
model_event_sender,
|
|
Box::new(initial_layout),
|
|
ctx,
|
|
)
|
|
}
|
|
|
|
/// Create a new pane group with a loading state for a conversation viewer.
|
|
/// The actual conversation data will be loaded asynchronously.
|
|
pub fn new_for_conversation_transcript_viewer_loading(
|
|
tips_completed: ModelHandle<TipsCompleted>,
|
|
user_default_shell_unsupported_banner_model_handle: ModelHandle<BannerState>,
|
|
server_api: Arc<ServerApi>,
|
|
model_event_sender: Option<SyncSender<ModelEvent>>,
|
|
ctx: &mut ViewContext<Self>,
|
|
) -> Self {
|
|
let model_event_sender_clone = model_event_sender.clone();
|
|
let initial_layout = move |resources,
|
|
pane_contents: &mut HashMap<PaneId, Box<dyn AnyPaneContent>>,
|
|
pane_history: &mut Vec<PaneId>,
|
|
view_bounds: RectF,
|
|
ctx: &mut ViewContext<Self>| {
|
|
let (terminal_view, terminal_manager) = Self::create_loading_terminal_manager_and_view(
|
|
resources,
|
|
view_bounds.size(),
|
|
ctx.window_id(),
|
|
ctx,
|
|
);
|
|
|
|
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, _ctx| {
|
|
history_model
|
|
.mark_terminal_view_as_conversation_transcript_viewer(terminal_view.id());
|
|
});
|
|
|
|
Self::terminal_pane_data(
|
|
Uuid::new_v4().as_bytes().to_vec(),
|
|
terminal_view,
|
|
terminal_manager,
|
|
model_event_sender_clone,
|
|
pane_contents,
|
|
pane_history,
|
|
ctx,
|
|
)
|
|
};
|
|
Self::new_internal(
|
|
tips_completed,
|
|
user_default_shell_unsupported_banner_model_handle,
|
|
server_api,
|
|
model_event_sender,
|
|
Box::new(initial_layout),
|
|
ctx,
|
|
)
|
|
}
|
|
|
|
/// Load conversation data into a conversation viewer that was created with a loading state.
|
|
/// Uses the active session view as the target.
|
|
pub fn load_data_into_conversation_transcript_viewer(
|
|
&mut self,
|
|
conversation: CloudConversationData,
|
|
ctx: &mut ViewContext<Self>,
|
|
) {
|
|
// Get the active terminal view
|
|
let Some(terminal_view) = self.active_session_view(ctx) else {
|
|
log::error!("No active terminal view to load conversation into");
|
|
return;
|
|
};
|
|
self.load_data_into_transcript_viewer(terminal_view, conversation, ctx);
|
|
}
|
|
|
|
/// Load conversation data into a specific transcript viewer terminal view.
|
|
fn load_data_into_transcript_viewer(
|
|
&mut self,
|
|
terminal_view: ViewHandle<TerminalView>,
|
|
cloud_conversation: CloudConversationData,
|
|
ctx: &mut ViewContext<Self>,
|
|
) {
|
|
let terminal_manager = self
|
|
.find_pane_id_for_terminal_view(terminal_view.id(), ctx)
|
|
.and_then(|pid| pid.as_terminal_pane_id())
|
|
.and_then(|tpid| self.terminal_session_by_id(tpid))
|
|
.map(|session| session.terminal_manager(ctx));
|
|
|
|
let ambient_agent_task_id = match &cloud_conversation {
|
|
CloudConversationData::Oz(conversation) => conversation
|
|
.server_metadata()
|
|
.and_then(|metadata| metadata.ambient_agent_task_id),
|
|
CloudConversationData::CLIAgent(cli_conversation) => {
|
|
cli_conversation.metadata.ambient_agent_task_id
|
|
}
|
|
};
|
|
|
|
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, _ctx| {
|
|
history_model.mark_terminal_view_as_conversation_transcript_viewer(terminal_view.id());
|
|
});
|
|
|
|
if let Some(ref terminal_manager) = terminal_manager {
|
|
let status = if let Some(task_id) = ambient_agent_task_id {
|
|
ConversationTranscriptViewerStatus::ViewingAmbientConversation(task_id)
|
|
} else {
|
|
ConversationTranscriptViewerStatus::ViewingLocalConversation
|
|
};
|
|
|
|
terminal_manager.update(ctx, |terminal_manager, _ctx| {
|
|
terminal_manager
|
|
.model()
|
|
.lock()
|
|
.set_conversation_transcript_viewer_status(Some(status));
|
|
});
|
|
}
|
|
|
|
match cloud_conversation {
|
|
CloudConversationData::Oz(conversation) => {
|
|
terminal_view.update(ctx, |view, ctx| {
|
|
view.restore_conversation_after_view_creation(
|
|
RestoredAIConversation::new(*conversation),
|
|
true,
|
|
ctx,
|
|
);
|
|
});
|
|
}
|
|
CloudConversationData::CLIAgent(cli_conversation) => {
|
|
if !FeatureFlag::AgentHarness.is_enabled() {
|
|
log::warn!("AgentHarness flag is disabled; ignoring CLI agent conversation");
|
|
return;
|
|
}
|
|
let harness = match cli_conversation.metadata.harness {
|
|
AIAgentHarness::ClaudeCode => Some(Harness::Claude),
|
|
AIAgentHarness::Gemini => Some(Harness::Gemini),
|
|
AIAgentHarness::Oz => None,
|
|
AIAgentHarness::Unknown => Some(Harness::Unknown),
|
|
};
|
|
terminal_view.update(ctx, |view, ctx| {
|
|
view.restore_conversation_and_directory_context(
|
|
CloudConversationData::CLIAgent(cli_conversation),
|
|
true,
|
|
|_, _| {},
|
|
ctx,
|
|
);
|
|
// Keep the viewer's AmbientAgentViewModel harness in sync with the loaded run.
|
|
if let Some(harness) = harness {
|
|
view.ambient_agent_view_model()
|
|
.clone()
|
|
.update(ctx, |model, ctx| {
|
|
model.set_harness(harness, ctx);
|
|
});
|
|
}
|
|
// 3p runs have no materialized AIConversation, so enter agent view with a
|
|
// fresh vehicle conversation and retag the restored snapshot block onto it so
|
|
// it passes `should_hide_block`'s agent view filter.
|
|
view.enter_agent_view_for_new_conversation(
|
|
None,
|
|
AgentViewEntryOrigin::ThirdPartyCloudAgent,
|
|
ctx,
|
|
);
|
|
if let Some(vehicle_conversation_id) = view.active_conversation_id(ctx) {
|
|
view.model
|
|
.lock()
|
|
.block_list_mut()
|
|
.attach_non_startup_blocks_to_conversation(vehicle_conversation_id);
|
|
}
|
|
});
|
|
}
|
|
};
|
|
|
|
// Register the transcript viewer as an ambient session so it appears in the Active section
|
|
// of the conversation list.
|
|
if let Some(task_id) = ambient_agent_task_id {
|
|
ActiveAgentViewsModel::handle(ctx).update(ctx, |active_views, ctx| {
|
|
active_views.register_ambient_session(terminal_view.id(), task_id, ctx);
|
|
});
|
|
}
|
|
|
|
// Insert the conversation ended tombstone (includes Open in Warp button on WASM).
|
|
if terminal_manager.is_some() {
|
|
terminal_view.update(ctx, |view, ctx| {
|
|
view.insert_conversation_ended_tombstone(ctx);
|
|
});
|
|
}
|
|
|
|
ctx.notify();
|
|
}
|
|
|
|
fn handle_windowing_state_update(
|
|
&mut self,
|
|
_handle: ModelHandle<WindowManager>,
|
|
ctx: &mut ViewContext<Self>,
|
|
) {
|
|
self.update_session_visibility(ctx);
|
|
}
|
|
|
|
fn handle_focus_state_event(
|
|
&mut self,
|
|
event: &PaneGroupFocusEvent,
|
|
ctx: &mut ViewContext<Self>,
|
|
) {
|
|
match event {
|
|
PaneGroupFocusEvent::FocusChanged { .. } => ctx.notify(),
|
|
PaneGroupFocusEvent::ActiveSessionChanged { .. } => {
|
|
ctx.emit(Event::ActiveSessionChanged);
|
|
ctx.notify();
|
|
}
|
|
PaneGroupFocusEvent::InSplitPaneChanged => ctx.notify(),
|
|
PaneGroupFocusEvent::FocusedPaneMaximizedChanged => ctx.notify(),
|
|
}
|
|
}
|
|
|
|
fn handle_share_block_modal_event(
|
|
&mut self,
|
|
event: &ShareBlockModalEvent,
|
|
ctx: &mut ViewContext<Self>,
|
|
) {
|
|
match event {
|
|
ShareBlockModalEvent::Close => {
|
|
self.focus(ctx);
|
|
self.terminal_with_open_share_block_modal = None;
|
|
ctx.notify();
|
|
}
|
|
ShareBlockModalEvent::ShowToast { message, flavor } => ctx.emit(Event::ShowToast {
|
|
message: message.clone(),
|
|
flavor: *flavor,
|
|
pane_id: None,
|
|
}),
|
|
}
|
|
}
|
|
|
|
/// Used to add a new pane but not splitting panes.
|
|
pub fn add_terminal_pane(
|
|
&mut self,
|
|
direction: Direction,
|
|
chosen_shell: Option<AvailableShell>,
|
|
ctx: &mut ViewContext<Self>,
|
|
) -> TerminalPaneId {
|
|
let new_pane_id = self.add_session(
|
|
direction,
|
|
Some(self.focused_pane_id(ctx)),
|
|
self.active_session_id(ctx),
|
|
chosen_shell,
|
|
None, /* conversation_restoration */
|
|
ctx,
|
|
);
|
|
ctx.emit(Event::AppStateChanged);
|
|
new_pane_id
|
|
}
|
|
|
|
/// Adds a terminal split pane without applying the user's default session mode.
|
|
pub fn add_terminal_pane_ignoring_default_session_mode(
|
|
&mut self,
|
|
direction: Direction,
|
|
chosen_shell: Option<AvailableShell>,
|
|
ctx: &mut ViewContext<Self>,
|
|
) -> TerminalPaneId {
|
|
let new_pane_id = self.add_session_with_default_session_mode_behavior(
|
|
direction,
|
|
Some(self.focused_pane_id(ctx)),
|
|
self.active_session_id(ctx),
|
|
chosen_shell,
|
|
None, /* conversation_restoration */
|
|
DefaultSessionModeBehavior::Ignore,
|
|
ctx,
|
|
);
|
|
ctx.emit(Event::AppStateChanged);
|
|
new_pane_id
|
|
}
|
|
|
|
/// Used when splitting panes.
|
|
fn insert_terminal_pane(
|
|
&mut self,
|
|
direction: Direction,
|
|
base_pane_id: PaneId,
|
|
chosen_shell: Option<AvailableShell>,
|
|
ctx: &mut ViewContext<Self>,
|
|
) -> TerminalPaneId {
|
|
let base_session_id = base_pane_id
|
|
.as_terminal_pane_id()
|
|
.or(self.active_session_id(ctx));
|
|
let new_pane_id = self.add_session(
|
|
direction,
|
|
Some(base_pane_id),
|
|
base_session_id,
|
|
chosen_shell,
|
|
None, /* conversation_restoration */
|
|
ctx,
|
|
);
|
|
ctx.emit(Event::AppStateChanged);
|
|
new_pane_id
|
|
}
|
|
|
|
/// Creates a terminal pane that is immediately hidden as a child agent pane.
|
|
/// Unlike `insert_terminal_pane`, the new pane is never focused and is hidden
|
|
/// before layout notifications propagate, preventing disturbance to the
|
|
/// existing pane arrangement.
|
|
fn insert_terminal_pane_hidden_for_child_agent(
|
|
&mut self,
|
|
base_pane_id: PaneId,
|
|
env_vars: HashMap<OsString, OsString>,
|
|
ctx: &mut ViewContext<Self>,
|
|
) -> TerminalPaneId {
|
|
let base_session_id = base_pane_id
|
|
.as_terminal_pane_id()
|
|
.or(self.active_session_id(ctx));
|
|
let startup_directory = self.startup_path_for_new_session(base_session_id, ctx);
|
|
let (pane_data, _view) =
|
|
self.create_terminal_pane_data(startup_directory, env_vars, None, None, ctx);
|
|
let new_pane_id = pane_data.terminal_pane_id();
|
|
let _ = self.add_pane_with_options(
|
|
Box::new(pane_data),
|
|
AddPaneOptions {
|
|
direction: Direction::Right,
|
|
base_pane_id: Some(base_pane_id),
|
|
focus_new_pane: false,
|
|
visibility: NewPaneVisibility::HiddenForChildAgent,
|
|
emit_app_state_changed: false,
|
|
},
|
|
ctx,
|
|
);
|
|
|
|
new_pane_id
|
|
}
|
|
|
|
/// Creates a cloud-mode pane that is immediately hidden as a child agent pane.
|
|
/// Unlike `create_ambient_agent_pane`, this leaves the new terminal view
|
|
/// uninitialized so callers can create and select the child conversation
|
|
/// explicitly before the deferred shared-session viewer binds to it.
|
|
fn insert_ambient_agent_pane_hidden_for_child_agent(
|
|
&mut self,
|
|
base_pane_id: PaneId,
|
|
ctx: &mut ViewContext<Self>,
|
|
) -> TerminalPaneId {
|
|
let uuid = Uuid::new_v4();
|
|
let resources = TerminalViewResources {
|
|
tips_completed: self.tips_completed.clone(),
|
|
server_api: self.server_api.clone(),
|
|
model_event_sender: self.model_event_sender.clone(),
|
|
};
|
|
let view_bounds = Self::estimated_view_bounds(ctx);
|
|
let (view, terminal_manager) =
|
|
Self::create_cloud_mode_terminal(resources, view_bounds.size(), ctx);
|
|
let pane_data = TerminalPane::new(
|
|
uuid.as_bytes().to_vec(),
|
|
terminal_manager,
|
|
view,
|
|
self.model_event_sender.clone(),
|
|
ctx,
|
|
);
|
|
let new_pane_id = pane_data.terminal_pane_id();
|
|
let _ = self.add_pane_with_options(
|
|
Box::new(pane_data),
|
|
AddPaneOptions {
|
|
direction: Direction::Right,
|
|
base_pane_id: Some(base_pane_id),
|
|
focus_new_pane: false,
|
|
visibility: NewPaneVisibility::HiddenForChildAgent,
|
|
emit_app_state_changed: false,
|
|
},
|
|
ctx,
|
|
);
|
|
|
|
new_pane_id
|
|
}
|
|
|
|
/// Get the [`PaneView<TerminalView>`] for the pane at `pane_index`, if that pane is:
|
|
/// 1. In bounds
|
|
/// 2. A terminal pane
|
|
#[cfg(any(test, feature = "integration_tests"))]
|
|
pub fn terminal_pane_view_at_pane_index(
|
|
&self,
|
|
pane_index: usize,
|
|
) -> Option<ViewHandle<self::pane::terminal_pane::TerminalPaneView>> {
|
|
self.terminal_session_by_pane_index(pane_index)
|
|
.map(|session| session.pane_view())
|
|
}
|
|
|
|
/// Get the [`TerminalView`] within the pane at `pane_index`, if that pane is:
|
|
/// 1. In bounds
|
|
/// 2. A terminal pane
|
|
#[cfg(any(test, feature = "integration_tests"))]
|
|
pub fn terminal_view_at_pane_index(
|
|
&self,
|
|
pane_index: usize,
|
|
ctx: &AppContext,
|
|
) -> Option<ViewHandle<TerminalView>> {
|
|
self.terminal_session_by_pane_index(pane_index)
|
|
.map(|session| session.terminal_view(ctx))
|
|
}
|
|
|
|
/// Gets the pane ID for the pane at `pane_index`, if any.
|
|
/// Only considers visible panes (excludes panes hidden for close, move, job, etc.).
|
|
pub fn pane_id_from_index(&self, pane_index: usize) -> Option<PaneId> {
|
|
self.panes.visible_pane_ids().get(pane_index).copied()
|
|
}
|
|
|
|
pub fn visible_pane_ids(&self) -> Vec<PaneId> {
|
|
self.panes.visible_pane_ids()
|
|
}
|
|
|
|
pub fn original_pane_for_replacement(&self, replacement_pane_id: PaneId) -> Option<PaneId> {
|
|
self.panes
|
|
.original_pane_for_replacement(replacement_pane_id)
|
|
}
|
|
|
|
pub fn pane_ids(&self) -> impl Iterator<Item = PaneId> + '_ {
|
|
self.pane_contents.keys().copied()
|
|
}
|
|
|
|
pub fn has_pane_id(&self, pane_id: PaneId) -> bool {
|
|
self.pane_contents.contains_key(&pane_id)
|
|
}
|
|
|
|
/// Get the notebook view within the pane at `pane_index`.
|
|
#[cfg(any(test, feature = "integration_tests"))]
|
|
pub fn notebook_view_at_pane_index(
|
|
&self,
|
|
pane_index: usize,
|
|
ctx: &AppContext,
|
|
) -> Option<ViewHandle<crate::notebooks::notebook::NotebookView>> {
|
|
self.content_by_pane_index(pane_index)
|
|
.and_then(|pane| pane.as_any().downcast_ref::<NotebookPane>())
|
|
.map(|pane| pane.notebook_view(ctx))
|
|
}
|
|
|
|
/// Get the notebook view within the pane at `pane_index`.
|
|
#[cfg(any(test, feature = "integration_tests"))]
|
|
pub fn workflow_view_at_pane_index(
|
|
&self,
|
|
pane_index: usize,
|
|
ctx: &AppContext,
|
|
) -> Option<ViewHandle<crate::workflows::workflow_view::WorkflowView>> {
|
|
self.content_by_pane_index(pane_index)
|
|
.and_then(|pane| pane.as_any().downcast_ref::<WorkflowPane>())
|
|
.map(|pane| pane.get_view(ctx))
|
|
}
|
|
|
|
/// Find the ID of the pane at an index (going left to right, top to bottom).
|
|
/// Only considers visible panes (excludes panes hidden for close, move, job, etc.).
|
|
pub fn pane_id_by_index(&self, pane_index: usize) -> Option<PaneId> {
|
|
self.panes.visible_pane_ids().get(pane_index).copied()
|
|
}
|
|
|
|
pub fn set_dim_even_if_focused_for_all_panes(
|
|
&mut self,
|
|
dim_even_if_focused: bool,
|
|
ctx: &mut ViewContext<Self>,
|
|
) {
|
|
for pane in self.pane_contents.values() {
|
|
let pane = pane.as_pane();
|
|
let configuration = pane.pane_configuration();
|
|
configuration.update(ctx, |config, ctx| {
|
|
config.set_dim_even_if_focused(dim_even_if_focused, ctx);
|
|
});
|
|
}
|
|
}
|
|
|
|
pub fn set_left_panel_open(&mut self, is_open: bool, ctx: &mut ViewContext<Self>) {
|
|
if self.left_panel_open != is_open {
|
|
self.left_panel_open = is_open;
|
|
ctx.emit(Event::LeftPanelToggled { is_open });
|
|
}
|
|
ctx.notify();
|
|
}
|
|
|
|
pub fn focus_first_pane(&mut self, ctx: &mut ViewContext<Self>) -> bool {
|
|
if let Some(first) = self.panes.visible_pane_ids().first().copied() {
|
|
return self.focus_pane_and_record_in_history(first, ctx);
|
|
}
|
|
false
|
|
}
|
|
|
|
pub fn focus_last_pane(&mut self, ctx: &mut ViewContext<Self>) -> bool {
|
|
if let Some(last) = self.panes.visible_pane_ids().last().copied() {
|
|
return self.focus_pane_and_record_in_history(last, ctx);
|
|
}
|
|
false
|
|
}
|
|
|
|
/// The current working directory of the active terminal session, if it's local.
|
|
pub fn active_session_path(&self, ctx: &AppContext) -> Option<PathBuf> {
|
|
self.session_path(&self.active_session_id(ctx)?, ctx)
|
|
}
|
|
|
|
fn session_path(&self, pane_id: &TerminalPaneId, ctx: &AppContext) -> Option<PathBuf> {
|
|
self.terminal_view_from_pane_id(*pane_id, ctx)?
|
|
.as_ref(ctx)
|
|
.active_session_path_if_local(ctx)
|
|
}
|
|
|
|
fn content_by_pane_index(&self, index: usize) -> Option<&dyn AnyPaneContent> {
|
|
self.content_by_pane_id(self.pane_id_by_index(index)?)
|
|
}
|
|
|
|
fn content_by_pane_id(&self, pane_id: PaneId) -> Option<&dyn AnyPaneContent> {
|
|
self.pane_contents.get(&pane_id).map(|pane| pane.as_ref())
|
|
}
|
|
|
|
fn terminal_session_by_pane_index(&self, index: usize) -> Option<&TerminalPane> {
|
|
self.content_by_pane_index(index)
|
|
.and_then(|pane| pane.as_any().downcast_ref())
|
|
}
|
|
|
|
pub fn any_pane_being_dragged(&self, app: &AppContext) -> bool {
|
|
self.pane_contents
|
|
.iter()
|
|
.any(|(_, pane_content)| pane_content.as_pane().is_pane_being_dragged(app))
|
|
}
|
|
|
|
/// Removes the given pane id from the pane group, focusing the previous active session
|
|
/// and pane and returning the Box<dyn AnyPaneContent> of the removed pane. Note that this
|
|
/// is primarily used for pane management, and should not be used if you are planning on closing
|
|
/// the session as this does not call the needed clean up code and does not add the tab
|
|
/// to the undo stack if it gets closed.
|
|
pub fn remove_pane_for_move(
|
|
&mut self,
|
|
pane_id: &PaneId,
|
|
ctx: &mut ViewContext<Self>,
|
|
) -> Option<Box<dyn AnyPaneContent>> {
|
|
// Clear any hidden pane entry since the pane is being permanently removed from this group.
|
|
self.panes.remove_hidden_pane(*pane_id);
|
|
|
|
let was_focused = self.focus_state.as_ref(ctx).is_pane_focused(*pane_id);
|
|
self.focus_next_terminal_pane_and_activate_session(*pane_id, PaneRemovalReason::Move, ctx);
|
|
if self.pane_count() == 1 {
|
|
ctx.emit(Event::Exited {
|
|
add_to_undo_stack: false,
|
|
});
|
|
}
|
|
|
|
match self.pane_contents.get(pane_id) {
|
|
Some(data) => {
|
|
let pane = data.as_pane();
|
|
pane.detach(self, DetachType::Moved, ctx);
|
|
}
|
|
None => log::error!("Could not find data for pane id: {pane_id:?}"),
|
|
};
|
|
|
|
if !self.panes.remove(*pane_id) {
|
|
log::error!("Pane not found");
|
|
}
|
|
|
|
let pane_content = self.pane_contents.remove(pane_id);
|
|
|
|
let in_split_pane = self.panes.visible_pane_count() > 1;
|
|
self.focus_state.update(ctx, |focus_state, ctx| {
|
|
focus_state.set_in_split_pane(in_split_pane, ctx);
|
|
// If the focused+maximized pane was removed, stop maximizing panes.
|
|
if was_focused {
|
|
focus_state.set_focused_pane_maximized(false, ctx);
|
|
}
|
|
});
|
|
|
|
ctx.notify();
|
|
ctx.emit(Event::TerminalViewStateChanged);
|
|
ctx.emit(Event::AppStateChanged);
|
|
pane_content
|
|
}
|
|
|
|
pub fn notebook_pane_by_pane_id(&self, pane_id: Option<PaneId>) -> Option<&NotebookPane> {
|
|
self.downcast_pane_by_id(pane_id?)
|
|
}
|
|
|
|
pub fn env_var_collection_pane_by_pane_id(
|
|
&self,
|
|
pane_id: Option<PaneId>,
|
|
) -> Option<&EnvVarCollectionPane> {
|
|
self.downcast_pane_by_id(pane_id?)
|
|
}
|
|
|
|
pub fn workflow_pane_by_pane_id(&self, pane_id: Option<PaneId>) -> Option<&WorkflowPane> {
|
|
self.downcast_pane_by_id(pane_id?)
|
|
}
|
|
|
|
pub fn ai_fact_pane_by_pane_id(&self, pane_id: Option<PaneId>) -> Option<&AIFactPane> {
|
|
self.downcast_pane_by_id(pane_id?)
|
|
}
|
|
|
|
pub fn code_pane_by_id(&self, pane_id: PaneId) -> Option<&CodePane> {
|
|
self.downcast_pane_by_id(pane_id)
|
|
}
|
|
|
|
/// Removes an editor tab from a code pane for moving to another location.
|
|
/// Returns the removed tab as a CodePane if the operation succeeds.
|
|
pub fn remove_editor_tab_for_move(
|
|
&mut self,
|
|
pane_id: PaneId,
|
|
editor_tab_index: usize,
|
|
ctx: &mut ViewContext<Self>,
|
|
) -> Option<Box<dyn AnyPaneContent>> {
|
|
self.code_pane_by_id(pane_id)
|
|
.and_then(|pane| {
|
|
pane.file_view(ctx).update(ctx, |file_view, ctx| {
|
|
file_view.remove_tab_for_move(editor_tab_index, ctx)
|
|
})
|
|
})
|
|
.map(|p| Box::new(p) as Box<dyn AnyPaneContent>)
|
|
}
|
|
|
|
/// The generic pane at `index`, if it exists.
|
|
pub fn pane_by_index(&self, index: usize) -> Option<&dyn PaneContent> {
|
|
self.content_by_pane_index(index).map(|pane| pane.as_pane())
|
|
}
|
|
|
|
/// The generic pane with the given pane ID, if it exists.
|
|
pub fn pane_by_id(&self, pane_id: PaneId) -> Option<&dyn PaneContent> {
|
|
self.content_by_pane_id(pane_id).map(|pane| pane.as_pane())
|
|
}
|
|
|
|
/// Get a pane's contents by ID. This returns `None` if the pane does not exist or is of the
|
|
/// wrong type.
|
|
pub fn downcast_pane_by_id<T: Any + 'static>(&self, pane_id: PaneId) -> Option<&T> {
|
|
self.content_by_pane_id(pane_id)?.as_any().downcast_ref()
|
|
}
|
|
|
|
/// Returns true if the given pane is hidden for close (undo functionality).
|
|
pub fn is_pane_hidden_for_close(&self, pane_id: PaneId) -> bool {
|
|
self.panes.is_hidden_closed_pane(&pane_id)
|
|
}
|
|
|
|
/// Emits an event for the workspace to show a confirmation dialog if necessary, or closes immediately if not.
|
|
/// If a dialog is opened, the workspace may call back into pane group to close the pane after the user confirms.
|
|
pub fn close_pane_with_confirmation(&mut self, pane_id: PaneId, ctx: &mut ViewContext<Self>) {
|
|
// Child agent panes are just hidden when closed, so skip the
|
|
// "process running" warning—it doesn't apply.
|
|
if self.is_child_agent_pane(pane_id) {
|
|
self.close_pane(pane_id, ctx);
|
|
return;
|
|
}
|
|
|
|
if let Some(terminal_manager) = self
|
|
.terminal_session_by_id(pane_id)
|
|
.map(|session| session.terminal_manager(ctx))
|
|
{
|
|
if terminal_manager.read(ctx, |terminal_manager, _ctx| {
|
|
terminal_manager
|
|
.model()
|
|
.lock()
|
|
.shared_session_status()
|
|
.is_sharer()
|
|
}) {
|
|
ctx.emit(Event::CloseSharedSessionPaneRequested { pane_id });
|
|
return;
|
|
}
|
|
}
|
|
|
|
let summary = UnsavedStateSummary::for_pane(self, pane_id, ctx);
|
|
if summary.should_display_warning(ctx) && ChannelState::channel() != Channel::Integration {
|
|
log::info!("Displaying unsaved changes warning for pane");
|
|
let confirm_self = ctx.handle();
|
|
let show_process_self = ctx.handle();
|
|
let dialog = summary
|
|
.dialog()
|
|
.on_confirm(move |ctx| {
|
|
if let Some(pane_group) = confirm_self.upgrade(ctx) {
|
|
pane_group.update(ctx, |pane_group, ctx| {
|
|
pane_group.close_pane(pane_id, ctx);
|
|
});
|
|
}
|
|
})
|
|
.on_show_processes(move |ctx| {
|
|
if let Some(pane_group) = show_process_self.upgrade(ctx) {
|
|
pane_group.update(ctx, |_, ctx| {
|
|
ctx.emit(Event::OpenPalette {
|
|
mode: PaletteMode::Navigation,
|
|
source: PaletteSource::QuitModal,
|
|
query: Some("running".to_string()),
|
|
});
|
|
})
|
|
}
|
|
})
|
|
.on_cancel(|_ctx| {});
|
|
|
|
if dialog.show(ctx) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
self.close_pane(pane_id, ctx);
|
|
}
|
|
|
|
/// Definitively close the pane. This does not go through the undo close check where we might hide the pane instead of
|
|
/// discarding it.
|
|
fn discard_pane(&mut self, pane_id: PaneId, ctx: &mut ViewContext<Self>) {
|
|
if let Some(terminal_view) = self.terminal_view_from_pane_id(pane_id, ctx) {
|
|
let terminal_view_id = terminal_view.id();
|
|
|
|
// Discard any child agent panes parented by this terminal view.
|
|
self.remove_child_agent_panes(terminal_view_id, ctx);
|
|
|
|
// Preserve conversations from terminal views before cleaning up the pane
|
|
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, _| {
|
|
history_model.mark_conversations_historical_for_terminal_view(terminal_view_id);
|
|
});
|
|
}
|
|
|
|
self.cleanup_closed_pane(pane_id, ctx);
|
|
}
|
|
|
|
/// If this pane was the active session and or focused pane, focuses the previous session and pane.
|
|
///
|
|
/// Called before removing a pane from a pane group (either because the pane is being closed or because it is being moved
|
|
/// to another pane group). Also does some other pane clean up actions like remove the pane from history.
|
|
fn focus_next_terminal_pane_and_activate_session(
|
|
&mut self,
|
|
pane_id_to_remove: PaneId,
|
|
pane_removal_reason: PaneRemovalReason,
|
|
ctx: &mut ViewContext<Self>,
|
|
) {
|
|
// If we're removing the latest active terminal pane, activate the last focused session. If
|
|
// focus changes to another terminal pane, that will become focused instead.
|
|
if Some(pane_id_to_remove) == self.active_session_id(ctx).map(Into::into) {
|
|
let new_active_session = self.choose_active_session(pane_id_to_remove);
|
|
self.focus_state.update(ctx, |focus_state, ctx| {
|
|
focus_state.set_active_session(new_active_session, ctx);
|
|
});
|
|
}
|
|
|
|
// Only change the focus if we're removing the focused pane
|
|
if pane_id_to_remove == self.focused_pane_id(ctx) {
|
|
match self.prev_pane_id(pane_id_to_remove) {
|
|
Some(id) => {
|
|
self.focus_pane(id, pane_removal_reason == PaneRemovalReason::Close, ctx);
|
|
}
|
|
None => {
|
|
log::error!("[PaneGroup] Unable to locate a panel to activate after close");
|
|
}
|
|
};
|
|
} else {
|
|
// If not, we still need to call notify to let the UI framework know about changes
|
|
ctx.notify();
|
|
}
|
|
|
|
self.remove_from_pane_history(pane_id_to_remove);
|
|
}
|
|
|
|
/// Returns true if the given pane is a child agent pane tracked in `child_agent_panes`.
|
|
fn is_child_agent_pane(&self, pane_id: PaneId) -> bool {
|
|
self.child_agent_panes.values().any(|&id| id == pane_id)
|
|
}
|
|
|
|
/// Collects the child agent pane IDs whose conversations are parented by
|
|
/// a conversation on the given terminal view.
|
|
fn child_pane_ids_for_parent(
|
|
&self,
|
|
parent_terminal_view_id: EntityId,
|
|
ctx: &AppContext,
|
|
) -> Vec<(AIConversationId, PaneId)> {
|
|
let history_model = BlocklistAIHistoryModel::as_ref(ctx);
|
|
self.child_agent_panes
|
|
.iter()
|
|
.filter(|(conv_id, _)| {
|
|
history_model
|
|
.conversation(conv_id)
|
|
.and_then(|c| c.parent_conversation_id())
|
|
.and_then(|parent_id| {
|
|
history_model.terminal_view_id_for_conversation(&parent_id)
|
|
})
|
|
.is_some_and(|tv_id| tv_id == parent_terminal_view_id)
|
|
})
|
|
.map(|(conv_id, pane_id)| (*conv_id, *pane_id))
|
|
.collect()
|
|
}
|
|
|
|
/// Removes and discards all child agent panes whose parent conversation
|
|
/// lives on the given terminal view. Used by both `close_pane` and
|
|
/// `discard_pane` to ensure children are cleaned up regardless of which
|
|
/// path removes the parent.
|
|
fn remove_child_agent_panes(
|
|
&mut self,
|
|
parent_terminal_view_id: EntityId,
|
|
ctx: &mut ViewContext<Self>,
|
|
) {
|
|
let children = self.child_pane_ids_for_parent(parent_terminal_view_id, ctx);
|
|
for (conv_id, child_pane_id) in children {
|
|
self.child_agent_panes.remove(&conv_id);
|
|
self.panes.remove_hidden_pane(child_pane_id);
|
|
self.discard_pane(child_pane_id, ctx);
|
|
}
|
|
}
|
|
|
|
pub fn close_pane(&mut self, pane_id: PaneId, ctx: &mut ViewContext<Self>) {
|
|
// Don't close a pane that doesn't exist
|
|
if !self.pane_contents.contains_key(&pane_id) {
|
|
return;
|
|
}
|
|
|
|
// If this pane is a child agent, remove it from tracking and
|
|
// discard it fully so it doesn't reappear on re-launch.
|
|
if self.is_child_agent_pane(pane_id) {
|
|
let dismissed_id = self
|
|
.child_agent_panes
|
|
.iter()
|
|
.find(|(_, p)| **p == pane_id)
|
|
.map(|(id, _)| *id);
|
|
if let Some(id) = dismissed_id {
|
|
self.dismissed_child_agents.insert(id);
|
|
}
|
|
self.child_agent_panes.retain(|_, &mut p| p != pane_id);
|
|
if !self.panes.is_pane_hidden(&pane_id) {
|
|
self.panes.hide_pane_for_child_agent(pane_id);
|
|
}
|
|
self.panes.remove_hidden_pane(pane_id);
|
|
self.focus_next_terminal_pane_and_activate_session(
|
|
pane_id,
|
|
PaneRemovalReason::Close,
|
|
ctx,
|
|
);
|
|
self.pane_contents.remove(&pane_id);
|
|
self.handle_pane_count_change(ctx);
|
|
ctx.emit(Event::TerminalViewStateChanged);
|
|
ctx.emit(Event::AppStateChanged);
|
|
return;
|
|
}
|
|
|
|
// If this is a parent with child agents, discard the children first.
|
|
if let Some(terminal_view) = self.terminal_view_from_pane_id(pane_id, ctx) {
|
|
self.remove_child_agent_panes(terminal_view.id(), ctx);
|
|
}
|
|
|
|
if FeatureFlag::UndoClosedPanes.is_enabled() {
|
|
// Don't clase a pane that's already been hidden to allow for undo functionality
|
|
if self.is_pane_hidden_for_close(pane_id) {
|
|
return;
|
|
}
|
|
|
|
if self.panes.visible_pane_count() == 1 {
|
|
// Tell the workspace that this pane group is now empty without
|
|
// doing any additional clean-up work. This ensures we don't
|
|
// pre-emptively delete any state that we might want to retain
|
|
// if the user re-opens the closed tab.
|
|
ctx.emit(Event::Exited {
|
|
add_to_undo_stack: true,
|
|
});
|
|
|
|
return;
|
|
}
|
|
|
|
if let Some(pane_data) = self.pane_contents.get(&pane_id) {
|
|
let pane = pane_data.as_pane();
|
|
pane.detach(self, DetachType::HiddenForClose, ctx);
|
|
|
|
let pane_group_handle = ctx.handle();
|
|
UndoCloseStack::handle(ctx).update(ctx, |stack, ctx| {
|
|
stack.handle_pane_closed_by_id(pane_group_handle, pane_id, ctx);
|
|
});
|
|
self.hide_closed_pane(pane_id, ctx);
|
|
}
|
|
|
|
// Remove opened share modal associated with the closing session.
|
|
if Some(pane_id) == self.terminal_with_open_share_block_modal.map(Into::into) {
|
|
self.terminal_with_open_share_block_modal = None;
|
|
}
|
|
|
|
if self.pane_with_open_environment_setup_mode_selector == Some(pane_id) {
|
|
self.pane_with_open_environment_setup_mode_selector = None;
|
|
}
|
|
if self.pane_with_open_agent_assisted_environment_modal == Some(pane_id) {
|
|
self.pane_with_open_agent_assisted_environment_modal = None;
|
|
}
|
|
|
|
self.focus_next_terminal_pane_and_activate_session(
|
|
pane_id,
|
|
PaneRemovalReason::Close,
|
|
ctx,
|
|
);
|
|
} else {
|
|
if self.pane_count() == 1 {
|
|
// Tell the workspace that this pane group is now empty without
|
|
// doing any additional clean-up work. This ensures we don't
|
|
// pre-emptively delete any state that we might want to retain
|
|
// if the user re-opens the closed tab.
|
|
ctx.emit(Event::Exited {
|
|
add_to_undo_stack: true,
|
|
});
|
|
|
|
return;
|
|
}
|
|
|
|
self.clean_up_pane(pane_id, ctx);
|
|
|
|
// Remove opened share modal associated with the closing session.
|
|
if Some(pane_id) == self.terminal_with_open_share_block_modal.map(Into::into) {
|
|
self.terminal_with_open_share_block_modal = None;
|
|
}
|
|
|
|
if self.pane_with_open_environment_setup_mode_selector == Some(pane_id) {
|
|
self.pane_with_open_environment_setup_mode_selector = None;
|
|
}
|
|
if self.pane_with_open_agent_assisted_environment_modal == Some(pane_id) {
|
|
self.pane_with_open_agent_assisted_environment_modal = None;
|
|
}
|
|
|
|
self.focus_next_terminal_pane_and_activate_session(
|
|
pane_id,
|
|
PaneRemovalReason::Close,
|
|
ctx,
|
|
);
|
|
|
|
self.pane_contents.remove(&pane_id);
|
|
|
|
// We should only remove the session id from the tree after we queried
|
|
// and got the previous session id.
|
|
if !self.panes.remove(pane_id) {
|
|
log::error!("Pane not found");
|
|
}
|
|
}
|
|
|
|
self.handle_pane_count_change(ctx);
|
|
|
|
ctx.emit(Event::TerminalViewStateChanged);
|
|
ctx.emit(Event::AppStateChanged);
|
|
}
|
|
|
|
pub fn close_pane_and_focus(
|
|
&mut self,
|
|
pane_id: PaneId,
|
|
pane_to_focus: PaneId,
|
|
ctx: &mut ViewContext<Self>,
|
|
) {
|
|
// Check if this is a temporary replacement that should be reverted
|
|
if self.panes.is_temporary_replacement(pane_id) {
|
|
// Remove the replacement pane and focus the original pane
|
|
let focused_pane_id = self
|
|
.close_temporary_replacement_pane(pane_id, ctx)
|
|
.unwrap_or(pane_to_focus);
|
|
ctx.emit(Event::FocusPane {
|
|
pane_to_focus: focused_pane_id,
|
|
});
|
|
ctx.notify();
|
|
} else {
|
|
// Normal pane close behavior
|
|
self.close_pane(pane_id, ctx);
|
|
ctx.emit(Event::FocusPane { pane_to_focus });
|
|
}
|
|
}
|
|
|
|
/// Temporarily replace a pane with another pane.
|
|
/// The original pane is hidden and can be restored later.
|
|
/// Returns true if the replacement was successful, false otherwise.
|
|
pub fn replace_pane<C: PaneContent>(
|
|
&mut self,
|
|
original_pane_id: PaneId,
|
|
replacement_pane: C,
|
|
is_temporary: bool,
|
|
ctx: &mut ViewContext<Self>,
|
|
) -> bool {
|
|
// Ensure original pane exists before attempting replacement
|
|
if !self.pane_contents.contains_key(&original_pane_id) {
|
|
log::error!(
|
|
"Attempted to replace pane {original_pane_id:?} that doesn't exist in contents"
|
|
);
|
|
return false;
|
|
}
|
|
|
|
let Some(replacement_pane_id) = self.add_pane_for_replacement(replacement_pane, ctx) else {
|
|
log::error!(
|
|
"Failed to create replacement pane for {original_pane_id:?} because attachment was prevented"
|
|
);
|
|
return false;
|
|
};
|
|
let success = self
|
|
.panes
|
|
.replace_pane(original_pane_id, replacement_pane_id, is_temporary);
|
|
|
|
if success {
|
|
// For permanent replacements, clean up the original pane
|
|
if !is_temporary {
|
|
self.clean_up_pane(original_pane_id, ctx);
|
|
self.pane_contents.remove(&original_pane_id);
|
|
}
|
|
|
|
// Focus the replacement pane to ensure proper user interaction
|
|
self.focus_pane_by_id(replacement_pane_id, ctx);
|
|
} else {
|
|
// If tree replacement failed, clean up the replacement pane we just created
|
|
log::error!(
|
|
"Failed to replace pane {original_pane_id:?} with {replacement_pane_id:?} in tree structure"
|
|
);
|
|
self.clean_up_pane(replacement_pane_id, ctx);
|
|
self.pane_contents.remove(&replacement_pane_id);
|
|
}
|
|
|
|
ctx.notify();
|
|
ctx.emit(Event::AppStateChanged);
|
|
success
|
|
}
|
|
|
|
fn close_temporary_replacement_pane(
|
|
&mut self,
|
|
replacement_pane_id: PaneId,
|
|
ctx: &mut ViewContext<Self>,
|
|
) -> Option<PaneId> {
|
|
let original_pane_id = self.panes.revert_temporary_replacement(replacement_pane_id);
|
|
self.clean_up_pane(replacement_pane_id, ctx);
|
|
self.pane_contents.remove(&replacement_pane_id);
|
|
|
|
if let Some(original_id) = original_pane_id {
|
|
// Focus the original pane to ensure proper user interaction
|
|
self.focus_pane_by_id(original_id, ctx);
|
|
}
|
|
|
|
original_pane_id
|
|
}
|
|
|
|
#[cfg(feature = "local_fs")]
|
|
fn replace_file_pane_with_code_pane(
|
|
&mut self,
|
|
file_pane_id: PaneId,
|
|
path: std::path::PathBuf,
|
|
source: Option<crate::code::editor_management::CodeSource>,
|
|
ctx: &mut ViewContext<Self>,
|
|
) {
|
|
use crate::code::editor_management::CodeSource;
|
|
use crate::pane_group::CodePane;
|
|
|
|
// Use the provided source if available.
|
|
let source = source.unwrap_or(CodeSource::Link {
|
|
path,
|
|
range_start: None,
|
|
range_end: None,
|
|
});
|
|
|
|
let code_pane = CodePane::new(source, None, ctx);
|
|
let success = self.replace_pane(file_pane_id, code_pane, false, ctx);
|
|
|
|
if !success {
|
|
log::error!("Failed to replace file pane {file_pane_id:?} with code pane");
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "local_fs")]
|
|
fn replace_code_pane_with_file_pane(
|
|
&mut self,
|
|
code_pane_id: PaneId,
|
|
path: std::path::PathBuf,
|
|
source: Option<crate::code::editor_management::CodeSource>,
|
|
ctx: &mut ViewContext<Self>,
|
|
) {
|
|
// Get the active session to pass to the FilePane, if any
|
|
let session = self.active_session_view(ctx).and_then(|view| {
|
|
// Use the active session if it's local
|
|
let view_ref = view.as_ref(ctx);
|
|
if view_ref.active_session_is_local(ctx) == Some(true) {
|
|
view_ref
|
|
.active_block_session_id()
|
|
.and_then(|session_id| view_ref.sessions_model().as_ref(ctx).get(session_id))
|
|
} else {
|
|
None
|
|
}
|
|
});
|
|
|
|
let file_pane = FilePane::new(Some(path), session, source, ctx);
|
|
let success = self.replace_pane(code_pane_id, file_pane, false, ctx);
|
|
|
|
if !success {
|
|
log::error!("Failed to replace code pane {code_pane_id:?} with file pane");
|
|
}
|
|
}
|
|
|
|
/// Handle a common pane event, such as splitting off another pane.
|
|
fn handle_pane_event(
|
|
&mut self,
|
|
pane_id: PaneId,
|
|
event: &PaneEvent,
|
|
ctx: &mut ViewContext<Self>,
|
|
) {
|
|
match event {
|
|
PaneEvent::Close => self.close_pane(pane_id, ctx),
|
|
PaneEvent::CloseAndFocus { pane_to_focus } => {
|
|
self.close_pane_and_focus(pane_id, *pane_to_focus, ctx);
|
|
}
|
|
// Pane-splitting events always create a new terminal pane, regardless of the original
|
|
// pane's type. This makes it easy to get a terminal session next to a non-terminal
|
|
// pane like a notebook. Once it's possible to open the same notebook more than once,
|
|
// we may revisit this so that splitting from a terminal pane starts a new session, but
|
|
// splitting from a notebook pane reopens the notebook side-by-side.
|
|
PaneEvent::SplitLeft(chosen_shell) => {
|
|
self.insert_terminal_pane(Direction::Left, pane_id, chosen_shell.clone(), ctx);
|
|
}
|
|
PaneEvent::SplitRight(chosen_shell) => {
|
|
self.insert_terminal_pane(Direction::Right, pane_id, chosen_shell.clone(), ctx);
|
|
}
|
|
PaneEvent::SplitUp(chosen_shell) => {
|
|
self.insert_terminal_pane(Direction::Up, pane_id, chosen_shell.clone(), ctx);
|
|
}
|
|
PaneEvent::SplitDown(chosen_shell) => {
|
|
self.insert_terminal_pane(Direction::Down, pane_id, chosen_shell.clone(), ctx);
|
|
}
|
|
PaneEvent::ToggleMaximized => {
|
|
// The toggled pane might not be the active pane -- focus it first.
|
|
self.focus_pane_by_id(pane_id, ctx);
|
|
self.toggle_maximize_pane(ctx);
|
|
}
|
|
PaneEvent::FocusSelf => self.focus_pane_by_id(pane_id, ctx),
|
|
PaneEvent::FocusActiveSession => self.focus_active_session(ctx),
|
|
PaneEvent::AppStateChanged => {
|
|
ctx.emit(Event::AppStateChanged);
|
|
}
|
|
PaneEvent::NewPaneInAIMode { initial_query } => {
|
|
self.add_terminal_pane_in_agent_mode(initial_query.as_deref(), None, ctx)
|
|
}
|
|
PaneEvent::ClearHoveredTabIndex => ctx.emit(Event::ClearHoveredTabIndex),
|
|
#[cfg(feature = "local_fs")]
|
|
PaneEvent::ReplaceWithCodePane { path, source } => {
|
|
self.replace_file_pane_with_code_pane(pane_id, path.clone(), source.clone(), ctx);
|
|
}
|
|
#[cfg(feature = "local_fs")]
|
|
PaneEvent::ReplaceWithFilePane { path, source } => {
|
|
self.replace_code_pane_with_file_pane(pane_id, path.clone(), source.clone(), ctx);
|
|
}
|
|
PaneEvent::RepoChanged => {
|
|
ctx.emit(Event::RepoChanged);
|
|
}
|
|
PaneEvent::RemoteRepoNavigated {
|
|
host_id,
|
|
indexed_path,
|
|
} => {
|
|
ctx.emit(Event::RemoteRepoNavigated {
|
|
host_id: host_id.clone(),
|
|
indexed_path: indexed_path.clone(),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The current pane group title, based on the focused pane.
|
|
pub(crate) fn title(&self, ctx: &AppContext) -> String {
|
|
self.focused_pane_content(ctx)
|
|
.map(|pane| pane.pane_configuration().as_ref(ctx).title().to_owned())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
/// The resolved display title for this pane group —
|
|
/// custom title if set, otherwise the focused pane's title.
|
|
pub fn display_title(&self, ctx: &AppContext) -> String {
|
|
self.custom_title(ctx).unwrap_or_else(|| self.title(ctx))
|
|
}
|
|
|
|
/// The tab-level custom title, if one has been set via the rename-tab flow.
|
|
pub fn custom_title(&self, _ctx: &AppContext) -> Option<String> {
|
|
self.custom_title.clone()
|
|
}
|
|
|
|
/// The original title of the active terminal session (without custom title override).
|
|
/// This returns the title that would be displayed if no custom title was set.
|
|
pub fn original_title(&self, ctx: &AppContext) -> Option<String> {
|
|
self.active_session_view(ctx)
|
|
.map(|view| {
|
|
let model = view.as_ref(ctx).model.lock();
|
|
model
|
|
.terminal_title()
|
|
.or_else(|| Some(model.shell_launch_state().display_name().to_string()))
|
|
})
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
pub fn set_title(&mut self, title: &str, ctx: &mut ViewContext<Self>) {
|
|
self.custom_title = Some(title.to_string()).filter(|t| !t.is_empty());
|
|
|
|
// refocus on the focused pane
|
|
if let Some(pane) = self.focused_pane_content(ctx) {
|
|
pane.focus(ctx);
|
|
}
|
|
}
|
|
|
|
pub fn clear_title(&mut self, ctx: &mut ViewContext<Self>) {
|
|
self.custom_title = None;
|
|
|
|
// refocus on the focused pane
|
|
if let Some(pane) = self.focused_pane_content(ctx) {
|
|
pane.focus(ctx);
|
|
}
|
|
}
|
|
|
|
fn close_active_pane_with_confirmation(&mut self, ctx: &mut ViewContext<Self>) {
|
|
if self.focused_pane_id(ctx).is_code_pane() {
|
|
// If focused on a CodePane, close its active editor tab (optionally, the entire pane if it only has 1 tab).
|
|
if let Some(code_view) = self.code_view_from_pane_id(self.focused_pane_id(ctx), ctx) {
|
|
code_view.update(ctx, |view, ctx| {
|
|
let index = view.active_tab_index();
|
|
view.handle_action(&CodeViewAction::RemoveTabAtIndex { index }, ctx);
|
|
});
|
|
} else {
|
|
self.close_pane_with_confirmation(self.focused_pane_id(ctx), ctx);
|
|
}
|
|
} else {
|
|
self.close_pane_with_confirmation(self.focused_pane_id(ctx), ctx);
|
|
}
|
|
}
|
|
|
|
pub fn add_pane_as_hidden(
|
|
&mut self,
|
|
pane: Box<dyn AnyPaneContent>,
|
|
direction: Direction,
|
|
ctx: &mut ViewContext<Self>,
|
|
) {
|
|
// Since we are hiding the pane before adding to the tree, use the requested
|
|
// direction for the temporary preview split without affecting focus.
|
|
let _ = self.add_pane_with_options(
|
|
pane,
|
|
AddPaneOptions {
|
|
direction,
|
|
base_pane_id: None,
|
|
focus_new_pane: false,
|
|
visibility: NewPaneVisibility::HiddenForMove,
|
|
emit_app_state_changed: true,
|
|
},
|
|
ctx,
|
|
);
|
|
}
|
|
|
|
/// We return a pane_id if the pane successfully attached
|
|
/// Otherwise, we return None
|
|
pub fn add_pane_for_replacement<C: PaneContent>(
|
|
&mut self,
|
|
pane: C,
|
|
ctx: &mut ViewContext<Self>,
|
|
) -> Option<PaneId> {
|
|
let pane_id = self.init_pane(Box::new(pane), ctx)?;
|
|
ctx.emit(Event::AppStateChanged);
|
|
Some(pane_id)
|
|
}
|
|
|
|
pub fn hide_pane_for_move(&mut self, id: PaneId, ctx: &mut ViewContext<Self>) {
|
|
self.panes.hide_pane_for_move(id);
|
|
|
|
ctx.notify();
|
|
ctx.emit(Event::TerminalViewStateChanged);
|
|
ctx.emit(Event::AppStateChanged);
|
|
}
|
|
|
|
/// Hide a pane for the purposes of running some hidden work. For example, uploading a file to a
|
|
/// remote session.
|
|
pub fn hide_pane_for_job(&mut self, id: PaneId, ctx: &mut ViewContext<Self>) {
|
|
self.panes.hide_pane_for_job(id);
|
|
|
|
ctx.notify();
|
|
ctx.emit(Event::TerminalViewStateChanged);
|
|
ctx.emit(Event::AppStateChanged);
|
|
}
|
|
|
|
/// Show a pane that was running some job. Undoes `PaneGroup::hide_pane_for_job`.
|
|
pub fn show_pane_for_job(&mut self, id: PaneId, ctx: &mut ViewContext<Self>) {
|
|
self.panes.show_pane_for_job(id);
|
|
|
|
ctx.notify();
|
|
ctx.emit(Event::TerminalViewStateChanged);
|
|
ctx.emit(Event::AppStateChanged);
|
|
}
|
|
|
|
/// Toggles the visibility of a pane running some job and returns its new state:
|
|
/// `true` if the pane is now visible, and `false` if it's now hidden.
|
|
pub fn toggle_pane_visibility_for_job(
|
|
&mut self,
|
|
id: PaneId,
|
|
ctx: &mut ViewContext<Self>,
|
|
) -> bool {
|
|
let pane_open = self.panes.toggle_pane_visibility_for_job(id);
|
|
|
|
ctx.notify();
|
|
ctx.emit(Event::TerminalViewStateChanged);
|
|
ctx.emit(Event::AppStateChanged);
|
|
|
|
pane_open
|
|
}
|
|
|
|
/// Hide a pane for close/undo functionality without removing it from the tree.
|
|
fn hide_closed_pane(&mut self, id: PaneId, ctx: &mut ViewContext<Self>) {
|
|
self.panes.hide_closed_pane(id);
|
|
|
|
ctx.notify();
|
|
ctx.emit(Event::TerminalViewStateChanged);
|
|
ctx.emit(Event::AppStateChanged);
|
|
}
|
|
|
|
/// Show a pane that was hidden for close. Used for undo functionality.
|
|
/// Returns true if the pane was successfully shown, false if it wasn't found.
|
|
fn unhide_closed_pane(&mut self, id: PaneId, ctx: &mut ViewContext<Self>) -> bool {
|
|
let success = self.panes.unhide_closed_pane(id);
|
|
if success {
|
|
ctx.notify();
|
|
ctx.emit(Event::TerminalViewStateChanged);
|
|
ctx.emit(Event::AppStateChanged);
|
|
}
|
|
|
|
success
|
|
}
|
|
|
|
/// Clear all panes that were hidden due to being closed (for undo functionality)
|
|
/// This is typically called when starting pane rearrangement operations
|
|
fn clear_hidden_closed_panes(&mut self, ctx: &mut ViewContext<Self>) {
|
|
let closed_pane_ids = self.panes.get_closed_pane_ids();
|
|
for pane_id in closed_pane_ids {
|
|
self.cleanup_closed_pane(pane_id, ctx);
|
|
}
|
|
}
|
|
|
|
/// Clean up a close-hidden pane completely (used when grace period expires)
|
|
/// Returns true if the pane was successfully cleaned up, false if it was already cleaned up
|
|
pub fn cleanup_closed_pane(&mut self, pane_id: PaneId, ctx: &mut ViewContext<Self>) -> bool {
|
|
self.panes.remove_hidden_pane(pane_id);
|
|
|
|
let Some(pane_data) = self.pane_contents.get(&pane_id) else {
|
|
return false;
|
|
};
|
|
|
|
let pane = pane_data.as_pane();
|
|
pane.detach(self, DetachType::Closed, ctx);
|
|
|
|
if !self.panes.remove(pane_id) {
|
|
log::warn!("Attempted to cleanup pane {pane_id} but it was not found in the tree");
|
|
}
|
|
self.pane_contents.remove(&pane_id);
|
|
|
|
ctx.notify();
|
|
ctx.emit(Event::TerminalViewStateChanged);
|
|
ctx.emit(Event::AppStateChanged);
|
|
|
|
true
|
|
}
|
|
|
|
/// Restore a pane that was closed by showing it, attaching it, and focusing it.
|
|
/// Returns true if the pane was successfully restored, false otherwise.
|
|
pub fn restore_closed_pane(&mut self, pane_id: PaneId, ctx: &mut ViewContext<Self>) -> bool {
|
|
if self.unhide_closed_pane(pane_id, ctx) {
|
|
if let Some(pane_content) = self
|
|
.pane_contents
|
|
.get(&pane_id)
|
|
.map(|content| content.as_ref())
|
|
{
|
|
if !self.try_attach_pane(pane_content, ctx) {
|
|
self.cleanup_closed_pane(pane_id, ctx);
|
|
return false;
|
|
}
|
|
|
|
self.focus_pane_and_record_in_history(pane_id, ctx);
|
|
|
|
ctx.emit(Event::TerminalViewStateChanged);
|
|
ctx.emit(Event::AppStateChanged);
|
|
return true;
|
|
}
|
|
}
|
|
false
|
|
}
|
|
|
|
/// If the given pane id exists in this pane group, performs a root split in the given direction
|
|
/// to move it to a new location.
|
|
pub fn move_pane_with_root_split(
|
|
&mut self,
|
|
id: PaneId,
|
|
direction: Direction,
|
|
ctx: &mut ViewContext<Self>,
|
|
) {
|
|
self.panes.clear_hidden_panes_from_move();
|
|
// Also clear hidden closed panes since rearranging invalidates undo functionality
|
|
self.clear_hidden_closed_panes(ctx);
|
|
|
|
if !self.panes.remove(id) {
|
|
log::error!("Pane not found when attempting to move");
|
|
return;
|
|
}
|
|
|
|
self.panes.split_root(id, direction);
|
|
self.handle_pane_count_change(ctx);
|
|
ctx.notify();
|
|
ctx.emit(Event::TerminalViewStateChanged);
|
|
ctx.emit(Event::AppStateChanged);
|
|
}
|
|
|
|
pub fn move_pane(
|
|
&mut self,
|
|
id: PaneId,
|
|
target_pane_id: PaneId,
|
|
direction: Direction,
|
|
ctx: &mut ViewContext<Self>,
|
|
) {
|
|
// Before we do a move, clear any hidden panes
|
|
self.panes.clear_hidden_panes_from_move();
|
|
// Also clear hidden closed panes since rearranging invalidates undo functionality
|
|
self.clear_hidden_closed_panes(ctx);
|
|
|
|
self.panes.move_pane(id, target_pane_id, direction);
|
|
|
|
self.handle_pane_count_change(ctx);
|
|
ctx.notify();
|
|
ctx.emit(Event::TerminalViewStateChanged);
|
|
ctx.emit(Event::AppStateChanged);
|
|
}
|
|
|
|
/// Returns the ID of the pane immediately previous to the given view
|
|
///
|
|
/// Will retrieve from the history of selected panes when available
|
|
fn prev_pane_id(&self, current_pane: PaneId) -> Option<PaneId> {
|
|
let pane_ids = self.panes.pane_ids();
|
|
|
|
let candidate = pane_ids
|
|
.iter()
|
|
.position(|pane_id| *pane_id == current_pane)
|
|
.and_then(|pane_idx| {
|
|
let history_len = self.pane_history.len();
|
|
if history_len > 2 {
|
|
// We have enough history, use the previous value.
|
|
let prev_idx = history_len - 2;
|
|
self.pane_history.get(prev_idx).copied()
|
|
} else if pane_idx == 0 {
|
|
// We have limited history and are focused on the first pane, focus the session to the right/down.
|
|
pane_ids.get(1).copied()
|
|
} else {
|
|
// We have limited history and are focused on a different pane, focus the session to the left/up.
|
|
pane_ids.get(pane_idx - 1).copied()
|
|
}
|
|
});
|
|
|
|
if let Some(id) = candidate {
|
|
if self.has_pane_id(id) && !self.is_pane_hidden_for_close(id) {
|
|
return Some(id);
|
|
}
|
|
}
|
|
|
|
// Fall back to the most recently focused pane that still exists and is visible.
|
|
self.pane_history
|
|
.iter()
|
|
.rfind(|&&id| {
|
|
id != current_pane && self.has_pane_id(id) && !self.is_pane_hidden_for_close(id)
|
|
})
|
|
.copied()
|
|
}
|
|
|
|
/// Returns of the ID of the previous pane, like iTerm does
|
|
/// Specifically used in the navigate_prev_pane function
|
|
fn prev_pane_id_navigation(&self, current_pane: PaneId) -> Option<PaneId> {
|
|
let pane_ids = self.panes.visible_pane_ids();
|
|
if pane_ids.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
match pane_ids.iter().position(|pane_id| *pane_id == current_pane) {
|
|
Some(0) => pane_ids.last().copied(),
|
|
Some(idx) => pane_ids.get(idx - 1).copied(),
|
|
None => None,
|
|
}
|
|
}
|
|
|
|
/// Choose a new active session pane, to handle the current one closing.
|
|
///
|
|
/// This returns the most-recently-focused terminal pane in the pane navigation history. If
|
|
/// there isn't one (for example, because the tab was created from a launch configuration and
|
|
/// some panes haven't been focused yet), it will instead search for the closest terminal pane
|
|
/// to the previous active session, first to the left/up and then to the right/down.
|
|
fn choose_active_session(&self, closing_session_pane: PaneId) -> Option<TerminalPaneId> {
|
|
if let Some(terminal_pane) = self
|
|
.pane_history
|
|
.iter()
|
|
.rev()
|
|
// Don't re-activate the pane being closed.
|
|
.filter(|pane_id| **pane_id != closing_session_pane)
|
|
.filter(|pane_id| {
|
|
self.has_pane_id(**pane_id) && !self.is_pane_hidden_for_close(**pane_id)
|
|
})
|
|
.find_map(PaneId::as_terminal_pane_id)
|
|
{
|
|
return Some(terminal_pane);
|
|
}
|
|
|
|
// In most cases, the next active session will be in `pane_history`. However, if the pane
|
|
// group was created from a launch configuration or restored session, it might have
|
|
// terminal panes that haven't been focused yet and therefore aren't in `pane_history`. In
|
|
// that case, we fall back to searching by position.
|
|
let pane_ids = self.panes.visible_pane_ids();
|
|
let pane_idx = pane_ids
|
|
.iter()
|
|
.position(|pane_id| *pane_id == closing_session_pane)?;
|
|
|
|
// If there's not enough history, prefer activating a session to the left/up.
|
|
if let Some(terminal_pane) = pane_ids
|
|
.iter()
|
|
.take(pane_idx)
|
|
.rev()
|
|
.find_map(PaneId::as_terminal_pane_id)
|
|
{
|
|
return Some(terminal_pane);
|
|
}
|
|
|
|
// Finally, fall back to a a session that's to the right/down.
|
|
pane_ids
|
|
.iter()
|
|
.skip(pane_idx + 1)
|
|
.find_map(PaneId::as_terminal_pane_id)
|
|
}
|
|
|
|
/// Returns the ID of the pane immediately after the given view
|
|
///
|
|
/// Will wrap around to the first pane if the given view is the last pane
|
|
fn next_pane_id(&self, current_pane: PaneId) -> Option<PaneId> {
|
|
let pane_ids = self.panes.visible_pane_ids();
|
|
if pane_ids.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
let last_position = pane_ids.len() - 1;
|
|
|
|
match pane_ids.iter().position(|pane_id| *pane_id == current_pane) {
|
|
Some(idx) if idx == last_position => pane_ids.first().copied(),
|
|
Some(idx) => pane_ids.get(idx + 1).copied(),
|
|
None => None,
|
|
}
|
|
}
|
|
|
|
fn navigate_prev_pane(&mut self, ctx: &mut ViewContext<Self>) {
|
|
if let Some(id) = self.prev_pane_id_navigation(self.focused_pane_id(ctx)) {
|
|
if self.focus_pane(id, true, ctx) {
|
|
ctx.emit(Event::AppStateChanged);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn navigate_next_pane(&mut self, ctx: &mut ViewContext<Self>) {
|
|
if let Some(id) = self.next_pane_id(self.focused_pane_id(ctx)) {
|
|
if self.focus_pane(id, true, ctx) {
|
|
ctx.emit(Event::AppStateChanged);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn navigate_pane_by_direction(&mut self, direction: Direction, ctx: &mut ViewContext<Self>) {
|
|
let ids = self
|
|
.panes
|
|
.panes_by_direction(self.focused_pane_id(ctx), direction, ctx);
|
|
if !ids.is_empty() {
|
|
// If there is more than one candidate pane in the direction of travel, pick the one that was most recently
|
|
// focused. This makes a better experience when navigating back and forth between two panes.
|
|
let recent_id = self
|
|
.pane_history
|
|
.iter()
|
|
.rfind(|id| ids.contains(*id))
|
|
.unwrap_or_else(|| &ids[0]);
|
|
self.focus_pane_and_record_in_history(*recent_id, ctx);
|
|
ctx.emit(Event::AppStateChanged);
|
|
}
|
|
}
|
|
|
|
/// Whether or not the focused pane is maximized.
|
|
pub fn is_focused_pane_maximized(&self, ctx: &AppContext) -> bool {
|
|
self.focus_state.as_ref(ctx).is_focused_pane_maximized()
|
|
}
|
|
|
|
pub fn focused_shell_indicator_type(&self, ctx: &AppContext) -> Option<ShellIndicatorType> {
|
|
self.pane_contents
|
|
.get(&self.focused_pane_id(ctx))
|
|
.and_then(|pane| pane.as_any().downcast_ref::<TerminalPane>())
|
|
.and_then(|terminal_pane| {
|
|
terminal_pane
|
|
.terminal_view(ctx)
|
|
.as_ref(ctx)
|
|
.shell_indicator_type()
|
|
})
|
|
}
|
|
|
|
/// Toggles whether or not the focused pane is maximized.
|
|
fn toggle_maximize_pane(&mut self, ctx: &mut ViewContext<Self>) {
|
|
if self.pane_count() > 1 {
|
|
self.focus_state.update(ctx, |focus_state, ctx| {
|
|
focus_state.toggle_focused_pane_maximized(ctx);
|
|
});
|
|
ctx.notify();
|
|
ctx.emit(Event::MaximizePaneToggled);
|
|
}
|
|
}
|
|
|
|
fn focus_pane_on_mouse_event(
|
|
&mut self,
|
|
id: PaneId,
|
|
reason: ActivationReason,
|
|
ctx: &mut ViewContext<Self>,
|
|
) {
|
|
let Some(content) = self.pane_contents.get(&id) else {
|
|
return;
|
|
};
|
|
|
|
if matches!(reason, ActivationReason::Hover) {
|
|
if !ctx.windows().app_is_active() {
|
|
// Don't focus panes on hover if the app is not active.
|
|
return;
|
|
}
|
|
|
|
if self.is_being_resized() || self.any_pane_being_dragged(ctx) {
|
|
// Don't focus panes on hover if the app is being resized or a pane is being dragged.
|
|
return;
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
{
|
|
// if the app is active, but the window is not active, activate the target window.
|
|
let current_window_id: WindowId = ctx.window_id();
|
|
let active_window_id = ctx.windows().state().active_window;
|
|
if active_window_id != Some(current_window_id) {
|
|
ctx.windows()
|
|
.show_window_and_focus_app_without_ordering_front(current_window_id);
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(session) = content.as_any().downcast_ref::<TerminalPane>() {
|
|
// Only activate the session if link tooltip is disabled or there is no highlighted link.
|
|
if *GeneralSettings::as_ref(ctx).link_tooltip
|
|
&& session
|
|
.terminal_view(ctx)
|
|
.as_ref(ctx)
|
|
.has_highlighted_link()
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
|
|
self.focus_pane_by_id(id, ctx);
|
|
}
|
|
|
|
pub fn focus_pane_by_id(&mut self, id: PaneId, ctx: &mut ViewContext<Self>) {
|
|
// If user clicks on a pane quickly after dragging the border, a race condition
|
|
// could happen where the mouse down movement is considered as part of dragging.
|
|
// We clear the dragging state here to avoid such conditions.
|
|
self.dragged_border = None;
|
|
if self.focus_pane_and_record_in_history(id, ctx) {
|
|
ctx.emit(Event::AppStateChanged);
|
|
ctx.emit(Event::PaneFocused);
|
|
}
|
|
}
|
|
|
|
/// Focused the specified terminal view, if it belongs to this pane group.
|
|
pub fn focus_terminal_view(&mut self, terminal_view_id: EntityId, ctx: &mut ViewContext<Self>) {
|
|
let pane_id = self
|
|
.pane_contents
|
|
.keys()
|
|
.find(|id| {
|
|
if let Some(terminal_view) = self.terminal_view_from_pane_id(**id, ctx) {
|
|
terminal_view_id == terminal_view.id()
|
|
} else {
|
|
false
|
|
}
|
|
})
|
|
.cloned();
|
|
|
|
if let Some(pane_id) = pane_id {
|
|
self.focus_pane_by_id(pane_id, ctx);
|
|
}
|
|
}
|
|
|
|
/// Show a notification error for the pane that we tried to send a notification for.
|
|
pub fn show_notification_error(
|
|
&mut self,
|
|
error: NotificationSendError,
|
|
pane_id: PaneId,
|
|
ctx: &mut ViewContext<Self>,
|
|
) {
|
|
if let Some(view) = self.terminal_view_from_pane_id(pane_id, ctx) {
|
|
view.update(ctx, |view, ctx| {
|
|
view.show_notification_error(error, ctx);
|
|
})
|
|
}
|
|
}
|
|
|
|
pub fn is_being_resized(&self) -> bool {
|
|
self.dragged_border.is_some()
|
|
}
|
|
|
|
// The drag event pertains to the divider being dragged between panes.
|
|
// If there's no active dragging state on the pane, the event is propagated up
|
|
// to workspace in case there the sidebar divider is being dragged.
|
|
fn maybe_resize_pane(&mut self, position: Vector2F, ctx: &mut ViewContext<Self>) {
|
|
if self.dragged_border.is_some() {
|
|
self.resize_pane(position, ctx);
|
|
}
|
|
}
|
|
|
|
fn resize_pane(&mut self, position: Vector2F, ctx: &mut ViewContext<Self>) {
|
|
if let Some(border) = &mut self.dragged_border {
|
|
let delta = match border.direction {
|
|
SplitDirection::Horizontal => position.x() - border.previous_mouse_location.x(),
|
|
SplitDirection::Vertical => position.y() - border.previous_mouse_location.y(),
|
|
};
|
|
|
|
self.panes.adjust_pane_size(border.border_id, delta, ctx);
|
|
|
|
border.previous_mouse_location = position;
|
|
ctx.notify();
|
|
}
|
|
}
|
|
|
|
pub fn start_resizing(&mut self, info: DraggedBorder, ctx: &mut ViewContext<Self>) {
|
|
// Clear hidden closed panes since resizing invalidates undo functionality
|
|
self.clear_hidden_closed_panes(ctx);
|
|
self.dragged_border = Some(info);
|
|
}
|
|
|
|
pub fn end_resizing(&mut self, ctx: &mut ViewContext<Self>) {
|
|
self.dragged_border = None;
|
|
ctx.emit(Event::AppStateChanged);
|
|
}
|
|
|
|
pub fn resize_left(&mut self, ctx: &mut ViewContext<Self>) {
|
|
self.panes.adjust_pane_size_by_id(
|
|
self.focused_pane_id(ctx),
|
|
SplitDirection::Horizontal,
|
|
-KEYBOARD_RESIZE_DELTA,
|
|
ctx,
|
|
);
|
|
ctx.notify();
|
|
ctx.emit(Event::AppStateChanged);
|
|
}
|
|
|
|
pub fn resize_right(&mut self, ctx: &mut ViewContext<Self>) {
|
|
self.panes.adjust_pane_size_by_id(
|
|
self.focused_pane_id(ctx),
|
|
SplitDirection::Horizontal,
|
|
KEYBOARD_RESIZE_DELTA,
|
|
ctx,
|
|
);
|
|
ctx.notify();
|
|
ctx.emit(Event::AppStateChanged);
|
|
}
|
|
|
|
pub fn resize_up(&mut self, ctx: &mut ViewContext<Self>) {
|
|
self.panes.adjust_pane_size_by_id(
|
|
self.focused_pane_id(ctx),
|
|
SplitDirection::Vertical,
|
|
-KEYBOARD_RESIZE_DELTA,
|
|
ctx,
|
|
);
|
|
ctx.notify();
|
|
ctx.emit(Event::AppStateChanged);
|
|
}
|
|
|
|
pub fn resize_down(&mut self, ctx: &mut ViewContext<Self>) {
|
|
self.panes.adjust_pane_size_by_id(
|
|
self.focused_pane_id(ctx),
|
|
SplitDirection::Vertical,
|
|
KEYBOARD_RESIZE_DELTA,
|
|
ctx,
|
|
);
|
|
ctx.notify();
|
|
ctx.emit(Event::AppStateChanged);
|
|
}
|
|
|
|
fn handle_user_default_shell_changed_banner_event(
|
|
&mut self,
|
|
event: &BannerEvent<PaneGroupAction>,
|
|
ctx: &mut ViewContext<Self>,
|
|
) {
|
|
match event {
|
|
BannerEvent::Dismiss(DismissalType::Temporary) => {
|
|
self.user_default_shell_unsupported_banner_model_handle
|
|
.update(ctx, |model, model_ctx| {
|
|
*model = BannerState::Dismissed;
|
|
model_ctx.notify();
|
|
});
|
|
}
|
|
BannerEvent::Dismiss(DismissalType::Permanent) => {
|
|
self.user_default_shell_unsupported_banner_model_handle
|
|
.update(ctx, |model, model_ctx| {
|
|
*model = BannerState::Dismissed;
|
|
model_ctx.notify();
|
|
});
|
|
|
|
GeneralSettings::handle(ctx).update(ctx, |general_settings, ctx| {
|
|
report_if_error!(general_settings
|
|
.user_default_shell_unsupported_banner_state
|
|
.set_value(BannerState::Dismissed, ctx));
|
|
});
|
|
}
|
|
BannerEvent::Action(_) => {
|
|
#[cfg(debug_assertions)]
|
|
unimplemented!("User default shell change banner doesn't support actions");
|
|
}
|
|
}
|
|
ctx.notify();
|
|
}
|
|
|
|
/// Sync changes in the visible pane count to the [`focus_state::PaneGroupFocusState`] model.
|
|
fn handle_pane_count_change(&mut self, ctx: &mut ViewContext<Self>) {
|
|
let in_split_pane = self.panes.visible_pane_count() > 1;
|
|
self.focus_state.update(ctx, |focus_state, ctx| {
|
|
focus_state.set_in_split_pane(in_split_pane, ctx);
|
|
});
|
|
}
|
|
|
|
// Instantiate the terminal view with the given parameters. Note that the active
|
|
// session path here needs to be a valid os path otherwise the app will crash.
|
|
// Environment variables are merged into the default environment for the terminal process,
|
|
// and do not completely replace it.
|
|
#[allow(clippy::too_many_arguments, unused_variables)]
|
|
fn create_session(
|
|
startup_directory: Option<PathBuf>,
|
|
env_vars: HashMap<OsString, OsString>,
|
|
is_shared_session: IsSharedSessionCreator,
|
|
resources: TerminalViewResources,
|
|
restored_blocks: Option<&Vec<SerializedBlockListItem>>,
|
|
conversation_restoration: Option<ConversationRestorationInNewPaneType>,
|
|
user_default_shell_unsupported_banner_model_handle: ModelHandle<BannerState>,
|
|
initial_size: Vector2F,
|
|
model_event_sender: Option<SyncSender<ModelEvent>>,
|
|
chosen_shell: Option<AvailableShell>,
|
|
initial_input_config: Option<InputConfig>,
|
|
ctx: &mut ViewContext<Self>,
|
|
) -> (
|
|
ViewHandle<TerminalView>,
|
|
ModelHandle<Box<dyn TerminalManager>>,
|
|
) {
|
|
cfg_if::cfg_if! {
|
|
if #[cfg(feature = "remote_tty")] {
|
|
let terminal_manager: ModelHandle<Box<dyn TerminalManager>> = crate::terminal::remote_tty::TerminalManager::create_model(
|
|
resources,
|
|
initial_size,
|
|
model_event_sender,
|
|
ctx.window_id(),
|
|
initial_input_config,
|
|
ctx,
|
|
);
|
|
} else if #[cfg(feature = "local_tty")] {
|
|
let terminal_manager: ModelHandle<Box<dyn TerminalManager>> = crate::terminal::local_tty::TerminalManager::create_model(
|
|
startup_directory,
|
|
env_vars,
|
|
is_shared_session,
|
|
resources,
|
|
restored_blocks,
|
|
conversation_restoration,
|
|
user_default_shell_unsupported_banner_model_handle,
|
|
initial_size,
|
|
model_event_sender,
|
|
ctx.window_id(),
|
|
chosen_shell,
|
|
initial_input_config,
|
|
ctx,
|
|
);
|
|
} else {
|
|
use crate::terminal::{ShellLaunchState, shell::{ShellName, ShellType}};
|
|
|
|
let terminal_manager: ModelHandle<Box<dyn TerminalManager>> = crate::terminal::MockTerminalManager::create_model(
|
|
ShellLaunchState::ShellSpawned {
|
|
available_shell: chosen_shell,
|
|
display_name: ShellName::blank(),
|
|
shell_type: ShellType::Zsh
|
|
},
|
|
resources,
|
|
None,
|
|
conversation_restoration,
|
|
initial_size,
|
|
ctx.window_id(),
|
|
ctx,
|
|
);
|
|
}
|
|
}
|
|
|
|
let terminal_view = terminal_manager.as_ref(ctx).view();
|
|
(terminal_view, terminal_manager)
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn create_shared_session_viewer(
|
|
session_id: SessionId,
|
|
resources: TerminalViewResources,
|
|
initial_size: Vector2F,
|
|
ctx: &mut ViewContext<Self>,
|
|
) -> (
|
|
ViewHandle<TerminalView>,
|
|
ModelHandle<Box<dyn TerminalManager>>,
|
|
) {
|
|
let window_id = ctx.window_id();
|
|
let terminal_manager = ctx.add_model(|ctx| {
|
|
let terminal_manager: Box<dyn TerminalManager> =
|
|
Box::new(shared_session::viewer::TerminalManager::new(
|
|
session_id,
|
|
resources,
|
|
initial_size,
|
|
window_id,
|
|
ctx,
|
|
));
|
|
terminal_manager
|
|
});
|
|
|
|
let terminal_view = terminal_manager.as_ref(ctx).view();
|
|
(terminal_view, terminal_manager)
|
|
}
|
|
|
|
fn create_conversation_viewer(
|
|
conversation: AIConversation,
|
|
ambient_agent_task_id: Option<AmbientAgentTaskId>,
|
|
resources: TerminalViewResources,
|
|
initial_size: Vector2F,
|
|
ctx: &mut ViewContext<Self>,
|
|
) -> (
|
|
ViewHandle<TerminalView>,
|
|
ModelHandle<Box<dyn TerminalManager>>,
|
|
) {
|
|
let restored_blocks = conversation.to_serialized_blocklist_items();
|
|
let terminal_manager = MockTerminalManager::create_model(
|
|
ShellLaunchState::ShellSpawned {
|
|
available_shell: None,
|
|
display_name: ShellName::blank(),
|
|
shell_type: ShellType::Zsh,
|
|
},
|
|
resources,
|
|
Some(&restored_blocks),
|
|
Some(ConversationRestorationInNewPaneType::Historical {
|
|
conversation,
|
|
should_use_live_appearance: true,
|
|
ambient_agent_task_id,
|
|
}),
|
|
initial_size,
|
|
ctx.window_id(),
|
|
ctx,
|
|
);
|
|
// Set the conversation viewer status based on whether this is an ambient agent conversation
|
|
let viewer_status = ambient_agent_task_id
|
|
.map(ConversationTranscriptViewerStatus::ViewingAmbientConversation)
|
|
.unwrap_or(ConversationTranscriptViewerStatus::ViewingLocalConversation);
|
|
|
|
terminal_manager.update(ctx, |terminal_manager, _ctx| {
|
|
terminal_manager
|
|
.model()
|
|
.lock()
|
|
.set_conversation_transcript_viewer_status(Some(viewer_status.clone()));
|
|
});
|
|
|
|
let terminal_view = terminal_manager.as_ref(ctx).view();
|
|
// Insert the conversation ended tombstone (includes Open in Warp button on WASM)
|
|
terminal_view.update(ctx, |view, ctx| {
|
|
view.insert_conversation_ended_tombstone(ctx);
|
|
});
|
|
|
|
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, _ctx| {
|
|
history_model.mark_terminal_view_as_conversation_transcript_viewer(terminal_view.id());
|
|
});
|
|
|
|
// Register the transcript viewer as an ambient session so it appears in the Active section
|
|
// of the conversation list.
|
|
if let Some(task_id) = ambient_agent_task_id {
|
|
ActiveAgentViewsModel::handle(ctx).update(ctx, |active_views, ctx| {
|
|
active_views.register_ambient_session(terminal_view.id(), task_id, ctx);
|
|
});
|
|
}
|
|
|
|
(terminal_view, terminal_manager)
|
|
}
|
|
|
|
/// Creates a loading terminal view with MockTerminalManager in loading state.
|
|
/// This is used by both `new_for_conversation_transcript_viewer_loading` and `create_loading_terminal_pane`.
|
|
fn create_loading_terminal_manager_and_view(
|
|
resources: TerminalViewResources,
|
|
view_bounds_size: Vector2F,
|
|
window_id: WindowId,
|
|
ctx: &mut ViewContext<Self>,
|
|
) -> (
|
|
ViewHandle<TerminalView>,
|
|
ModelHandle<Box<dyn TerminalManager>>,
|
|
) {
|
|
let terminal_manager = MockTerminalManager::create_model(
|
|
ShellLaunchState::ShellSpawned {
|
|
available_shell: None,
|
|
display_name: ShellName::blank(),
|
|
shell_type: ShellType::Zsh,
|
|
},
|
|
resources,
|
|
None, // No restored blocks
|
|
None, // No conversation restoration
|
|
view_bounds_size,
|
|
window_id,
|
|
ctx,
|
|
);
|
|
|
|
// Set the conversation transcript viewer status to Loading
|
|
terminal_manager.update(ctx, |terminal_manager, _ctx| {
|
|
terminal_manager
|
|
.model()
|
|
.lock()
|
|
.set_conversation_transcript_viewer_status(Some(
|
|
ConversationTranscriptViewerStatus::Loading,
|
|
));
|
|
});
|
|
|
|
let terminal_view = terminal_manager.as_ref(ctx).view();
|
|
(terminal_view, terminal_manager)
|
|
}
|
|
|
|
/// Whether to use the user-specified startup directory when starting
|
|
/// a new session. On Windows, we ignore this custom directory setting in
|
|
/// WSL sessions. On all other systems, we honor the custom directory.
|
|
#[cfg(feature = "local_tty")]
|
|
fn should_ignore_custom_startup_directory(
|
|
&self,
|
|
chosen_shell: &Option<AvailableShell>,
|
|
ctx: &ViewContext<Self>,
|
|
) -> bool {
|
|
let wsl_distro = chosen_shell
|
|
.to_owned()
|
|
.unwrap_or_else(move || {
|
|
AvailableShells::handle(ctx)
|
|
.read(ctx, |shells, ctx| shells.get_user_preferred_shell(ctx))
|
|
})
|
|
.wsl_distro();
|
|
wsl_distro.is_some()
|
|
}
|
|
|
|
#[cfg(not(feature = "local_tty"))]
|
|
const fn should_ignore_custom_startup_directory(
|
|
&self,
|
|
_chosen_shell: &Option<AvailableShell>,
|
|
_ctx: &ViewContext<Self>,
|
|
) -> bool {
|
|
false
|
|
}
|
|
|
|
/// Creates a loading terminal pane that shows a spinner while conversation data is being fetched.
|
|
/// Returns the pane ID so it can be replaced later with the real terminal pane.
|
|
pub fn add_loading_conversation_pane(
|
|
&mut self,
|
|
direction: Direction,
|
|
base_pane_id: Option<PaneId>,
|
|
ctx: &mut ViewContext<Self>,
|
|
) -> PaneId {
|
|
let uuid = Uuid::new_v4();
|
|
let resources = TerminalViewResources {
|
|
tips_completed: self.tips_completed.clone(),
|
|
server_api: self.server_api.clone(),
|
|
model_event_sender: self.model_event_sender.clone(),
|
|
};
|
|
|
|
let view_bounds = Self::estimated_view_bounds(ctx);
|
|
let (terminal_view, terminal_manager) = Self::create_loading_terminal_manager_and_view(
|
|
resources,
|
|
view_bounds.size(),
|
|
ctx.window_id(),
|
|
ctx,
|
|
);
|
|
|
|
let pane_data = TerminalPane::new(
|
|
uuid.as_bytes().to_vec(),
|
|
terminal_manager,
|
|
terminal_view,
|
|
self.model_event_sender.clone(),
|
|
ctx,
|
|
);
|
|
let pane_id: PaneId = pane_data.terminal_pane_id().into();
|
|
|
|
let _ = self.add_pane(direction, base_pane_id, Box::new(pane_data), true, ctx);
|
|
|
|
pane_id
|
|
}
|
|
|
|
/// Replaces a loading pane with a real terminal pane that has a conversation restored.
|
|
/// Returns true if replacement was successful.
|
|
pub fn replace_loading_pane_with_terminal(
|
|
&mut self,
|
|
loading_pane_id: PaneId,
|
|
cloud_conversation: CloudConversationData,
|
|
ctx: &mut ViewContext<Self>,
|
|
) -> bool {
|
|
let restoration = match cloud_conversation {
|
|
CloudConversationData::Oz(conversation) => {
|
|
ConversationRestorationInNewPaneType::Historical {
|
|
conversation: *conversation,
|
|
should_use_live_appearance: true,
|
|
ambient_agent_task_id: None,
|
|
}
|
|
}
|
|
CloudConversationData::CLIAgent(cli_conversation) => {
|
|
if !FeatureFlag::AgentHarness.is_enabled() {
|
|
log::warn!("AgentHarness flag is disabled; ignoring CLI agent conversation");
|
|
return false;
|
|
}
|
|
ConversationRestorationInNewPaneType::HistoricalCLIAgent {
|
|
conversation: *cli_conversation,
|
|
should_use_live_appearance: true,
|
|
}
|
|
}
|
|
};
|
|
|
|
// Get the initial working directory from the restored conversation.
|
|
let startup_directory = restoration
|
|
.initial_working_directory()
|
|
.map(PathBuf::from)
|
|
.filter(|path| path.is_dir());
|
|
|
|
let uuid = Uuid::new_v4();
|
|
let resources = TerminalViewResources {
|
|
tips_completed: self.tips_completed.clone(),
|
|
server_api: self.server_api.clone(),
|
|
model_event_sender: self.model_event_sender.clone(),
|
|
};
|
|
|
|
let view_bounds = Self::estimated_view_bounds(ctx);
|
|
let (view, terminal_manager) = PaneGroup::create_session(
|
|
startup_directory,
|
|
HashMap::new(),
|
|
IsSharedSessionCreator::No,
|
|
resources,
|
|
None,
|
|
Some(restoration),
|
|
self.user_default_shell_unsupported_banner_model_handle
|
|
.clone(),
|
|
view_bounds.size(),
|
|
self.model_event_sender.clone(),
|
|
None, // chosen_shell
|
|
None, // initial_input_config
|
|
ctx,
|
|
);
|
|
|
|
let terminal_view_id = view.id();
|
|
let pane_data = TerminalPane::new(
|
|
uuid.as_bytes().to_vec(),
|
|
terminal_manager,
|
|
view,
|
|
self.model_event_sender.clone(),
|
|
ctx,
|
|
);
|
|
|
|
// Use replace_pane to swap loading pane with new terminal pane
|
|
let success = self.replace_pane(loading_pane_id, pane_data, false, ctx);
|
|
|
|
// The new terminal view was created before pane-group subscriptions
|
|
// were set up, so scan its conversations for child agent panes now.
|
|
if success {
|
|
let new_pane_id = self
|
|
.find_pane_id_for_terminal_view(terminal_view_id, ctx)
|
|
.unwrap_or(loading_pane_id);
|
|
self.create_missing_child_agent_panes(new_pane_id, ctx);
|
|
}
|
|
|
|
success
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn add_session(
|
|
&mut self,
|
|
direction: Direction,
|
|
base_pane_id_for_split: Option<PaneId>,
|
|
base_pane_id_for_context: Option<TerminalPaneId>,
|
|
chosen_shell: Option<AvailableShell>,
|
|
conversation_restoration: Option<ConversationRestorationInNewPaneType>,
|
|
ctx: &mut ViewContext<Self>,
|
|
) -> TerminalPaneId {
|
|
self.add_session_with_default_session_mode_behavior(
|
|
direction,
|
|
base_pane_id_for_split,
|
|
base_pane_id_for_context,
|
|
chosen_shell,
|
|
conversation_restoration,
|
|
DefaultSessionModeBehavior::Apply,
|
|
ctx,
|
|
)
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn add_session_with_default_session_mode_behavior(
|
|
&mut self,
|
|
direction: Direction,
|
|
base_pane_id_for_split: Option<PaneId>,
|
|
base_pane_id_for_context: Option<TerminalPaneId>,
|
|
chosen_shell: Option<AvailableShell>,
|
|
conversation_restoration: Option<ConversationRestorationInNewPaneType>,
|
|
default_session_mode_behavior: DefaultSessionModeBehavior,
|
|
ctx: &mut ViewContext<Self>,
|
|
) -> TerminalPaneId {
|
|
// If restoring a conversation, use its initial working directory if it exists
|
|
let startup_directory_from_conversation = conversation_restoration
|
|
.as_ref()
|
|
.and_then(|restoration| restoration.initial_working_directory())
|
|
.map(PathBuf::from)
|
|
.filter(|path| path.is_dir());
|
|
|
|
let startup_directory = startup_directory_from_conversation.or_else(|| {
|
|
let ignore_custom_startup_directory =
|
|
self.should_ignore_custom_startup_directory(&chosen_shell, ctx);
|
|
|
|
let initial_directory_from_current_session =
|
|
self.startup_path_for_new_session(base_pane_id_for_context, ctx);
|
|
|
|
SessionSettings::handle(ctx).read(ctx, |settings, _ctx| {
|
|
settings
|
|
.working_directory_config
|
|
.initial_directory_for_new_session(
|
|
NewSessionSource::SplitPane,
|
|
initial_directory_from_current_session,
|
|
ignore_custom_startup_directory,
|
|
)
|
|
})
|
|
});
|
|
self.add_session_in_directory(
|
|
direction,
|
|
base_pane_id_for_split,
|
|
chosen_shell,
|
|
startup_directory,
|
|
conversation_restoration,
|
|
default_session_mode_behavior,
|
|
ctx,
|
|
)
|
|
}
|
|
|
|
/// Creates a new terminal session and wraps it in a `TerminalPane`.
|
|
/// This is the shared session-creation boilerplate used by both
|
|
/// `add_session_in_directory` and `insert_terminal_pane_hidden_for_child_agent`.
|
|
fn create_terminal_pane_data(
|
|
&self,
|
|
startup_directory: Option<PathBuf>,
|
|
env_vars: HashMap<OsString, OsString>,
|
|
chosen_shell: Option<AvailableShell>,
|
|
conversation_restoration: Option<ConversationRestorationInNewPaneType>,
|
|
ctx: &mut ViewContext<Self>,
|
|
) -> (TerminalPane, ViewHandle<TerminalView>) {
|
|
let uuid = Uuid::new_v4();
|
|
let resources = TerminalViewResources {
|
|
tips_completed: self.tips_completed.clone(),
|
|
server_api: self.server_api.clone(),
|
|
model_event_sender: self.model_event_sender.clone(),
|
|
};
|
|
|
|
let view_bounds = Self::estimated_view_bounds(ctx);
|
|
let (view, terminal_manager) = PaneGroup::create_session(
|
|
startup_directory,
|
|
env_vars,
|
|
IsSharedSessionCreator::No,
|
|
resources,
|
|
None,
|
|
conversation_restoration,
|
|
self.user_default_shell_unsupported_banner_model_handle
|
|
.clone(),
|
|
view_bounds.size(),
|
|
self.model_event_sender.clone(),
|
|
chosen_shell,
|
|
None,
|
|
ctx,
|
|
);
|
|
|
|
let pane_data = TerminalPane::new(
|
|
uuid.as_bytes().to_vec(),
|
|
terminal_manager,
|
|
view.clone(),
|
|
self.model_event_sender.clone(),
|
|
ctx,
|
|
);
|
|
|
|
(pane_data, view)
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn add_session_in_directory(
|
|
&mut self,
|
|
direction: Direction,
|
|
base_pane_id: Option<PaneId>,
|
|
chosen_shell: Option<AvailableShell>,
|
|
startup_directory: Option<PathBuf>,
|
|
conversation_restoration: Option<ConversationRestorationInNewPaneType>,
|
|
default_session_mode_behavior: DefaultSessionModeBehavior,
|
|
ctx: &mut ViewContext<Self>,
|
|
) -> TerminalPaneId {
|
|
let should_immediately_enter_agent_view = matches!(
|
|
default_session_mode_behavior,
|
|
DefaultSessionModeBehavior::Apply
|
|
) && conversation_restoration.is_none()
|
|
&& AISettings::as_ref(ctx).default_session_mode(ctx) == DefaultSessionMode::Agent;
|
|
|
|
let (pane_data, view) = self.create_terminal_pane_data(
|
|
startup_directory,
|
|
HashMap::new(),
|
|
chosen_shell,
|
|
conversation_restoration,
|
|
ctx,
|
|
);
|
|
let new_pane_id = pane_data.terminal_pane_id();
|
|
|
|
let _ = self.add_pane(direction, base_pane_id, Box::new(pane_data), true, ctx);
|
|
|
|
// Enter agent view if default session mode is Agent and AI is enabled
|
|
if should_immediately_enter_agent_view {
|
|
view.update(ctx, |terminal_view, ctx| {
|
|
terminal_view.enter_agent_view_for_new_conversation(
|
|
None,
|
|
AgentViewEntryOrigin::DefaultSessionMode,
|
|
ctx,
|
|
);
|
|
});
|
|
}
|
|
|
|
new_pane_id
|
|
}
|
|
|
|
/// Adds a new side-pane to this group, at the root of the pane tree.
|
|
pub fn add_pane_with_direction<C: PaneContent>(
|
|
&mut self,
|
|
direction: Direction,
|
|
pane: C,
|
|
focus_new_pane: bool,
|
|
ctx: &mut ViewContext<Self>,
|
|
) {
|
|
let _ = self.add_pane(direction, None, Box::new(pane), focus_new_pane, ctx);
|
|
}
|
|
|
|
/// Adds a new pane to this group, relative to an existing pane.
|
|
pub fn add_pane_sibling(
|
|
&mut self,
|
|
relative_to: PaneId,
|
|
direction: Direction,
|
|
pane: impl Into<Box<dyn AnyPaneContent>>,
|
|
focus_new_pane: bool,
|
|
ctx: &mut ViewContext<Self>,
|
|
) {
|
|
let _ = self.add_pane(
|
|
direction,
|
|
Some(relative_to),
|
|
pane.into(),
|
|
focus_new_pane,
|
|
ctx,
|
|
);
|
|
}
|
|
|
|
fn init_pane(
|
|
&mut self,
|
|
pane: Box<dyn AnyPaneContent>,
|
|
ctx: &mut ViewContext<Self>,
|
|
) -> Option<PaneId> {
|
|
let pane_id = pane.as_pane().id();
|
|
self.pane_contents.insert(pane_id, pane);
|
|
// The HashMap entry API would let us insert and then get a mutable reference to the
|
|
// just-added pane. However, this would mean that attach() can't take the pane group
|
|
// as an argument.
|
|
let pane = self
|
|
.pane_contents
|
|
.get(&pane_id)
|
|
.expect("Just inserted pane");
|
|
|
|
if !self.try_attach_pane(pane.as_ref(), ctx) {
|
|
// Remove the pane we didn't end up attaching the pane.
|
|
self.pane_contents.remove(&pane_id);
|
|
return None;
|
|
}
|
|
Some(pane_id)
|
|
}
|
|
|
|
/// Adds a new pane to the tree with configurable visibility/focus/event behavior.
|
|
fn add_pane_with_options(
|
|
&mut self,
|
|
new_pane: Box<dyn AnyPaneContent>,
|
|
options: AddPaneOptions,
|
|
ctx: &mut ViewContext<Self>,
|
|
) -> Option<PaneId> {
|
|
let pane_id = new_pane.as_pane().id();
|
|
match options.visibility {
|
|
NewPaneVisibility::Visible => {}
|
|
NewPaneVisibility::HiddenForMove => self.panes.hide_pane_for_move(pane_id),
|
|
NewPaneVisibility::HiddenForChildAgent => self.panes.hide_pane_for_child_agent(pane_id),
|
|
}
|
|
|
|
let pane_id = self.init_pane(new_pane, ctx)?;
|
|
let split_succeeded = match options.base_pane_id {
|
|
Some(base_pane_id) => self.panes.split(base_pane_id, pane_id, options.direction),
|
|
None => {
|
|
self.panes.split_root(pane_id, options.direction);
|
|
true
|
|
}
|
|
};
|
|
|
|
if !split_succeeded {
|
|
log::error!(
|
|
"Failed to split pane tree when adding pane {:?} relative to {:?}",
|
|
pane_id,
|
|
options.base_pane_id
|
|
);
|
|
self.panes.remove_hidden_pane(pane_id);
|
|
self.clean_up_pane(pane_id, ctx);
|
|
self.pane_contents.remove(&pane_id);
|
|
return None;
|
|
}
|
|
|
|
if options.focus_new_pane {
|
|
self.focus_pane_and_record_in_history(pane_id, ctx);
|
|
}
|
|
|
|
self.handle_pane_count_change(ctx);
|
|
|
|
ctx.notify();
|
|
if options.emit_app_state_changed {
|
|
ctx.emit(Event::AppStateChanged);
|
|
}
|
|
Some(pane_id)
|
|
}
|
|
|
|
/// Adds a new pane to the tree. If `base_pane_id` is `Some`, the new pane is inserted relative
|
|
/// to that pane. Otherwise, it's inserted at the root of the pane tree.
|
|
fn add_pane(
|
|
&mut self,
|
|
direction: Direction,
|
|
base_pane_id: Option<PaneId>,
|
|
new_pane: Box<dyn AnyPaneContent>,
|
|
focus_new_pane: bool,
|
|
ctx: &mut ViewContext<Self>,
|
|
) -> Option<PaneId> {
|
|
if self.pane_count() == 1 {
|
|
// Only sending telemetry event the first time a user enters split pane in a session.
|
|
send_telemetry_from_ctx!(TelemetryEvent::SplitPane, ctx);
|
|
}
|
|
|
|
self.tips_completed.update(ctx, |tips_completed, ctx| {
|
|
mark_feature_used_and_write_to_user_defaults(
|
|
Tip::Action(TipAction::SplitPane),
|
|
tips_completed,
|
|
ctx,
|
|
);
|
|
ctx.notify();
|
|
});
|
|
self.add_pane_with_options(
|
|
new_pane,
|
|
AddPaneOptions {
|
|
direction,
|
|
base_pane_id,
|
|
focus_new_pane,
|
|
visibility: NewPaneVisibility::Visible,
|
|
emit_app_state_changed: true,
|
|
},
|
|
ctx,
|
|
)
|
|
}
|
|
|
|
pub fn pane_count(&self) -> usize {
|
|
self.panes.len()
|
|
}
|
|
|
|
pub fn has_horizontal_split(&self) -> bool {
|
|
self.panes.has_horizontal_split()
|
|
}
|
|
|
|
pub fn try_navigate_next(&mut self, ctx: &mut ViewContext<Self>) -> bool {
|
|
let pane_ids = self.panes.visible_pane_ids();
|
|
if pane_ids.len() <= 1 {
|
|
return false;
|
|
}
|
|
|
|
// Only move to the next pane if we're not already at the last index.
|
|
if let Some(idx) = pane_ids
|
|
.iter()
|
|
.position(|pane_id| *pane_id == self.focused_pane_id(ctx))
|
|
{
|
|
if idx < pane_ids.len() - 1 {
|
|
self.navigate_next_pane(ctx);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
false
|
|
}
|
|
|
|
pub fn try_navigate_prev(&mut self, ctx: &mut ViewContext<Self>) -> bool {
|
|
let pane_ids = self.panes.visible_pane_ids();
|
|
if pane_ids.len() <= 1 {
|
|
return false;
|
|
}
|
|
|
|
// Only move to the previous pane if we're not already at the first index.
|
|
if let Some(idx) = pane_ids
|
|
.iter()
|
|
.position(|pane_id| *pane_id == self.focused_pane_id(ctx))
|
|
{
|
|
if idx > 0 {
|
|
self.navigate_prev_pane(ctx);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
false
|
|
}
|
|
|
|
/// Returns the count of visible panes (excluding hidden panes).
|
|
#[cfg(any(test, feature = "integration_tests"))]
|
|
pub fn visible_pane_count(&self) -> usize {
|
|
self.panes.visible_pane_count()
|
|
}
|
|
|
|
/// Returns the path of the directory in which a newly created session should start, if any.
|
|
/// On Windows, this path will be in native Windows format (including the WSL prefix and
|
|
/// distribution, if applicable).
|
|
///
|
|
/// This returns the active (parent) session's current directory if the active session is local
|
|
/// (not an SSH session) and if the active session is done bootstrapping. Else, it returns the
|
|
/// the current session's startup directory.
|
|
pub fn startup_path_for_new_session(
|
|
&self,
|
|
base_pane_id: Option<TerminalPaneId>,
|
|
ctx: &AppContext,
|
|
) -> Option<PathBuf> {
|
|
let pane_id = base_pane_id?;
|
|
if let Some(current_session_path) = self.session_path(&pane_id, ctx) {
|
|
return Some(current_session_path);
|
|
}
|
|
|
|
self.terminal_view_from_pane_id(pane_id, ctx)
|
|
.and_then(|terminal_handle| {
|
|
terminal_handle.read(ctx, |view, _| {
|
|
let model = view.model.lock();
|
|
let session_startup_path = model.session_startup_path();
|
|
if let (Some(distribution_name), Some(path)) =
|
|
(view.active_session_wsl_distro(ctx), &session_startup_path)
|
|
{
|
|
path.to_str().and_then(|path| {
|
|
convert_wsl_to_windows_host_path(
|
|
&TypedPath::unix(path),
|
|
&distribution_name,
|
|
)
|
|
.inspect_err(|err| {
|
|
log::warn!(
|
|
"unable to convert WSL path to Windows host path: {err:?}"
|
|
);
|
|
})
|
|
.ok()
|
|
})
|
|
} else {
|
|
session_startup_path
|
|
}
|
|
})
|
|
})
|
|
}
|
|
|
|
pub fn launch_data_for_session(
|
|
&self,
|
|
pane_id: TerminalPaneId,
|
|
ctx: &AppContext,
|
|
) -> Option<ShellLaunchData> {
|
|
self.terminal_view_from_pane_id(pane_id, ctx)
|
|
.and_then(|terminal_handle| {
|
|
terminal_handle.read(ctx, |view, ctx| {
|
|
view.active_block_session_id()
|
|
.and_then(|id| view.sessions_model().as_ref(ctx).get(id))
|
|
.and_then(|s| s.launch_data().cloned())
|
|
})
|
|
})
|
|
}
|
|
|
|
/// Updates visibility of sessions contained within this pane group based
|
|
/// on window visibility and view focus state.
|
|
fn update_session_visibility(&mut self, ctx: &mut ViewContext<Self>) {
|
|
if !ctx.is_self_or_child_focused() {
|
|
return;
|
|
}
|
|
|
|
let active_window_id = ctx.windows().state().active_window;
|
|
if active_window_id == Some(ctx.window_id()) {
|
|
for session in self.panes_of::<TerminalPane>() {
|
|
session.terminal_view(ctx).update(ctx, |view, _ctx| {
|
|
view.mark_as_visible();
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn focus(&mut self, ctx: &mut ViewContext<Self>) {
|
|
self.update_session_visibility(ctx);
|
|
|
|
// We're adding a new pane to a tab that potentially has set the custom tab title.
|
|
// Lets ensure the new pane will honor it, otherwise, we'd want to change the title based
|
|
// on the default title for the pane.
|
|
if let Some(pane) = self.focused_pane_content(ctx) {
|
|
pane.focus(ctx);
|
|
}
|
|
|
|
#[cfg(target_family = "wasm")]
|
|
{
|
|
if ContextFlag::DynamicBrowserUrl.is_enabled() {
|
|
self.update_browser_url(ctx);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn handle_pane_link_updated(&self, pane_id: PaneId, url: Option<Url>, ctx: &AppContext) {
|
|
log::debug!("Url for pane should be updated pane_id: {pane_id:?}, url: {url:?}");
|
|
#[cfg(target_family = "wasm")]
|
|
if pane_id == self.focused_pane_id(ctx) {
|
|
update_browser_url(url, false);
|
|
}
|
|
|
|
let _ = ctx;
|
|
}
|
|
|
|
#[cfg(target_family = "wasm")]
|
|
fn update_browser_url(&self, ctx: &mut ViewContext<Self>) {
|
|
// We need to wait for the app to be loaded before we attempt to get the
|
|
// shareable links. This is because the links come from CloudModel objects
|
|
|
|
let initial_load_complete = UpdateManager::as_ref(ctx).initial_load_complete();
|
|
ctx.spawn(initial_load_complete, move |me, _, ctx| {
|
|
if let Some(pane) = me.focused_pane_content(ctx) {
|
|
match pane.shareable_link(ctx) {
|
|
Ok(crate::pane_group::pane::ShareableLink::Base) => {
|
|
update_browser_url(None, false)
|
|
}
|
|
Ok(crate::pane_group::pane::ShareableLink::Pane { url }) => {
|
|
update_browser_url(Some(url), false)
|
|
}
|
|
Err(crate::pane_group::pane::ShareableLinkError::Expected) => {}
|
|
Err(crate::pane_group::pane::ShareableLinkError::Unexpected(message)) => {
|
|
log::error!("Failed to updated browser url. {message}")
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Focus the active terminal session, if there is one.
|
|
pub fn focus_active_session(&mut self, ctx: &mut ViewContext<Self>) {
|
|
self.update_session_visibility(ctx);
|
|
|
|
if let Some(session_id) = self.active_session_id(ctx) {
|
|
if self.focus_pane(session_id.into(), true, ctx) {
|
|
ctx.emit(Event::AppStateChanged);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn active_session_terminal_model(
|
|
&self,
|
|
app: &AppContext,
|
|
) -> Option<Arc<FairMutex<TerminalModel>>> {
|
|
self.active_session_id(app)
|
|
.and_then(|id| self.terminal_session_by_id(id))
|
|
.map(|session| session.terminal_manager(app).as_ref(app).model())
|
|
}
|
|
|
|
fn focused_pane_content(&self, app: &AppContext) -> Option<&dyn PaneContent> {
|
|
self.pane_contents
|
|
.get(&self.focused_pane_id(app))
|
|
.map(|pane| pane.as_pane())
|
|
}
|
|
|
|
/// The terminal view backing the active terminal session. This may not be the same as the
|
|
/// focused pane, if a non-terminal pane is focused.
|
|
pub fn active_session_view(&self, ctx: &AppContext) -> Option<ViewHandle<TerminalView>> {
|
|
self.terminal_view_from_pane_id(self.active_session_id(ctx)?, ctx)
|
|
}
|
|
|
|
/// The terminal view backing the _focused_ terminal session. This will be the same
|
|
/// as the active_session_view if the focused pane is a terminal pane.
|
|
pub fn focused_session_view(&self, ctx: &AppContext) -> Option<ViewHandle<TerminalView>> {
|
|
self.terminal_view_from_pane_id(self.focused_pane_id(ctx), ctx)
|
|
}
|
|
|
|
/// Given a pane ID, retrieve its backing terminal pane contents, if the pane is a terminal pane.
|
|
fn terminal_session_by_id(&self, pane_id: impl Into<PaneId>) -> Option<&TerminalPane> {
|
|
self.pane_contents
|
|
.get(&pane_id.into())
|
|
.and_then(|contents| contents.as_any().downcast_ref::<TerminalPane>())
|
|
}
|
|
|
|
/// Given a pane ID, retrieve its backing terminal view, if the pane is a terminal pane.
|
|
pub fn terminal_view_from_pane_id(
|
|
&self,
|
|
pane_id: impl Into<PaneId>,
|
|
ctx: &AppContext,
|
|
) -> Option<ViewHandle<TerminalView>> {
|
|
self.terminal_session_by_id(pane_id)
|
|
.map(|session| session.terminal_view(ctx))
|
|
}
|
|
|
|
/// Given a pane ID, retrieve its backing code view, if the pane is a code pane.
|
|
pub fn code_view_from_pane_id(
|
|
&self,
|
|
pane_id: impl Into<PaneId>,
|
|
ctx: &AppContext,
|
|
) -> Option<ViewHandle<CodeView>> {
|
|
self.pane_contents
|
|
.get(&pane_id.into())
|
|
.and_then(|contents| contents.as_any().downcast_ref::<CodePane>())
|
|
.map(|pane| pane.file_view(ctx))
|
|
}
|
|
|
|
fn update_pane_history(&mut self, new_pane: PaneId) {
|
|
self.pane_history.retain(|&x| x != new_pane);
|
|
self.pane_history.push(new_pane);
|
|
}
|
|
|
|
fn remove_from_pane_history(&mut self, pane: PaneId) {
|
|
self.pane_history.retain(|&x| x != pane);
|
|
}
|
|
|
|
/// Switch focus to a pane. If the pane is a terminal session, it also becomes the active terminal session.
|
|
/// If focus_pane_contents is true, then the pane's contents will be focused in the UI framework.
|
|
/// Returns whether the pane was actually focused.
|
|
pub fn focus_pane(
|
|
&mut self,
|
|
id: PaneId,
|
|
focus_pane_contents: bool,
|
|
ctx: &mut ViewContext<Self>,
|
|
) -> bool {
|
|
// No-op if the pane is hidden-for-close (undo stack) or no longer present.
|
|
if FeatureFlag::UndoClosedPanes.is_enabled() && self.is_pane_hidden_for_close(id) {
|
|
return false;
|
|
}
|
|
if !self.pane_contents.contains_key(&id) {
|
|
return false;
|
|
}
|
|
// Saves the handle of a currently focused terminal pane before switching away from it.
|
|
let maybe_origin_terminal_view =
|
|
self.terminal_view_from_pane_id(self.focused_pane_id(ctx), ctx);
|
|
|
|
if self.focused_pane_id(ctx) == id
|
|
// As a safeguard, don't allow switching to unknown panes.
|
|
|| !self.pane_contents.contains_key(&id)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
self.focus_state.update(ctx, |focus_state, ctx| {
|
|
focus_state.set_focused_pane(id, ctx);
|
|
});
|
|
|
|
ctx.emit(Event::PaneTitleUpdated);
|
|
// Update the active session if the newly focused pane is a terminal pane.
|
|
if let Some(terminal_pane_id) = id.as_terminal_pane_id() {
|
|
self.focus_state.update(ctx, |focus_state, ctx| {
|
|
focus_state.set_active_session(Some(terminal_pane_id), ctx);
|
|
});
|
|
}
|
|
ctx.notify();
|
|
|
|
// Dismisses tooltips on a terminal pane that we've switched away from.
|
|
if let Some(view) = maybe_origin_terminal_view {
|
|
view.update(ctx, |terminal_view, ctx| {
|
|
terminal_view.dismiss_tooltips(ctx);
|
|
ctx.notify();
|
|
});
|
|
}
|
|
|
|
// There are some instances of focusing a pane where we don't actually want to focus the pane contents
|
|
// immediately within the UI framework. For instance, if this pane is being focused in the pane
|
|
// group as a result of another pane being move, then we don't actually need the contents
|
|
// to take focus in the ui framework.
|
|
if focus_pane_contents {
|
|
self.focus(ctx);
|
|
}
|
|
true
|
|
}
|
|
|
|
fn focus_pane_and_record_in_history(
|
|
&mut self,
|
|
id: PaneId,
|
|
ctx: &mut ViewContext<Self>,
|
|
) -> bool {
|
|
let focused = self.focus_pane(id, true, ctx);
|
|
if focused {
|
|
self.update_pane_history(id);
|
|
}
|
|
focused
|
|
}
|
|
|
|
pub fn terminal_manager(
|
|
&self,
|
|
pane_index: usize,
|
|
app: &AppContext,
|
|
) -> Option<ModelHandle<Box<dyn TerminalManager>>> {
|
|
self.terminal_session_by_pane_index(pane_index)
|
|
.map(|session| session.terminal_manager(app))
|
|
}
|
|
|
|
// When user clicked on the close tab button, we should wind down the existing panes
|
|
// by deleting all the saved blocks in each pane from the database.
|
|
pub fn clean_up_panes(&self, ctx: &mut ViewContext<Self>) {
|
|
for pane in self.pane_contents.values() {
|
|
let pane = pane.as_pane();
|
|
pane.detach(self, DetachType::Closed, ctx);
|
|
}
|
|
}
|
|
|
|
fn clean_up_pane(&self, pane_id: PaneId, ctx: &mut ViewContext<Self>) {
|
|
match self.pane_contents.get(&pane_id) {
|
|
Some(data) => {
|
|
let pane = data.as_pane();
|
|
pane.detach(self, DetachType::Closed, ctx);
|
|
}
|
|
None => log::error!("Could not find data for pane id: {pane_id:?}"),
|
|
};
|
|
}
|
|
|
|
/// Detach all panes from this group. This is called when a tab is closed, but may still
|
|
/// be restored.
|
|
pub fn detach_panes(&self, ctx: &mut ViewContext<Self>) {
|
|
for pane in self.pane_contents.values() {
|
|
let pane = pane.as_pane();
|
|
pane.detach(self, DetachType::HiddenForClose, ctx);
|
|
}
|
|
}
|
|
|
|
/// Detach all panes and clean up associated state when closing a tab.
|
|
/// This should be called instead of `detach_panes` when the pane group is being destroyed.
|
|
pub fn detach_panes_for_close(
|
|
&self,
|
|
working_directories_model: &ModelHandle<WorkingDirectoriesModel>,
|
|
ctx: &mut ViewContext<Self>,
|
|
) {
|
|
self.detach_panes(ctx);
|
|
|
|
// Clean up any state associated with this pane group (global search views, etc.)
|
|
let pane_group_id = ctx.view_id();
|
|
working_directories_model.update(ctx, |model, ctx| {
|
|
model.remove_pane_group(pane_group_id, ctx);
|
|
});
|
|
}
|
|
|
|
/// Reattach all panes to this group. This is called when a closed tab is restored.
|
|
pub fn reattach_panes(&mut self, ctx: &mut ViewContext<Self>) {
|
|
for pane in self.pane_contents.values() {
|
|
self.attach_pane(pane.as_ref(), ctx);
|
|
}
|
|
}
|
|
|
|
/// Attempts to attach a pane, calling pre_attach first.
|
|
/// Returns true if attachment succeeded, false if pre_attach prevented it.
|
|
fn try_attach_pane(&self, pane: &dyn AnyPaneContent, ctx: &mut ViewContext<Self>) -> bool {
|
|
if pane.pre_attach(self, ctx) {
|
|
self.attach_pane(pane, ctx);
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
|
|
/// Attaches a pane and does some post-attach work. All internal pane attachments
|
|
/// should go through this API.
|
|
pub fn attach_pane(&self, pane: &dyn AnyPaneContent, ctx: &mut ViewContext<Self>) {
|
|
// Attach the pane.
|
|
let pane = pane.as_pane();
|
|
let focus_handle = focus_state::PaneFocusHandle::new(pane.id(), self.focus_state.clone());
|
|
pane.attach(self, focus_handle, ctx);
|
|
|
|
// Title updates need to get propagated up to workspace (to update tab bar and window title).
|
|
ctx.subscribe_to_model(&pane.pane_configuration(), |_group, _, event, ctx| {
|
|
if matches!(
|
|
event,
|
|
PaneConfigurationEvent::TitleUpdated
|
|
| PaneConfigurationEvent::VerticalTabsTitleUpdated
|
|
) {
|
|
ctx.emit(Event::PaneTitleUpdated);
|
|
}
|
|
});
|
|
}
|
|
|
|
fn estimated_view_bounds(ctx: &mut ViewContext<Self>) -> RectF {
|
|
let window_id = ctx.window_id();
|
|
let window_bounds = match ctx.window_bounds(&window_id) {
|
|
Some(rect) => {
|
|
let size = rect.size();
|
|
if size.x() == 0. || size.y() == 0. {
|
|
RectF::new(Vector2F::zero(), *FALLBACK_INITIAL_WINDOW_SIZE)
|
|
} else {
|
|
rect
|
|
}
|
|
}
|
|
None => RectF::new(Vector2F::zero(), *FALLBACK_INITIAL_WINDOW_SIZE),
|
|
};
|
|
|
|
// Subtract the padding used in the workspace view for the panel
|
|
// border.
|
|
let window_bounds = window_bounds.contract(crate::workspace::WORKSPACE_PADDING);
|
|
|
|
let tab_bar_offset = vec2f(0.0, workspace::TOTAL_TAB_BAR_HEIGHT);
|
|
RectF::new(
|
|
window_bounds.origin() + tab_bar_offset,
|
|
window_bounds.size() - tab_bar_offset,
|
|
)
|
|
}
|
|
|
|
pub fn number_of_shared_sessions(&self, ctx: &AppContext) -> usize {
|
|
self.shared_session_view_ids(ctx).len()
|
|
}
|
|
|
|
pub fn shared_session_view_ids(&self, ctx: &AppContext) -> Vec<EntityId> {
|
|
self.panes_of::<TerminalPane>()
|
|
.filter_map(|p| {
|
|
let terminal_view = p.terminal_view(ctx);
|
|
let is_shared = terminal_view.as_ref(ctx).is_sharing_session();
|
|
is_shared.then(|| terminal_view.id())
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Filters out any hidden panes that aren't yet deleted (due to undo functionality).
|
|
pub fn terminal_views(&self, ctx: &AppContext) -> Vec<ViewHandle<TerminalView>> {
|
|
self.panes_of::<TerminalPane>()
|
|
.filter(|p| !self.is_pane_hidden_for_close(p.terminal_pane_id().into()))
|
|
.map(|p| p.terminal_view(ctx))
|
|
.collect()
|
|
}
|
|
|
|
pub fn code_views(&self, ctx: &AppContext) -> Vec<ViewHandle<CodeView>> {
|
|
self.panes_of::<CodePane>()
|
|
.map(|p| p.file_view(ctx))
|
|
.collect()
|
|
}
|
|
|
|
pub fn code_diff_views(&self, ctx: &AppContext) -> Vec<ViewHandle<CodeDiffView>> {
|
|
self.panes_of::<CodeDiffPane>()
|
|
.map(|p| p.diff_view(ctx))
|
|
.collect()
|
|
}
|
|
|
|
pub fn file_notebook_views(&self, ctx: &AppContext) -> Vec<ViewHandle<FileNotebookView>> {
|
|
self.panes_of::<FilePane>()
|
|
.map(|p| p.file_view(ctx))
|
|
.collect()
|
|
}
|
|
|
|
/// Get all terminal CWDs for this pane group.
|
|
/// This is used by the Workspace to refresh the active directories model.
|
|
pub fn terminal_view_working_directories<'a>(
|
|
&'a self,
|
|
ctx: &'a AppContext,
|
|
) -> impl Iterator<Item = (EntityId, Option<String>)> + 'a {
|
|
self.terminal_views(ctx).into_iter().map(|terminal_view| {
|
|
let terminal_id = terminal_view.id();
|
|
let cwd = terminal_view.as_ref(ctx).pwd_if_local(ctx);
|
|
(terminal_id, cwd)
|
|
})
|
|
}
|
|
|
|
/// Get all code CWDs for this pane group.
|
|
/// This is used by the Workspace to refresh the active directories model.
|
|
pub fn code_view_local_paths<'a>(
|
|
&'a self,
|
|
ctx: &'a AppContext,
|
|
) -> impl Iterator<Item = (EntityId, Option<String>)> + 'a {
|
|
self.code_views(ctx).into_iter().map(move |code_view| {
|
|
let id = code_view.id();
|
|
let local_path = code_view
|
|
.as_ref(ctx)
|
|
.local_path(ctx)
|
|
.map(|p| p.display().to_string());
|
|
(id, local_path)
|
|
})
|
|
}
|
|
|
|
pub fn code_diff_view_local_paths<'a>(
|
|
&'a self,
|
|
ctx: &'a AppContext,
|
|
) -> impl Iterator<Item = (EntityId, Option<String>)> + 'a {
|
|
self.code_diff_views(ctx).into_iter().map(move |diff_view| {
|
|
let id = diff_view.id();
|
|
let local_path = diff_view.as_ref(ctx).primary_file_path(ctx);
|
|
(id, local_path)
|
|
})
|
|
}
|
|
|
|
pub fn file_notebook_local_paths<'a>(
|
|
&'a self,
|
|
ctx: &'a AppContext,
|
|
) -> impl Iterator<Item = (EntityId, Option<String>)> + 'a {
|
|
self.file_notebook_views(ctx)
|
|
.into_iter()
|
|
.map(move |file_view| {
|
|
let id = file_view.id();
|
|
let local_path = file_view
|
|
.as_ref(ctx)
|
|
.local_path()
|
|
.map(|p| p.display().to_string());
|
|
(id, local_path)
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub fn is_share_session_modal_open(&self) -> bool {
|
|
self.terminal_with_open_share_session_modal.is_some()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub fn share_session_modal(&self) -> &ViewHandle<ShareSessionModal> {
|
|
&self.share_session_modal
|
|
}
|
|
|
|
pub(crate) fn start_agent_mode_in_new_pane(
|
|
&mut self,
|
|
initial_query: Option<&str>,
|
|
zero_state_prompt_suggestion_type: Option<ZeroStatePromptSuggestionType>,
|
|
ctx: &mut ViewContext<Self>,
|
|
) {
|
|
if let Some(terminal_view) = self.focused_session_view(ctx) {
|
|
terminal_view.update(ctx, |terminal_view, terminal_view_ctx| {
|
|
terminal_view.enter_agent_view_for_new_conversation(
|
|
None,
|
|
// TODO(zachbai): This is just a placeholder origin - I'm not even sure
|
|
// if this is called in live codepaths beyond the create-environment deep
|
|
// link flow.
|
|
AgentViewEntryOrigin::Input {
|
|
was_prompt_autodetected: false,
|
|
},
|
|
terminal_view_ctx,
|
|
);
|
|
|
|
if let Some(initial_query) = initial_query {
|
|
terminal_view
|
|
.input()
|
|
.update(terminal_view_ctx, |input, ctx| {
|
|
input.replace_buffer_content(initial_query, ctx);
|
|
input.focus_input_box(ctx);
|
|
});
|
|
}
|
|
if let Some(zero_state_prompt_suggestion_type) = zero_state_prompt_suggestion_type {
|
|
terminal_view
|
|
.input()
|
|
.update(terminal_view_ctx, |input, ctx| {
|
|
input.insert_zero_state_prompt_suggestion(
|
|
zero_state_prompt_suggestion_type,
|
|
ZeroStatePromptSuggestionTriggeredFrom::TryAgentModeBanner,
|
|
ctx,
|
|
);
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Add and focus a terminal pane in AI mode. Adds the pane to the right of all other panes as
|
|
/// a split on the root node. If `initial_query` is `Some` pre-fill the input with its value.
|
|
pub(crate) fn add_terminal_pane_in_agent_mode(
|
|
&mut self,
|
|
initial_query: Option<&str>,
|
|
zero_state_prompt_suggestion_type: Option<ZeroStatePromptSuggestionType>,
|
|
ctx: &mut ViewContext<Self>,
|
|
) {
|
|
// We can only control the size of a pane that hasn't been laid out by setting `PaneFlex`
|
|
// ratios because we don't have element sizes until we lay out the view. Here we make sure
|
|
// the Agent Mode pane will have at least the desired minimum width by checking the size of
|
|
// the already laid out panes.
|
|
let root_pane_width = self.panes.root.pane_size(ctx).x();
|
|
// The Agent Mode pane should take up no more than 50% of the root pane's width.
|
|
let new_pane_min_width = AGENT_MODE_PANE_DEFAULT_MINIMUM_WIDTH.min(root_pane_width / 2.);
|
|
|
|
let flex_for_min_width = {
|
|
let root_horizontal_flex_values_sum = self
|
|
.panes
|
|
.root
|
|
.pane_flex_sum_along_axis(SplitDirection::Horizontal);
|
|
let default_new_pane_width =
|
|
root_pane_width / (root_horizontal_flex_values_sum + DEFAULT_FLEX_VALUE);
|
|
|
|
let remaining_width_for_existing_panes = root_pane_width - new_pane_min_width;
|
|
let ratio = new_pane_min_width / remaining_width_for_existing_panes;
|
|
|
|
if default_new_pane_width < new_pane_min_width && ratio.is_normal() && ratio > 0. {
|
|
Some(PaneFlex(root_horizontal_flex_values_sum * ratio))
|
|
} else {
|
|
None
|
|
}
|
|
};
|
|
|
|
self.add_session_with_default_session_mode_behavior(
|
|
Direction::Right,
|
|
None,
|
|
self.focused_pane_id(ctx).as_terminal_pane_id(),
|
|
None, /* chosen_shell */
|
|
None, /* conversation_restoration */
|
|
DefaultSessionModeBehavior::Ignore,
|
|
ctx,
|
|
);
|
|
|
|
// Now that the Agent Mode pane has been inserted into the pane tree, we can update its
|
|
// `PaneFlex` value.
|
|
if let Some(custom_flex) = flex_for_min_width {
|
|
if let PaneNode::Branch(ref mut root_branch) = self.panes.root {
|
|
if let Some((agent_mode_pane_flex, PaneNode::Leaf(_))) =
|
|
root_branch.nodes.last_mut()
|
|
{
|
|
*agent_mode_pane_flex = custom_flex;
|
|
}
|
|
}
|
|
}
|
|
|
|
ctx.emit(Event::AppStateChanged);
|
|
|
|
self.start_agent_mode_in_new_pane(initial_query, zero_state_prompt_suggestion_type, ctx);
|
|
}
|
|
|
|
/// Creates an ambient agent pane with the given initial prompt.
|
|
fn create_ambient_agent_pane(&self, ctx: &mut ViewContext<Self>) -> TerminalPane {
|
|
let uuid = Uuid::new_v4();
|
|
let resources = TerminalViewResources {
|
|
tips_completed: self.tips_completed.clone(),
|
|
server_api: self.server_api.clone(),
|
|
model_event_sender: self.model_event_sender.clone(),
|
|
};
|
|
|
|
let view_bounds = Self::estimated_view_bounds(ctx);
|
|
|
|
let (terminal_view, terminal_manager) =
|
|
Self::create_ambient_agent_terminal(resources, view_bounds.size(), ctx);
|
|
|
|
TerminalPane::new(
|
|
uuid.into_bytes().to_vec(),
|
|
terminal_manager,
|
|
terminal_view,
|
|
self.model_event_sender.clone(),
|
|
ctx,
|
|
)
|
|
}
|
|
|
|
/// Add and focus a cloud mode pane.
|
|
pub fn add_ambient_agent_pane(&mut self, ctx: &mut ViewContext<Self>) {
|
|
if !FeatureFlag::AgentView.is_enabled() || !FeatureFlag::CloudMode.is_enabled() {
|
|
return;
|
|
}
|
|
|
|
let pane_data = self.create_ambient_agent_pane(ctx);
|
|
|
|
// Add the pane to the right
|
|
let _ = self.add_pane(Direction::Right, None, Box::new(pane_data), true, ctx);
|
|
}
|
|
|
|
/// Close overlays whose state is managed by this pane group or its terminal panes. Does not
|
|
/// change what element is focused.
|
|
pub fn close_overlays(&mut self, ctx: &mut ViewContext<Self>) {
|
|
self.for_all_terminal_panes(
|
|
|terminal_view, ctx| {
|
|
terminal_view.close_overlays(ctx);
|
|
},
|
|
ctx,
|
|
);
|
|
|
|
self.for_all_code_panes(
|
|
|code_view, ctx| {
|
|
code_view.close_overlays(ctx);
|
|
},
|
|
ctx,
|
|
);
|
|
|
|
self.close_share_session_modal(ctx);
|
|
self.close_shared_session_role_change_modal(RoleChangeCloseSource::ViewerRequest, ctx);
|
|
self.terminal_with_open_share_block_modal = None;
|
|
ctx.notify();
|
|
}
|
|
|
|
/// Updates the pane group's state in response to a view within a pane
|
|
/// receiving focus.
|
|
fn handle_focus_change(&mut self, ctx: &mut ViewContext<Self>) {
|
|
for pane_index in 0..self.pane_count() {
|
|
if let Some(content) = self.pane_by_index(pane_index) {
|
|
if content.has_application_focus(ctx) {
|
|
if let Some(pane_id) = self.pane_id_from_index(pane_index) {
|
|
// Mark the pane as the focused pane _without_ moving
|
|
// application focus to it.
|
|
//
|
|
// DO NOT CHANGE FALSE TO TRUE HERE! It can create an
|
|
// infinite loop of panes getting focused. This
|
|
// codepath should only be invoked when focus has
|
|
// already changed, so we only want to update our own
|
|
// state, and not manipulate application focus.
|
|
self.focus_pane(pane_id, false, ctx);
|
|
self.update_pane_history(pane_id);
|
|
ctx.emit(Event::PaneFocused);
|
|
};
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Entity for PaneGroup {
|
|
type Event = Event;
|
|
}
|
|
|
|
impl TypedActionView for PaneGroup {
|
|
type Action = PaneGroupAction;
|
|
|
|
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
|
use PaneGroupAction::*;
|
|
match action {
|
|
Add(direction) => {
|
|
let chosen_shell = {
|
|
if let Some(model) = self.active_session_terminal_model(ctx) {
|
|
let model = model.lock();
|
|
model.shell_launch_state().available_shell()
|
|
} else {
|
|
None
|
|
}
|
|
};
|
|
self.add_terminal_pane(*direction, chosen_shell, ctx);
|
|
}
|
|
Remove(view_id) => self.close_pane_with_confirmation(*view_id, ctx),
|
|
RemoveActive => self.close_active_pane_with_confirmation(ctx),
|
|
Activate(view_id, reason) => self.focus_pane_on_mouse_event(*view_id, *reason, ctx),
|
|
ResizeMove(position) => self.maybe_resize_pane(*position, ctx),
|
|
StartResizing(border) => self.start_resizing(*border, ctx),
|
|
EndResizing => self.end_resizing(ctx),
|
|
ResizeLeft => self.resize_left(ctx),
|
|
ResizeRight => self.resize_right(ctx),
|
|
ResizeUp => self.resize_up(ctx),
|
|
ResizeDown => self.resize_down(ctx),
|
|
NavigatePrev => self.navigate_prev_pane(ctx),
|
|
NavigateNext => self.navigate_next_pane(ctx),
|
|
NavigateLeft => self.navigate_pane_by_direction(Direction::Left, ctx),
|
|
NavigateRight => self.navigate_pane_by_direction(Direction::Right, ctx),
|
|
NavigateUp => self.navigate_pane_by_direction(Direction::Up, ctx),
|
|
NavigateDown => self.navigate_pane_by_direction(Direction::Down, ctx),
|
|
ToggleMaximizePane => self.toggle_maximize_pane(ctx),
|
|
Move {
|
|
id,
|
|
target_pane_id,
|
|
direction,
|
|
} => self.move_pane(*id, *target_pane_id, *direction, ctx),
|
|
HandleFocusChange => self.handle_focus_change(ctx),
|
|
FocusTerminalView(terminal_view_id) => self.focus_terminal_view(*terminal_view_id, ctx),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl View for PaneGroup {
|
|
fn ui_name() -> &'static str {
|
|
"PaneGroup"
|
|
}
|
|
|
|
fn keymap_context(&self, app: &AppContext) -> Context {
|
|
let mut ctx = Self::default_keymap_context();
|
|
|
|
if self.is_focused_pane_maximized(app) {
|
|
ctx.set.insert("PaneGroup_PaneMaximized");
|
|
}
|
|
|
|
if self.any_pane_being_dragged(app) {
|
|
ctx.set.insert("PaneGroup_PaneDragging");
|
|
}
|
|
|
|
match self.panes.len() {
|
|
0 => {
|
|
debug_assert!(false, "Should always be at least one pane");
|
|
}
|
|
1 => {
|
|
ctx.set.insert("PaneGroup_SinglePane");
|
|
}
|
|
_ => {
|
|
ctx.set.insert("PaneGroup_MultiplePanes");
|
|
}
|
|
};
|
|
|
|
ctx
|
|
}
|
|
|
|
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
|
let appearance = Appearance::as_ref(app);
|
|
|
|
let mut column = Flex::column()
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
|
.with_main_axis_size(MainAxisSize::Max);
|
|
|
|
if self
|
|
.user_default_shell_unsupported_banner_model_handle
|
|
.as_ref(app)
|
|
== &BannerState::Open
|
|
{
|
|
column.add_child(ChildView::new(&self.user_default_shell_changed_banner).finish());
|
|
}
|
|
|
|
let main_content = if self.is_focused_pane_maximized(app) {
|
|
self.focused_pane_id(app).render(app)
|
|
} else {
|
|
EventHandler::new(self.panes.render(appearance.theme(), app))
|
|
.on_mouse_dragged(move |ctx, _, position| {
|
|
ctx.dispatch_typed_action(PaneGroupAction::ResizeMove(position));
|
|
DispatchEventResult::StopPropagation
|
|
})
|
|
.on_left_mouse_up(move |ctx, _, _| {
|
|
ctx.dispatch_typed_action(PaneGroupAction::EndResizing);
|
|
DispatchEventResult::StopPropagation
|
|
})
|
|
.finish()
|
|
};
|
|
column.add_child(Shrinkable::new(1., main_content).finish());
|
|
|
|
let mut stack = Stack::new().with_child(column.finish());
|
|
|
|
// Render the share modals on the pane group level so that their
|
|
// size is not restricted to within the terminal view.
|
|
if self.terminal_with_open_share_block_modal.is_some() {
|
|
stack
|
|
.add_child(Clipped::new(ChildView::new(&self.share_block_modal).finish()).finish());
|
|
} else if FeatureFlag::CreatingSharedSessions.is_enabled()
|
|
&& self.terminal_with_open_share_session_modal.is_some()
|
|
{
|
|
stack.add_child(ChildView::new(&self.share_session_modal).finish());
|
|
} else if self
|
|
.terminal_with_shared_session_role_change_modal_open
|
|
.is_some()
|
|
{
|
|
stack.add_child(ChildView::new(&self.shared_session_role_change_modal).finish());
|
|
}
|
|
|
|
// Render the summarization cancel dialog at tab level when open.
|
|
if let Some(terminal_pane_id) = self.terminal_with_open_summarization_dialog {
|
|
if let Some(terminal_view) = self.terminal_view_from_pane_id(terminal_pane_id, app) {
|
|
if let Some(dialog_handle) = terminal_view.read(app, |view, ctx| {
|
|
view.summarization_cancel_dialog_handle(ctx)
|
|
}) {
|
|
stack.add_child(ChildView::new(&dialog_handle).finish());
|
|
}
|
|
}
|
|
}
|
|
|
|
// Render environment setup mode selector at tab level when open.
|
|
if let Some(pane_id) = self.pane_with_open_environment_setup_mode_selector {
|
|
let selector_handle = self
|
|
.terminal_view_from_pane_id(pane_id, app)
|
|
.and_then(|tv| {
|
|
tv.as_ref(app)
|
|
.environment_setup_mode_selector_handle()
|
|
.cloned()
|
|
})
|
|
.or_else(|| {
|
|
self.downcast_pane_by_id::<EnvironmentManagementPane>(pane_id)
|
|
.and_then(|emp| {
|
|
emp.environments_page_view(app)
|
|
.as_ref(app)
|
|
.environment_setup_mode_selector_handle()
|
|
.cloned()
|
|
})
|
|
});
|
|
if let Some(handle) = selector_handle {
|
|
stack.add_child(ChildView::new(&handle).finish());
|
|
}
|
|
}
|
|
|
|
// Render agent-assisted environment modal at tab level when open.
|
|
if let Some(pane_id) = self.pane_with_open_agent_assisted_environment_modal {
|
|
if let Some(handle) = self
|
|
.downcast_pane_by_id::<EnvironmentManagementPane>(pane_id)
|
|
.and_then(|emp| {
|
|
emp.environments_page_view(app)
|
|
.as_ref(app)
|
|
.agent_assisted_environment_modal_handle(app)
|
|
.cloned()
|
|
})
|
|
{
|
|
stack.add_child(ChildView::new(&handle).finish());
|
|
}
|
|
}
|
|
|
|
stack.finish()
|
|
}
|
|
|
|
fn on_window_transferred(
|
|
&mut self,
|
|
_old_window_id: WindowId,
|
|
_new_window_id: WindowId,
|
|
_ctx: &mut ViewContext<Self>,
|
|
) {
|
|
}
|
|
}
|