Files
galaxy/app/src/workspace/view.rs
T
Ryan WardandClaude Opus 4.6 ccc67e6a33 Rebrand Warp to Galaxy v1.1.0
- Rename all UI-facing "Warp" references to "Galaxy"
- Rename "Warpify" to "Wormhole" throughout the UI
- Replace app icons with 5 new Galaxy-branded variants
- Update About page to show selected app icon dynamically
- Rephrase Privacy page for Bedrock context
- Remove "Fallback to Warp server" toggle from Bedrock settings
- Widen model selector dropdowns in profile editor (480px)
- Update TERM_PROGRAM to "GalaxyTerminal"
- Bump version to 1.1.0

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-19 11:21:44 -05:00

22956 lines
931 KiB
Rust

mod build_plan_migration_modal;
pub(crate) mod cloud_agent_capacity_modal;
pub(crate) mod codex_modal;
pub mod conversation_list;
#[cfg(enable_crash_recovery)]
mod crash_recovery;
pub(crate) mod free_tier_limit_hit_modal;
pub mod global_search;
pub(crate) mod launch_modal;
pub(crate) mod left_panel;
pub(crate) mod onboarding;
pub(crate) mod openwarp_launch_modal;
pub(crate) mod right_panel;
mod startup_directory;
#[cfg(test)]
#[path = "view_test.rs"]
mod tests;
mod vertical_tabs;
#[cfg(target_family = "wasm")]
mod wasm_view;
use self::vertical_tabs::telemetry::{VerticalTabsDisplayOption, VerticalTabsTelemetryEvent};
use self::vertical_tabs::{
render_detail_sidecar, render_settings_popup, VerticalTabsPanelState,
VERTICAL_TABS_SETTINGS_BUTTON_POSITION_ID,
};
pub(crate) use onboarding::OnboardingTutorial;
use crate::ai::active_agent_views_model::ActiveAgentViewsModel;
use crate::ai::agent_conversations_model::AgentConversationsModel;
use crate::ai::agent_conversations_model::ConversationOrTask;
use crate::ai::agent_management::notifications::toast_stack::AgentNotificationToastStack;
use crate::ai::agent_management::notifications::view::{
NotificationMailboxView, NotificationMailboxViewEvent,
};
use crate::ai::agent_management::notifications::NotificationFilter;
use crate::ai::agent_management::telemetry::AgentManagementTelemetryEvent;
use crate::ai::agent_management::view::{AgentManagementView, AgentManagementViewEvent};
use crate::ai::agent_management::AgentManagementEvent;
use crate::ai::ambient_agents::telemetry::{CloudAgentTelemetryEvent, CloudModeEntryPoint};
use crate::ai::ambient_agents::AmbientAgentTaskId;
use crate::ai::blocklist::agent_view::agent_input_footer::editor::AgentToolbarEditorMode;
use crate::ai::blocklist::agent_view::AgentViewEntryOrigin;
use crate::ai::blocklist::history_model::load_conversation_from_server;
use crate::ai::blocklist::suggested_agent_mode_workflow_modal::SuggestedAgentModeWorkflowAndId;
use crate::ai::blocklist::suggested_rule_modal::{
SuggestedRuleAndId, SuggestedRuleModal, SuggestedRuleModalEvent,
};
use crate::ai::conversation_utils;
use crate::ai::document::ai_document_model::{AIDocumentId, AIDocumentModel};
use crate::ai::llms::LLMPreferences;
use crate::ai::persisted_workspace::PersistedWorkspace;
use crate::ai::AIRequestUsageModel;
use crate::ai::{
agent::{api::ServerConversationToken, conversation::AIConversationId, EntrypointType},
blocklist::{
inline_action::code_diff_view::CodeDiffView,
suggested_agent_mode_workflow_modal::{
SuggestedAgentModeWorkflowModal, SuggestedAgentModeWorkflowModalEvent,
},
SlashCommandRequest,
},
facts::{view::AIFactPage, AIFactManager, AIFactView, AIFactViewEvent},
};
use crate::ai_assistant::execution_context::WarpAiExecutionContext;
use crate::app_state::{
LeafContents, LeafSnapshot, LeftPanelDisplayedTab, LeftPanelSnapshot, NotebookPaneSnapshot,
PaneNodeSnapshot, PaneUuid, RightPanelSnapshot, SettingsPaneSnapshot, TabSnapshot,
TerminalPaneSnapshot, WindowSnapshot, WorkflowPaneSnapshot,
};
use crate::code_review::diff_state::DiffStateModel;
#[cfg(feature = "local_fs")]
use crate::code_review::CodeReviewTelemetryEvent;
use crate::code_review::GlobalCodeReviewModel;
use crate::coding_panel_enablement_state::CodingPanelEnablementState;
use crate::default_terminal::DefaultTerminal;
use crate::notebooks::CloudNotebook;
use crate::notification::NotificationContext;
use crate::pane_group::pane::ActionOrigin;
use crate::projects::ProjectManagementModel;
use crate::settings_view::mcp_servers_page::MCPServersSettingsPage;
use crate::terminal::enable_auto_reload_modal::{
EnableAutoReloadModal, EnableAutoReloadModalEvent,
};
use crate::terminal::model::terminal_model::ConversationTranscriptViewerStatus;
use crate::terminal::session_settings::SessionSettings;
use crate::terminal::view::inline_banner::ZeroStatePromptSuggestionType;
use crate::terminal::view::load_ai_conversation::{RestorationDirState, RestoredAIConversation};
use crate::terminal::view::{
AgentOnboardingVersion, ConversationRestorationInNewPaneType, OnboardingIntention,
OnboardingVersion,
};
use crate::ui_components::red_notification_dot::RedNotificationDot;
#[cfg(feature = "local_fs")]
use crate::util::file::external_editor::settings::OpenConversationPreference;
use crate::workspace::bonus_grant_notification_model::BonusGrantNotificationEvent;
use crate::workspace::toast_stack::ToastStack;
use crate::workspace::view::global_search::view::GlobalSearchEntryFocus;
use crate::workspace::view::left_panel::{
LeftPanelAction, LeftPanelEvent, LeftPanelView, ToolPanelView,
};
use crate::workspace::view::right_panel::{RightPanelEvent, RightPanelView};
use crate::ui_components::window_focus_dimming::WindowFocusDimming;
#[cfg(feature = "local_fs")]
use crate::util::file::external_editor::Editor;
#[cfg(feature = "local_fs")]
use crate::util::file::external_editor::EditorSettings;
use crate::util::openable_file_type::FileTarget;
#[cfg(feature = "local_fs")]
use crate::util::openable_file_type::{resolve_file_target_with_editor_choice, EditorLayout};
use crate::ai::blocklist::history_model::CloudConversationData;
use crate::ai::blocklist::FORK_PREFIX;
#[cfg(not(target_family = "wasm"))]
use crate::terminal::cli_agent_sessions::plugin_manager::{plugin_manager_for, PluginModalKind};
use crate::terminal::cli_agent_sessions::{CLIAgentSessionsModel, CLIAgentSessionsModelEvent};
use crate::workspace::header_toolbar_editor::{HeaderToolbarEditorEvent, HeaderToolbarEditorModal};
use crate::workspace::header_toolbar_item::HeaderToolbarItemKind;
use crate::workspace::tab_settings::TabCloseButtonPosition;
use crate::workspace::view::build_plan_migration_modal::{
BuildPlanMigrationModal, BuildPlanMigrationModalEvent,
};
use crate::workspace::view::cloud_agent_capacity_modal::{
CloudAgentCapacityModal, CloudAgentCapacityModalEvent, CloudAgentCapacityModalVariant,
};
use crate::workspace::view::codex_modal::{CodexModal, CodexModalEvent};
use crate::workspace::view::free_tier_limit_hit_modal::{
FreeTierLimitHitModal, FreeTierLimitHitModalEvent,
};
use crate::workspace::view::launch_modal::{LaunchModal, LaunchModalEvent, OzLaunchSlide};
use crate::workspace::view::openwarp_launch_modal::{
OpenWarpLaunchModal, OpenWarpLaunchModalEvent,
};
use crate::workspace::{ForkFromExchange, ForkedConversationDestination};
use crate::BlocklistAIHistoryModel;
use ai::index::full_source_code_embedding::manager::CodebaseIndexManager;
use galaxyui::notification::NotificationSendError;
#[cfg(all(target_os = "macos", feature = "crash_reporting"))]
use sentry::protocol::{Attachment, AttachmentType};
use serde_json;
use super::hoa_onboarding::{
mark_hoa_onboarding_completed, HoaOnboardingFlow, HoaOnboardingFlowEvent, HoaOnboardingStep,
};
use super::lightbox_view::{LightboxParams, LightboxView, LightboxViewEvent};
use super::util;
use super::WorkspaceRegistry;
use crate::ai::execution_profiles::editor::ExecutionProfileEditorManager;
use crate::ai::execution_profiles::profiles::{AIExecutionProfilesModel, ClientProfileId};
use crate::auth::auth_manager::{AuthManager, AuthManagerEvent};
use crate::auth::auth_override_warning_modal::{
AuthOverrideWarningModal, AuthOverrideWarningModalEvent, AuthOverrideWarningModalVariant,
};
use crate::auth::auth_state::AuthState;
use crate::auth::auth_view_modal::{AuthRedirectPayload, AuthView, AuthViewEvent, AuthViewVariant};
#[cfg(feature = "local_fs")]
use crate::code::editor_management::CodeManager;
use crate::code::editor_management::CodeSource;
use crate::code_review::telemetry_event::CodeReviewPaneEntrypoint;
use crate::drive::export::ExportManager;
use crate::drive::settings::WarpDriveSettings;
use crate::launch_configs::launch_config::WindowTemplate;
use crate::pane_group::{
AIFactPane, CodeReviewPanelArg, Direction as PaneGroupDirection, EnvironmentManagementPane,
ExecutionProfileEditorPane, NetworkLogPane, PaneGroup, PaneId, TerminalPaneId,
};
use crate::quit_warning::UnsavedStateSummary;
use crate::search::command_palette::view::NavigationMode;
use crate::search::slash_command_menu::static_commands::commands;
use crate::server::network_log_pane_manager::NetworkLogPaneManager;
use crate::server::server_api::ai::AIClient;
use crate::server::server_api::auth::AuthClient;
use crate::settings::{
AISettings, AISettingsChangedEvent, CodeSettings, CodeSettingsChangedEvent, CtrlTabBehavior,
DefaultSessionMode, InputModeSettings,
};
use crate::settings_view::environments_page::EnvironmentsPage;
use crate::settings_view::pane_manager::SettingsPaneManager;
use crate::settings_view::{SettingsSection, SettingsView, SettingsViewEvent};
#[cfg(all(target_os = "windows", feature = "local_tty"))]
use crate::shell_indicator::ShellIndicatorType;
use crate::terminal::available_shells::AvailableShell;
#[cfg(target_os = "windows")]
use crate::terminal::available_shells::AvailableShells;
use crate::terminal::block_list_viewport::InputMode;
use crate::terminal::ligature_settings::should_use_ligature_rendering;
use crate::terminal::warpify::settings::WarpifySettings;
use crate::ui_components::avatar::{Avatar, AvatarContent, StatusElementTypes};
#[cfg(target_family = "wasm")]
use crate::ai::agent_conversations_model::AgentConversationsModelEvent;
#[cfg(target_family = "wasm")]
use crate::ai::conversation_details_panel::ConversationDetailsPanel;
#[cfg(target_family = "wasm")]
use crate::uri::browser_url_handler::{parse_current_url, update_browser_url};
use crate::workflows::manager::WorkflowManager;
use crate::workflows::workflow::Workflow;
#[cfg(feature = "local_fs")]
use repo_metadata::RemoteRepositoryIdentifier;
#[cfg(target_family = "wasm")]
use url::Url;
use crate::billing::shared_objects_creation_denied_modal::{
SharedObjectsCreationDeniedModal, SharedObjectsCreationDeniedModalEvent,
};
#[cfg(target_family = "wasm")]
use crate::wasm_nux_dialog::WasmNUXDialog;
use crate::drive::items::WarpDriveItemId;
use crate::drive::settings::WarpDriveSettingsChangedEvent;
use crate::env_vars::{
manager::{EnvVarCollectionManager, EnvVarCollectionSource},
CloudEnvVarCollection,
};
use crate::settings::cloud_preferences::CloudPreferencesSettings;
use crate::appearance::{Appearance, AppearanceManager};
use crate::auth::AuthStateProvider;
use crate::autoupdate::{
is_incoming_version_past_current, AutoupdateState, AutoupdateStateEvent, RelaunchModel,
};
use crate::banner::BannerState;
use crate::changelog_model::{ChangelogModel, ChangelogRequestType, Event as ChangelogEvent};
use crate::channel::Channel;
use crate::cloud_object::toast_message::CloudObjectToastMessage;
use crate::cloud_object::{
CloudObject, GenericStringObjectFormat, JsonObjectType, ObjectType, Owner, Space,
};
use crate::context_chips::ChipRuntimeCapabilities;
use crate::drive::import::modal::{ImportModal, ImportModalEvent};
use crate::drive::workflows::arguments::ArgumentsState;
use crate::drive::workflows::modal::{WorkflowModal, WorkflowModalEvent};
use crate::drive::{
CloudObjectTypeAndId, DriveObjectType, DrivePanel, DrivePanelEvent, OpenWarpDriveObjectSettings,
};
use crate::experiments::{BlockOnboarding, Experiment};
use crate::menu::{
Event as MenuEvent, Menu, MenuItem, MenuItemFields, MenuSelectionSource,
DEFAULT_WIDTH as MENU_DEFAULT_WIDTH,
};
use crate::modal::{Modal, ModalEvent, ModalViewState};
use crate::network::{NetworkStatus, NetworkStatusEvent};
use crate::notebooks::manager::{NotebookManager, NotebookSource};
#[cfg(feature = "local_fs")]
use crate::pane_group::FilePane;
use crate::pane_group::{
self, AnyPaneContent, CodeDiffPane, CodePane, Direction, NewTerminalOptions, PanesLayout,
TabBarHoverIndex,
};
use crate::remote_server::manager::RemoteServerManager;
#[cfg(feature = "local_fs")]
use crate::remote_server::manager::RemoteServerManagerEvent;
use crate::terminal::keys_settings::KeysSettings;
use crate::terminal::shared_session::SharedSessionActionSource;
use crate::ai::blocklist::agent_view::editor::{AgentToolbarEditorEvent, AgentToolbarEditorModal};
use crate::prompt::editor_modal::{
EditorModal as PromptEditorModal, EditorModalEvent as PromptEditorModalEvent,
OpenSource as PromptEditorOpenSource,
};
use crate::referral_theme_status::ReferralThemeEvent;
use crate::resource_center::{
mark_feature_used_and_write_to_user_defaults, skip_tips_and_write_to_user_defaults,
ResourceCenterEvent, ResourceCenterPage, ResourceCenterView, Tip, TipAction, TipsCompleted,
};
use crate::reward_view::{RewardEvent, RewardKind, RewardView};
use crate::root_view::{quake_mode_window_id, NewWorkspaceSource, OpenLaunchConfigArg};
use crate::search::command_search::searcher::{
AcceptedHistoryItem, AcceptedWorkflow, CommandSearchItemAction,
};
use crate::search::command_search::view::{CommandSearchEvent, CommandSearchView};
use crate::server::cloud_objects::update_manager::{
ObjectOperation, OperationSuccessType, UpdateManager, UpdateManagerEvent,
};
use crate::server::ids::{ObjectUid, ServerId, SyncId};
use crate::server::server_api::{ServerApi, ServerApiEvent, ServerApiProvider, ServerTime};
use crate::server::telemetry::{
AddTabWithShellSource, AnonymousUserSignupEntrypoint, CloseTarget, EnvVarTelemetryMetadata,
FileTreeSource, KnowledgePaneEntrypoint, LaunchConfigUiLocation,
MCPServerCollectionPaneEntrypoint, OpenedWarpAISource, SharingDialogSource, TierLimitHitEvent,
WarpDriveSource,
};
use crate::session_management::{SessionNavigationData, SessionSource};
use crate::settings::{
active_theme_kind, respect_system_theme, AccessibilitySettings, AliasExpansionSettings,
AppEditorSettings, BlockVisibilitySettings, ChangelogSettings, CursorBlink, DebugSettings,
FontSettings, GPUSettings, InputSettings, MonospaceFontSize, PaneSettings, PrivacySettings,
SelectionSettings, Settings, SshSettings, ThemeSettings,
};
use crate::settings_view::flags;
use crate::settings_view::keybindings::{KeybindingChangedEvent, KeybindingChangedNotifier};
use crate::terminal::alt_screen_reporting::AltScreenReporting;
use crate::terminal::general_settings::GeneralSettings;
use crate::terminal::input::{Input, MenuPositioning};
#[cfg(feature = "local_tty")]
use crate::terminal::local_tty::docker_sandbox::resolve_sbx_path_from_user_shell;
use crate::terminal::model::blockgrid::BlockGrid;
#[cfg(feature = "local_fs")]
use crate::terminal::model::session::Session;
use crate::terminal::model::session::SessionId;
use crate::terminal::resizable_data::{
ModalSizes, ModalType, ResizableData, DEFAULT_LEFT_PANEL_WIDTH, DEFAULT_RIGHT_PANEL_WIDTH,
};
use crate::terminal::safe_mode_settings::SafeModeSettings;
use crate::terminal::session_settings::{
NewSessionSource, NotificationsMode, NotificationsSettings, SessionSettingsChangedEvent,
WorkingDirectoryMode,
};
use crate::terminal::settings::{SpacingMode, TerminalSettings};
use crate::terminal::shell::ShellType;
#[cfg(feature = "local_tty")]
use crate::terminal::view::docker_sandbox::DEFAULT_DOCKER_SANDBOX_BASE_IMAGE;
use crate::terminal::{self, SizeInfo, TerminalView};
#[cfg(target_os = "macos")]
use crate::workspace::cli_install;
use crate::workspaces::user_workspaces::UserWorkspaces;
use crate::{report_if_error, AgentNotificationsModel};
use ::settings::{Setting, ToggleableSetting};
use galaxy_core::features::FeatureFlag;
use crate::search::{self, QueryFilter};
use crate::terminal::view::{
SyncEvent, SyncInputType, TerminalAction, NOTIFICATIONS_TROUBLESHOOT_URL,
};
use crate::terminal::{BlockListSettings, TerminalModel};
use crate::themes::theme::{AnsiColorIdentifier, RespectSystemTheme, ThemeKind};
use crate::themes::theme_chooser::{ThemeChooser, ThemeChooserEvent, ThemeChooserMode};
use crate::themes::theme_creator_modal::{ThemeCreatorModal, ThemeCreatorModalEvent};
use crate::themes::theme_deletion_modal::{ThemeDeletionModal, ThemeDeletionModalEvent};
use crate::tips::{TipsEvent, TipsView};
use crate::ui_components::buttons::{combo_inner_button, icon_button_with_color};
use crate::undo_close::UndoCloseStack;
#[cfg(feature = "local_fs")]
use crate::user_config::{
ensure_default_worktree_config, find_unused_tab_config_path, find_unused_toml_path,
find_unused_worktree_config_path, materialize_default_worktree_config, sanitize_toml_base_name,
tab_configs_dir,
};
use crate::user_config::{WarpConfig, WarpConfigUpdateEvent};
use crate::util::bindings::{
keybinding_name_to_display_string, keybinding_name_to_keystroke, trigger_to_keystroke,
};
use crate::util::links;
use crate::util::traffic_lights::{traffic_light_data, TrafficLightMouseStates, TrafficLightSide};
use crate::util::truncation::truncate_from_end;
#[cfg(target_family = "wasm")]
use crate::view_components::action_button::ActionButton;
use crate::view_components::callout_bubble::{
render_callout_bubble, CalloutArrowDirection, CalloutArrowPosition, CalloutBubbleConfig,
};
use crate::view_components::{
AgentToast, AgentToastStack, DismissibleToast, DismissibleToastStack, ToastLink,
};
use crate::window_settings::{WindowSettings, WindowSettingsChangedEvent, ZoomLevel};
use crate::workflows::{
manager::WorkflowOpenSource, AIWorkflowOrigin, CloudWorkflow, WorkflowSelectionSource,
WorkflowSource, WorkflowType, WorkflowViewMode,
};
use crate::workspace::action::CommandSearchOptions;
use crate::workspace::one_time_modal_model::OneTimeModalModel;
use crate::workspace::sync_inputs::SyncedInputState;
use crate::workspace::toast_stack::{
ToastStack as WorkspaceToastStack, ToastStackEvent as WorkspaceToastStackEvent,
};
use crate::{
ai_assistant::{
panel::{AIAssistantPanelEvent, AIAssistantPanelView},
AskAIType, AI_ASSISTANT_FEATURE_NAME, AI_ASSISTANT_LOGO_COLOR,
},
settings,
ui_components::blended_colors,
};
use crate::{send_telemetry_from_ctx, GlobalResourceHandles};
use futures::Future;
use galaxy_core::context_flag::ContextFlag;
use galaxy_core::semantic_selection::SemanticSelection;
use galaxy_util::path::{user_friendly_path, LineAndColumnArg};
use galaxyui::fonts::Weight;
use galaxyui::modals::{AlertDialogWithCallbacks, AppModalCallback};
use galaxyui::windowing::{StateEvent, WindowManager};
use itertools::Itertools;
use parking_lot::FairMutex;
use pathfinder_geometry::rect::RectF;
use repo_metadata::repositories::DetectedRepositories;
use session_sharing_protocol::common::SessionId as SharedSessionId;
use std::collections::{HashMap, HashSet};
#[cfg(feature = "local_fs")]
use std::convert::TryFrom;
use std::time::Duration;
#[cfg(target_os = "macos")]
use std::time::{SystemTime, UNIX_EPOCH};
use galaxy_core::user_preferences::GetUserPreferences as _;
use galaxyui::clipboard::ClipboardContent;
#[cfg(target_family = "wasm")]
use galaxyui::elements::Percentage;
use galaxyui::elements::{
CacheOption, DispatchEventResult, DropTarget, EventHandler, Image, MouseInBehavior, Rect,
};
use galaxyui::ui_components::button::{Button, ButtonVariant};
use galaxyui::{elements::MouseStateHandle, fonts::Properties};
use crate::{autoupdate, channel::ChannelState};
use crate::ai::blocklist::{BlocklistAIHistoryEvent, PendingQueryState, SerializedBlockListItem};
use crate::editor::{
EditorView, Event as EditorEvent, PropagateAndNoOpNavigationKeys, SingleLineEditorOptions,
TextOptions,
};
use crate::persistence::ModelEvent;
use super::action::{
InitContent, RestoreConversationLayout, TabContextMenuAnchor,
VerticalTabsPaneContextMenuTarget, WorkspaceAction,
};
use super::close_session_confirmation_dialog::{
CloseSessionConfirmationDialog, CloseSessionConfirmationEvent, OpenDialogSource,
};
use super::delete_conversation_confirmation_dialog::{
DeleteConversationConfirmationDialog, DeleteConversationConfirmationEvent,
DeleteConversationDialogSource,
};
use super::native_modal::{NativeModal, NativeModalEvent};
use super::one_time_modal_model::OneTimeModalEvent;
use super::rewind_confirmation_dialog::{
RewindConfirmationDialog, RewindConfirmationEvent, RewindDialogSource,
};
use super::{ActiveSession, TabBarDropTargetData, TabBarLocation};
use super::tab_settings::{
HeaderToolbarChipSelection, NewTabPlacement, TabSettings, TabSettingsChangedEvent,
VerticalTabsDisplayGranularity, WorkspaceDecorationVisibility,
};
use super::util::{
PaneViewLocator, TabMovement, TerminalSessionFallbackBehavior, WelcomeTipsViewState,
WorkspaceMouseStates, WorkspaceState,
};
use crate::cloud_object::model::persistence::CloudModel;
use crate::launch_configs::save_modal::{LaunchConfigModalEvent, LaunchConfigSaveModal};
use crate::tab_configs::action_sidecar::SidecarItemKind;
use crate::tab_configs::remove_confirmation_dialog::{
RemoveTabConfigConfirmationDialog, RemoveTabConfigConfirmationEvent,
};
use crate::tab_configs::session_config_modal::{SessionConfigModal, SessionConfigModalEvent};
use crate::tab_configs::telemetry::{
ExistingTabConfigOpenMode, GuidedModalSessionType, TabConfigsTelemetryEvent,
};
#[cfg(feature = "local_fs")]
use crate::tab_configs::telemetry::{NewWorktreeConfigOpenSource, WorktreeBranchNamingMode};
use crate::tab_configs::{
NewWorktreeModal, NewWorktreeModalEvent, TabConfigParamsModal, TabConfigParamsModalEvent,
};
use crate::code::editor::{add_color, remove_color};
use crate::palette::PaletteMode;
use crate::search::command_palette::view::{Event as CommandPaletteEvent, View as CommandPalette};
use crate::server::telemetry::{NotificationsTurnedOnSource, PaletteSource, TabRenameEvent};
use crate::tab::{
tab_position_id, NewSessionMenuItem, PaneNameMenuTarget, SelectedTabColor, TabBarState,
TabComponent, TabData, TabTelemetryAction, TAB_BAR_BORDER_HEIGHT,
};
use crate::terminal::view::ssh_file_upload::FileUploadId;
use crate::ui_components::icons;
use crate::TelemetryEvent;
use autoupdate::AutoupdateStage;
#[cfg(target_os = "macos")]
use command::blocking::Command;
use galaxy_core::ui::theme::{color::internal_colors, phenomenon::PhenomenonStyle, Fill};
use galaxy_core::ui::{color::coloru_with_opacity, Icon};
use galaxy_editor::editor::NavigationKey;
use galaxyui::keymap::Context;
use galaxyui::notification::{RequestPermissionsOutcome, UserNotification};
use galaxyui::platform::{
Cursor, FilePickerConfiguration, FullscreenState, SystemTheme, TerminationMode,
};
use galaxyui::text_layout::ClipConfig;
use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
use galaxyui::{
accessibility::{
AccessibilityContent, AccessibilityVerbosity, ActionAccessibilityContent, WarpA11yRole,
},
elements::{
Align, Border, ChildAnchor, ChildView, Clipped, ConstrainedBox, Container, CornerRadius,
CrossAxisAlignment, Dismiss, Element, Empty, Expanded, Fill as ElementFill, Flex,
Highlight, Hoverable, Icon as WarpUiIcon, MainAxisAlignment, MainAxisSize,
OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds,
PositionedElementAnchor, PositionedElementOffsetBounds, Radius, SavePosition, Shrinkable,
Stack, Text,
},
geometry::vector::{vec2f, Vector2F},
AppContext, Entity, TypedActionView, UpdateView, View, ViewContext, ViewHandle,
};
use galaxyui::{
EntityId, FocusContext, ModelHandle, SingletonEntity, UpdateModel, ViewAsRef, WeakViewHandle,
WindowId,
};
use lazy_static::lazy_static;
use pathfinder_color::ColorU;
#[cfg(target_os = "macos")]
use std::env;
use std::fmt::Write;
#[cfg(all(target_os = "macos", feature = "crash_reporting"))]
use std::fs;
use std::path::Path;
use std::path::PathBuf;
#[cfg(target_os = "macos")]
use std::process;
use std::sync::{mpsc, Mutex};
use std::{cmp::Ordering, sync::Arc};
use crate::terminal::view::LeftPanelTargetView;
/// The padding that should be applied to the workspace as a whole.
pub const WORKSPACE_PADDING: f32 = 1.0;
/// The minimum font size at which terminal text will be rendered.
const MIN_FONT_SIZE: f32 = 5.0;
/// The maximum font size at which terminal text will be rendered.
const MAX_FONT_SIZE: f32 = 25.0;
/// The increment for increasing/decreasing the font size.
const FONT_SIZE_INCREMENT: f32 = 1.0;
pub const TAB_BAR_HEIGHT: f32 = 34.;
/// Height for all panel headers (tab bar, warp drive, resource center, theme chooser, etc.).
/// This ensures consistent header heights across all UI panels.
pub const PANEL_HEADER_HEIGHT: f32 = TAB_BAR_HEIGHT;
/// The hover area height for states where the tab bar is revealed on hover.
const TAB_BAR_HOVER_HEIGHT: f32 = 12.;
const TAB_BAR_PADDING_LEFT: f32 = 4.;
const TAB_BAR_PADDING_RIGHT: f32 = 8.;
const TITLE_BAR_SEARCH_BAR_MAX_WIDTH: f32 = 320.;
const TITLE_BAR_SEARCH_BAR_SLOT_PADDING: f32 = 8.;
// The total height taken up by the tab bar, including its bottom border.
pub const TOTAL_TAB_BAR_HEIGHT: f32 = TAB_BAR_HEIGHT + TAB_BAR_BORDER_HEIGHT;
const TAB_BAR_ICON_PADDING: f32 = 4.;
const TAB_BAR_PILL_WIDTH: f32 = 100.;
const PILL_FONT_SIZE: f32 = 12.;
// We use the word "Warp" in the Update Ready button to make it obvious that the terminal is Warp.
// This can lead to free advertising when users screen-share Warp when an update is available.
const UPDATE_READY_TEXT: &str = "Update Galaxy";
const TAB_BAR_OVERFLOW_MENU_WIDTH: f32 = 300.;
#[cfg(not(target_family = "wasm"))]
const RESOURCE_CENTER_WIDTH: f32 = 361.;
// Ratio of terminal : theme chooser when theme chooser is active
const THEME_CHOOSER_RATIO: f32 = 3.5;
/// Save position for the tab bar.
const TAB_BAR_POSITION_ID: &str = "workspace_view:tab_bar";
/// Save position for the vertical tabs panel.
/// HOA onboarding callouts anchor relative to this position, so whichever code
/// path renders the vertical tabs panel must wrap it in a `SavePosition` with
/// this id.
const VERTICAL_TABS_PANEL_POSITION_ID: &str = "workspace_view:vertical_tabs_panel";
/// The main content area in a workspace. This is directly below the tab bar.
const TAB_CONTENT_POSITION_ID: &str = "workspace_view:tab_content";
const WELCOME_TIPS_POSITION_ID: &str = "welcome_tips_pill";
const AI_ASSISTANT_BUTTON_ID: &str = "workspace_view:ai_assistant_button";
const VERSION_DEPRECATION_BANNER_TEXT: &str = "Your app is out of date and some features may not work as expected. Please update immediately.";
const VERSION_DEPRECATION_WITHOUT_PERMISSIONS_BANNER_TEXT: &str = "Some Galaxy features may not work as expected without updating immediately, but Galaxy is unable to perform the update.";
const ASK_AI_ASSISTANT_KEYBINDING_NAME: &str = "workspace:toggle_ai_assistant";
const TOGGLE_RESOURCE_CENTER_KEYBINDING_NAME: &str = "workspace:toggle_resource_center";
/// Shared position ID for the new-session sidecar overlay. Used for both the
/// `SavePosition` wrapper and the safe-zone rect lookup.
const NEW_SESSION_SIDECAR_POSITION_ID: &str = "new_session_sidecar";
const NEW_SESSION_SIDECAR_WIDTH: f32 = 300.;
const NEW_SESSION_SIDECAR_SEARCH_BOX_HEIGHT: f32 = 32.;
const NEW_SESSION_SIDECAR_SEARCH_BOX_HORIZONTAL_PADDING: f32 = 12.;
const NEW_SESSION_SIDECAR_SEARCH_BOX_VERTICAL_PADDING: f32 = 6.;
const NEW_SESSION_SIDECAR_FOOTER_HORIZONTAL_PADDING: f32 = 16.;
const NEW_SESSION_SIDECAR_FOOTER_VERTICAL_PADDING: f32 = 8.;
const SESSION_CONFIG_TAB_CONFIG_CHIP_TEXT: &str = "Access your tab configs here.";
const SESSION_CONFIG_TAB_CONFIG_CHIP_WIDTH: f32 = 206.;
const SHOW_SETTINGS_KEYBINDING_NAME: &str = "workspace:show_settings";
pub const TOGGLE_COMMAND_PALETTE_KEYBINDING_NAME: &str = "workspace:toggle_command_palette";
const USER_AVATAR_BUTTON_POSITION_ID: &str = "workspace:user_avatar_button";
const NOTIFICATIONS_MAILBOX_POSITION_ID: &str = "workspace:notifications_mailbox";
pub(crate) const JUMP_TO_LATEST_TOAST_BINDING_NAME: &str = "workspace:jump_to_latest_toast";
pub(crate) const TOGGLE_NOTIFICATION_MAILBOX_BINDING_NAME: &str =
"workspace:toggle_notification_mailbox";
// these won't have to be public after we deprecate the code mode v1 project explorer which is defined in terminal
pub(crate) const TOGGLE_PROJECT_EXPLORER_BINDING_NAME: &str = "workspace:toggle_project_explorer";
pub(crate) const TOGGLE_WARP_DRIVE_BINDING_NAME: &str = "workspace:toggle_warp_drive";
pub(crate) const TOGGLE_RIGHT_PANEL_BINDING_NAME: &str = "workspace:toggle_right_panel";
pub(crate) const TOGGLE_VERTICAL_TABS_PANEL_BINDING_NAME: &str =
"workspace:toggle_vertical_tabs_panel";
pub(crate) const OPEN_GLOBAL_SEARCH_BINDING_NAME: &str = "workspace:open_global_search";
pub(crate) const TOGGLE_CONVERSATION_LIST_VIEW_BINDING_NAME: &str =
"workspace:toggle_conversation_list_view";
pub(crate) const NEW_TAB_BINDING_NAME: &str = "workspace:new_tab";
pub(crate) const NEW_TERMINAL_TAB_BINDING_NAME: &str = "workspace:new_terminal_tab";
pub(crate) const NEW_AGENT_TAB_BINDING_NAME: &str = "workspace:new_agent_tab";
pub(crate) const NEW_AMBIENT_AGENT_TAB_BINDING_NAME: &str = "workspace:new_ambient_agent_tab";
pub(crate) const TOGGLE_TAB_CONFIGS_MENU_BINDING_NAME: &str = "workspace:toggle_tab_configs_menu";
// Editable left panel toolbelt keybindings.
pub(crate) const LEFT_PANEL_PROJECT_EXPLORER_BINDING_NAME: &str =
"workspace:left_panel_project_explorer";
pub(crate) const LEFT_PANEL_GLOBAL_SEARCH_BINDING_NAME: &str = "workspace:left_panel_global_search";
pub(crate) const LEFT_PANEL_WARP_DRIVE_BINDING_NAME: &str = "workspace:left_panel_warp_drive";
pub(crate) const LEFT_PANEL_AGENT_CONVERSATIONS_BINDING_NAME: &str =
"workspace:left_panel_agent_conversations";
const KEYBINDINGS_TO_CACHE: [&str; 4] = [
ASK_AI_ASSISTANT_KEYBINDING_NAME,
TOGGLE_RESOURCE_CENTER_KEYBINDING_NAME,
SHOW_SETTINGS_KEYBINDING_NAME,
TOGGLE_COMMAND_PALETTE_KEYBINDING_NAME,
];
const WORKFLOW_AND_ENV_VAR_SPLIT_RATIO: f32 = 0.56;
const NOTEBOOK_SMART_SPLIT_RATIO: f32 = 0.42;
#[cfg(target_family = "wasm")]
const MOBILE_OVERLAY_PANEL_WIDTH_RATIO: f32 = 0.9;
#[cfg(target_family = "wasm")]
const MOBILE_OVERLAY_SCRIM_ALPHA: u8 = 128;
pub const NEW_TAB_BUTTON_POSITION_ID: &str = "new_tab_button";
pub const NEW_SESSION_MENU_BUTTON_POSITION_ID: &str = "new_session_menu_button";
// The max length of the title of a fork toast (after which we truncate it).
const MAX_FORK_TOAST_TITLE_LENGTH: usize = 100;
// The max length of the window title (matching conversation title truncation).
const MAX_WINDOW_TITLE_LENGTH: usize = 80;
/// The default display name used for the user if they have no associated display name.
pub const DEFAULT_USER_DISPLAY_NAME: &str = "User";
lazy_static! {
static ref OPENING_WARP_DRIVE_ON_START_UP: Arc<Mutex<bool>> = Arc::new(Mutex::new(false));
static ref PANEL_CORNER_RADIUS: CornerRadius = CornerRadius::with_all(Radius::Pixels(8.));
static ref PANEL_HEADER_CORNER_RADIUS: CornerRadius =
CornerRadius::with_top(Radius::Pixels(8.));
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum TabConfigsMenuOpenSource {
KeyboardShortcut,
Pointer,
}
/// This enumerates the different kinds of banners we show to the user.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum WorkspaceBanner {
/// to display the banner when we are in AutoupdateStage::UpdateReady and the
/// user's current version has been deprecated
VersionDeprecated,
/// to display the AutoupdateStage::UnableToUpdateToNewVersion
UnableToUpdateToNewVersion,
/// to display the AutoupdateStage::UnableToLaunchNewVersion
UnableToLaunchNewVersion,
/// to display when the user needs to reauthenticate
Reauth,
// to display an anonymous user has X days left to sign in
AnonymousUserAuth,
/// to display when recovering from a crash that may have been due to use
/// of Wayland
#[cfg(target_os = "linux")]
WaylandCrashRecovery,
/// to display when settings.toml has errors (parse failure or invalid values)
InvalidSettings,
}
impl WorkspaceBanner {
/// We want some banners to have a close button and not others, e.g. if they are running a very
/// outdated version and we want to nag them to update, AutoupdateBanner::VersionDeprecated should
/// not be dismissible
fn is_dismissible(&self) -> bool {
match self {
Self::UnableToUpdateToNewVersion => true,
Self::UnableToLaunchNewVersion => true,
Self::VersionDeprecated => false,
Self::AnonymousUserAuth => false,
Self::Reauth => true,
#[cfg(target_os = "linux")]
Self::WaylandCrashRecovery => true,
Self::InvalidSettings => true,
}
}
}
#[derive(Debug, Clone, Eq, PartialEq)]
enum SessionCycleDirection {
Next,
Previous,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PanePanelDirection {
Prev,
Next,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FocusRegion {
LeftPanel,
PaneGroup,
RightPanel,
Other,
}
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum PanelPosition {
Left,
Right,
}
pub struct TabPaneGroupIdentifiers {
pub tab_idx: usize,
pub pane_group_id: EntityId,
pub terminal_ids: Vec<EntityId>,
}
/// Categorization of how the tab bar should be rendered.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
enum ShowTabBar {
/// Show the tab bar stacked on top of the pane group area.
#[default]
Stacked,
/// Hide the tab bar.
Hidden,
}
impl ShowTabBar {
fn has_tab_bar(self) -> bool {
matches!(self, ShowTabBar::Stacked)
}
}
/// The type of content being displayed when the simplified WASM tab bar is shown.
/// Used to determine which elements to render (e.g., icon, info button).
#[cfg(target_family = "wasm")]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SimplifiedWasmTabBarContent {
/// Viewing a Warp Drive object (notebook, workflow, env vars, AI facts, MCP servers)
WarpDriveObject,
/// Participating in a shared session (viewer or writer). Contains the optional ambient agent task ID.
SharedSession { task_id: Option<AmbientAgentTaskId> },
/// Viewing a conversation transcript. Contains the optional ambient agent task ID.
ConversationTranscript { task_id: Option<AmbientAgentTaskId> },
}
type RemoteUploadId = (TerminalPaneId, FileUploadId);
type WorkspaceMenuHandles = (
ViewHandle<Menu<WorkspaceAction>>,
ViewHandle<Menu<WorkspaceAction>>,
ViewHandle<Menu<NewSessionSidecarSelection>>,
);
#[derive(Clone, Debug, PartialEq, Eq)]
enum NewSessionSidecarSelection {
OpenWorktreeRepo { repo_path: String },
}
#[derive(Debug, Default)]
struct FileUploadSessions {
/// Maps a local session pane handling a file upload
/// to the remote session pane through which the upload was initiated.
local_to_remote_map: HashMap<TerminalPaneId, TerminalPaneId>,
/// Maps a local pane to the ID of the file upload it is responsible for.
local_to_upload_id_map: HashMap<TerminalPaneId, RemoteUploadId>,
upload_id_to_local_map: HashMap<RemoteUploadId, TerminalPaneId>,
}
/// Controls the color palette used for a workspace banner.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum BannerSeverity {
/// Warning banners use an ansi-blended yellow background.
Warning,
/// Error banners use an ansi-blended red background.
Error,
}
/// Visual style for an individual banner action button.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
enum BannerButtonVariant {
/// No fill, no border, just text (and optional icon). Used for the primary
/// action in the Figma design (e.g. "Fix with Oz").
Naked,
/// Border-only, no fill (e.g. "Open file").
Outlined,
}
struct WorkspaceBannerButtonDetails {
text: String,
action: WorkspaceAction,
variant: BannerButtonVariant,
/// Optional leading icon shown before the label.
icon: Option<Icon>,
/// If set, renders an adjacent "More info" pill that dispatches this action.
more_info_button_action: Option<WorkspaceAction>,
}
struct WorkspaceBannerFields {
banner_type: WorkspaceBanner,
severity: BannerSeverity,
/// Optional bold heading rendered inline before the description.
heading: Option<String>,
/// Main description text (regular weight).
description: String,
secondary_button: Option<WorkspaceBannerButtonDetails>,
button: Option<WorkspaceBannerButtonDetails>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DefaultSessionModeBehavior {
/// Respect the user's default-session-mode setting and auto-enter agent view when applicable.
Apply,
/// Skip default-session-mode auto-entry because the caller is explicitly specifying the mode for the new session.
Ignore,
}
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
struct CodeReviewPaneContext {
repo_path: Option<PathBuf>,
diff_state_model: ModelHandle<DiffStateModel>,
terminal_view: WeakViewHandle<TerminalView>,
}
/// Parameters for updating the right panel's 'state.
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
struct RightPanelUpdateParams<'a> {
pane_group: &'a ViewHandle<PaneGroup>,
target_open_state: bool,
entrypoint: Option<CodeReviewPaneEntrypoint>,
cli_agent: Option<crate::terminal::CLIAgent>,
review_pane_context: Option<&'a CodeReviewPaneContext>,
}
/// Groups a modal view handle with the ID of the tab that was created to host
/// it, so the custom tab title can be cleared on close regardless of which tab
/// is active at that point.
struct ModalWithTab<V> {
view: ViewHandle<V>,
/// Set when the modal opens a new tab; consumed (taken) when the modal
/// closes so we can clear the custom tab title.
tab_pane_group_id: Option<EntityId>,
}
/// Context saved when the session config modal triggers `open_tab_config` and
/// the tab config has params (worktree). The params modal opens asynchronously,
/// so we store what we need to finish the tab replacement when it completes.
struct PendingSessionConfigReplacement {
old_pane_group_id: EntityId,
}
enum PendingSessionConfigTabConfigChipTutorial {
WhenBootstrapped {
has_project: bool,
intention: OnboardingIntention,
},
AfterSetupCommands {
intention: OnboardingIntention,
},
}
pub struct TransferredTab {
pub pane_group: ViewHandle<PaneGroup>,
pub color: Option<AnsiColorIdentifier>,
pub custom_title: Option<String>,
pub left_panel_open: bool,
pub vertical_tabs_panel_open: bool,
pub right_panel_open: bool,
pub is_right_panel_maximized: bool,
}
pub struct Workspace {
window_id: WindowId,
tabs: Vec<TabData>,
active_tab_index: usize,
hovered_tab_index: Option<TabBarHoverIndex>,
tab_bar_hover_state: MouseStateHandle,
tab_fixed_width: Option<f32>,
traffic_light_mouse_states: TrafficLightMouseStates,
tab_rename_editor: ViewHandle<EditorView>,
pane_rename_editor: ViewHandle<EditorView>,
vertical_tabs_search_input: ViewHandle<EditorView>,
tips_completed: ModelHandle<TipsCompleted>,
user_default_shell_unsupported_banner_model_handle: ModelHandle<BannerState>,
server_api: Arc<ServerApi>,
auth_state: Arc<AuthState>,
server_time: Option<Arc<ServerTime>>,
tab_bar_overflow_menu: ViewHandle<Menu<WorkspaceAction>>,
show_tab_bar_overflow_menu: bool,
tab_right_click_menu: ViewHandle<Menu<WorkspaceAction>>,
show_tab_right_click_menu: Option<(usize, TabContextMenuAnchor)>,
// TODO(CORE-2300): this used to be add_tab_dropdown_menu.
// Because we are rolling out the change behind a feature flag,
// keep this comment here until the feature flag is removed.
// Otherwise people might be confused as to why there is a right click
// menu in the "new_session_dropdown_menu"
// Same applies to "show_new_session_dropdown_menu"
new_session_dropdown_menu: ViewHandle<Menu<WorkspaceAction>>,
show_new_session_dropdown_menu: Option<Vector2F>,
changelog_model: ModelHandle<ChangelogModel>,
palette: ViewHandle<CommandPalette>,
ctrl_tab_palette: ViewHandle<CommandPalette>,
mouse_states: WorkspaceMouseStates,
settings_pane: ViewHandle<SettingsView>,
import_modal: ViewHandle<ImportModal>,
theme_chooser_view: ViewHandle<ThemeChooser>,
previous_theme: Option<ThemeKind>,
reward_modal: ViewHandle<Modal<RewardView>>,
reward_modal_pending: Option<RewardKind>,
current_workspace_state: WorkspaceState,
previous_workspace_state: Option<WorkspaceState>,
welcome_tips_view_state: WelcomeTipsViewState,
welcome_tips_view: ViewHandle<TipsView>,
model_event_sender: Option<mpsc::SyncSender<ModelEvent>>,
launch_config_save_modal: ModalViewState<LaunchConfigSaveModal>,
tab_config_params_modal: ModalViewState<Modal<TabConfigParamsModal>>,
session_config_modal: ModalViewState<Modal<SessionConfigModal>>,
pending_session_config_replacement: Option<PendingSessionConfigReplacement>,
/// When set, the guided onboarding tutorial will start after the session
/// config modal is closed (submitted or dismissed).
pending_onboarding_intention: Option<OnboardingIntention>,
pending_session_config_tab_config_chip: bool,
show_session_config_tab_config_chip: bool,
pending_session_config_tab_config_chip_tutorial:
Option<PendingSessionConfigTabConfigChipTutorial>,
new_worktree_modal: ModalViewState<Modal<NewWorktreeModal>>,
close_session_confirmation_dialog: ViewHandle<CloseSessionConfirmationDialog>,
rewind_confirmation_dialog: ViewHandle<RewindConfirmationDialog>,
delete_conversation_confirmation_dialog: ViewHandle<DeleteConversationConfirmationDialog>,
resource_center_view: ViewHandle<ResourceCenterView>,
command_search_view: ViewHandle<CommandSearchView>,
autoupdate_unable_to_update_banner_dismissed: bool,
autoupdate_unable_to_launch_new_version: bool,
reauth_banner_dismissed: bool,
settings_file_error: Option<crate::settings::SettingsFileError>,
settings_error_banner_dismissed: bool,
ai_assistant_panel: ViewHandle<AIAssistantPanelView>,
should_show_ai_assistant_warm_welcome: bool,
ai_assistant_close_warm_welcome_mouse_state_handle: MouseStateHandle,
auth_override_warning_modal: ViewHandle<AuthOverrideWarningModal>,
require_login_modal: ViewHandle<AuthView>,
workflow_modal: ViewHandle<WorkflowModal>,
prompt_editor_modal: ViewHandle<PromptEditorModal>,
agent_toolbar_editor_modal: ViewHandle<AgentToolbarEditorModal>,
header_toolbar_editor_modal: ViewHandle<HeaderToolbarEditorModal>,
header_toolbar_context_menu: ViewHandle<Menu<WorkspaceAction>>,
show_header_toolbar_context_menu: Option<Vector2F>,
theme_creator_modal: ViewHandle<ThemeCreatorModal>,
theme_deletion_modal: ViewHandle<ThemeDeletionModal>,
suggested_agent_mode_workflow_modal: ViewHandle<SuggestedAgentModeWorkflowModal>,
suggested_rule_modal: ViewHandle<SuggestedRuleModal>,
oz_launch_modal: ModalWithTab<LaunchModal<OzLaunchSlide>>,
openwarp_launch_modal: ViewHandle<OpenWarpLaunchModal>,
enable_auto_reload_modal: ViewHandle<EnableAutoReloadModal>,
build_plan_migration_modal: ViewHandle<BuildPlanMigrationModal>,
codex_modal: ViewHandle<CodexModal>,
cloud_agent_capacity_modal: ViewHandle<CloudAgentCapacityModal>,
free_tier_limit_hit_modal: ViewHandle<FreeTierLimitHitModal>,
free_tier_limit_check_triggered: bool,
toast_stack: ViewHandle<DismissibleToastStack<WorkspaceAction>>,
agent_toast_stack: ViewHandle<AgentToastStack>,
update_toast_stack: ViewHandle<DismissibleToastStack<WorkspaceAction>>,
/// We need to render some dynamic keybindings for our tooltips. These cannot be looked up in the
/// render method, so look them up when the view is constructed and cache them here. Note that they
/// need to be kept in sync as the keybindings change.
cached_keybindings: HashMap<String, Option<String>>,
is_user_menu_open: bool,
tab_bar_pinned_by_popup: bool,
user_menu: ViewHandle<Menu<WorkspaceAction>>,
native_modal: ViewHandle<NativeModal>,
shared_objects_creation_denied_modal: ViewHandle<SharedObjectsCreationDeniedModal>,
shown_staging_banner_count: u32,
// When user's open WEB for the first time, we ask them to select a preference of
// always opening in web or opening in native app.
#[cfg(target_family = "wasm")]
show_wasm_nux_dialog: bool,
#[cfg(target_family = "wasm")]
wasm_nux_dialog: ViewHandle<WasmNUXDialog>,
#[cfg(target_family = "wasm")]
open_in_warp_button: ViewHandle<ActionButton>,
#[cfg(target_family = "wasm")]
view_cloud_runs_button: ViewHandle<ActionButton>,
#[cfg(target_family = "wasm")]
transcript_info_button: ViewHandle<ActionButton>,
#[cfg(target_family = "wasm")]
transcript_details_panel: ViewHandle<ConversationDetailsPanel>,
file_upload_sessions: FileUploadSessions,
ai_fact_view: ViewHandle<AIFactView>,
left_panel_open: bool,
vertical_tabs_panel_open: bool,
vertical_tabs_panel: VerticalTabsPanelState,
left_panel_view: ViewHandle<LeftPanelView>,
left_panel_views: Vec<ToolPanelView>,
right_panel_view: ViewHandle<RightPanelView>,
working_directories_model: ModelHandle<pane_group::WorkingDirectoriesModel>,
agent_management_view: ViewHandle<AgentManagementView>,
notification_mailbox_view: Option<ViewHandle<NotificationMailboxView>>,
notification_toast_stack: Option<ViewHandle<AgentNotificationToastStack>>,
lightbox_view: Option<ViewHandle<LightboxView>>,
hoa_onboarding_flow: Option<ViewHandle<HoaOnboardingFlow>>,
/// Pinned position for the vertical tabs callout so it doesn't move when
/// the user toggles between vertical and horizontal tabs.
hoa_vtabs_callout_pinned_position: Option<Vector2F>,
/// When true, this workspace was created to receive a transferred PaneGroup.
/// The placeholder tab will be replaced when adopt_transferred_pane_group is called.
pending_pane_group_transfer: bool,
is_drag_preview_workspace: bool,
/// Sidecar menu for submenu-parent items (Terminal, New worktree config) in the
/// new-session dropdown. Shown as a positioned overlay next to the hovered
/// parent item, following the model picker sidecar pattern.
new_session_sidecar_menu: ViewHandle<Menu<NewSessionSidecarSelection>>,
show_new_session_sidecar: bool,
worktree_sidecar_active: bool,
worktree_sidecar_search_editor: ViewHandle<EditorView>,
worktree_sidecar_search_query: String,
new_session_sidecar_add_repo_mouse_state: MouseStateHandle,
tab_config_action_sidecar_item: Option<SidecarItemKind>,
tab_config_action_sidecar_mouse_states: crate::tab_configs::action_sidecar::SidecarMouseStates,
remove_tab_config_confirmation_dialog: ViewHandle<RemoveTabConfigConfirmationDialog>,
}
impl Workspace {
pub fn is_drag_preview_workspace(&self) -> bool {
self.is_drag_preview_workspace
}
fn tab_rename_editor_font_size(ctx: &AppContext, appearance: &Appearance) -> f32 {
if FeatureFlag::VerticalTabs.is_enabled() && *TabSettings::as_ref(ctx).use_vertical_tabs {
match *TabSettings::as_ref(ctx)
.vertical_tabs_display_granularity
.value()
{
VerticalTabsDisplayGranularity::Panes => 10.,
VerticalTabsDisplayGranularity::Tabs => 12.,
}
} else {
appearance.ui_font_size()
}
}
/// Clears the worktree sidecar state and hides the sidecar.
fn clear_worktree_sidecar_state(&mut self, ctx: &mut ViewContext<Self>) {
self.show_new_session_sidecar = false;
self.worktree_sidecar_active = false;
self.worktree_sidecar_search_query.clear();
self.worktree_sidecar_search_editor
.update(ctx, |editor, ctx| {
editor.clear_buffer(ctx);
});
self.new_session_sidecar_menu.update(ctx, |menu, view_ctx| {
menu.clear_pinned_header_builder();
menu.clear_pinned_footer_builder();
menu.set_content_padding_overrides(None, None);
menu.reset_selection(view_ctx);
});
}
fn close_new_session_dropdown_menu(&mut self, ctx: &mut ViewContext<Self>) {
self.show_new_session_dropdown_menu = None;
self.tab_config_action_sidecar_item = None;
self.clear_worktree_sidecar_state(ctx);
self.new_session_dropdown_menu.update(ctx, |menu, _| {
menu.set_safe_zone_target(None);
menu.set_submenu_being_shown_for_item_index(None);
});
ctx.notify();
}
fn select_first_worktree_sidecar_repo(&mut self, ctx: &mut ViewContext<Self>) {
self.new_session_sidecar_menu.update(ctx, |menu, view_ctx| {
if menu.items_len() > 1 {
menu.set_selected_by_index(1, view_ctx);
} else {
menu.reset_selection(view_ctx);
}
});
}
fn reset_worktree_sidecar_repo_selection(&mut self, ctx: &mut ViewContext<Self>) {
self.new_session_sidecar_menu.update(ctx, |menu, view_ctx| {
menu.reset_selection(view_ctx);
});
}
fn navigate_worktree_sidecar_selection(
&mut self,
select_next: bool,
ctx: &mut ViewContext<Self>,
) {
self.new_session_sidecar_menu.update(ctx, |menu, view_ctx| {
let items_len = menu.items_len();
if items_len <= 1 {
return;
}
match menu.selected_index() {
Some(_) if select_next => menu.select_next(view_ctx),
Some(_) => menu.select_previous(view_ctx),
None if select_next => menu.set_selected_by_index(1, view_ctx),
None => menu.set_selected_by_index(items_len.saturating_sub(1), view_ctx),
}
});
}
fn confirm_worktree_sidecar_selection(&mut self, ctx: &mut ViewContext<Self>) {
let selected_selection = self.new_session_sidecar_menu.update(ctx, |menu, view_ctx| {
if menu.items_len() <= 1 {
return None;
}
if menu.selected_index().is_none() {
menu.set_selected_by_index(1, view_ctx);
}
menu.selected_item().and_then(|item| match item {
MenuItem::Item(fields) => fields.on_select_action().cloned(),
_ => None,
})
});
if let Some(selection) = selected_selection {
self.execute_new_session_sidecar_selection(selection, ctx);
self.close_new_session_dropdown_menu(ctx);
}
}
fn sync_new_session_sidecar_selection_to_hover(&mut self, ctx: &mut ViewContext<Self>) {
self.new_session_sidecar_menu.update(ctx, |menu, view_ctx| {
let Some(hovered_index) = menu.hovered_index() else {
return;
};
let hovered_item_has_action = menu
.items()
.get(hovered_index)
.and_then(MenuItem::item_on_select_action)
.is_some();
if hovered_item_has_action && menu.selected_index() != Some(hovered_index) {
menu.set_selected_by_index(hovered_index, view_ctx);
}
});
}
fn build_worktree_sidecar_search_input(ctx: &mut ViewContext<Self>) -> ViewHandle<EditorView> {
let editor = ctx.add_typed_action_view(|ctx| {
let appearance = Appearance::as_ref(ctx);
let mut editor = EditorView::single_line(
SingleLineEditorOptions {
text: TextOptions::ui_text(Some(appearance.ui_font_size()), appearance),
select_all_on_focus: true,
clear_selections_on_blur: true,
propagate_and_no_op_vertical_navigation_keys:
PropagateAndNoOpNavigationKeys::Always,
..Default::default()
},
ctx,
);
editor.set_placeholder_text("Search repos", ctx);
editor
});
ctx.subscribe_to_view(&editor, |me, editor_view, event, ctx| match event {
EditorEvent::Edited(_) => {
me.worktree_sidecar_search_query = editor_view.as_ref(ctx).buffer_text(ctx);
me.refresh_worktree_sidecar_if_active(ctx);
ctx.notify();
}
EditorEvent::Escape => {
me.close_new_session_dropdown_menu(ctx);
}
EditorEvent::Navigate(NavigationKey::Up) => {
me.navigate_worktree_sidecar_selection(false, ctx);
}
EditorEvent::Navigate(NavigationKey::Down) => {
me.navigate_worktree_sidecar_selection(true, ctx);
}
EditorEvent::Enter => {
me.confirm_worktree_sidecar_selection(ctx);
}
_ => {}
});
editor
}
fn vertical_tabs_search_input(ctx: &mut ViewContext<Self>) -> ViewHandle<EditorView> {
let editor = ctx.add_typed_action_view(|ctx| {
let appearance = Appearance::as_ref(ctx);
let options = SingleLineEditorOptions {
text: TextOptions::ui_text(Some(12.), appearance),
..Default::default()
};
EditorView::single_line(options, ctx)
});
editor.update(ctx, |editor, ctx| {
editor.set_placeholder_text("Search tabs...", ctx);
});
ctx.subscribe_to_view(&editor, |me, editor_view, event, ctx| match event {
EditorEvent::Edited(_) => {
me.vertical_tabs_panel.search_query = editor_view.as_ref(ctx).buffer_text(ctx);
ctx.notify();
}
EditorEvent::Escape => {
me.vertical_tabs_panel.search_query.clear();
me.focus_active_tab(ctx);
}
_ => {}
});
editor
}
fn tab_rename_editor(ctx: &mut ViewContext<Self>) -> ViewHandle<EditorView> {
let editor = {
ctx.add_typed_action_view(|ctx| {
let appearance = Appearance::as_ref(ctx);
let options = SingleLineEditorOptions {
text: TextOptions::ui_text(
Some(Self::tab_rename_editor_font_size(ctx, appearance)),
appearance,
),
..Default::default()
};
EditorView::single_line(options, ctx)
})
};
ctx.subscribe_to_view(&editor, move |me, _, event, ctx| {
me.handle_tab_rename_editor_event(event, ctx);
});
editor
}
fn pane_rename_editor(ctx: &mut ViewContext<Self>) -> ViewHandle<EditorView> {
let editor = ctx.add_typed_action_view(|ctx| {
let appearance = Appearance::as_ref(ctx);
let options = SingleLineEditorOptions {
text: TextOptions::ui_text(Some(12.), appearance),
..Default::default()
};
EditorView::single_line(options, ctx)
});
ctx.subscribe_to_view(&editor, move |me, _, event, ctx| {
me.handle_pane_rename_editor_event(event, ctx);
});
editor
}
pub fn handle_tab_rename_editor_event(
&mut self,
event: &EditorEvent,
ctx: &mut ViewContext<Self>,
) {
if self.current_workspace_state.is_tab_being_renamed() {
match event {
EditorEvent::Blurred | EditorEvent::Enter => {
self.finish_tab_rename(ctx);
}
EditorEvent::Escape => {
self.cancel_tab_rename(ctx);
}
_ => {}
}
}
}
pub fn handle_pane_rename_editor_event(
&mut self,
event: &EditorEvent,
ctx: &mut ViewContext<Self>,
) {
if self.current_workspace_state.is_any_pane_being_renamed() {
match event {
EditorEvent::Blurred | EditorEvent::Enter => {
self.finish_pane_rename(ctx);
}
EditorEvent::Escape => {
self.cancel_pane_rename(ctx);
}
_ => {}
}
}
}
fn finish_tab_rename(&mut self, ctx: &mut ViewContext<Self>) {
if let Some(tab_index) = self.current_workspace_state.tab_being_renamed() {
self.current_workspace_state.clear_tab_being_renamed();
let title = self.tab_rename_editor.as_ref(ctx).buffer_text(ctx);
let tab = &self.tabs[tab_index];
tab.pane_group.update(ctx, |view, ctx| {
// Only update the title if it was actually changed. Otherwise, lets assume
// user's intend was to cancel the operation.
if view.display_title(ctx) != title {
send_telemetry_from_ctx!(
TelemetryEvent::TabRenamed(TabRenameEvent::CustomNameSet),
ctx
);
view.set_title(&title, ctx);
}
});
self.clear_tab_name_editor(ctx);
self.update_window_title(ctx);
ctx.notify();
}
}
fn finish_pane_rename(&mut self, ctx: &mut ViewContext<Self>) {
let Some(locator) = self.current_workspace_state.pane_being_renamed() else {
return;
};
self.current_workspace_state.clear_pane_being_renamed();
let title = self.pane_rename_editor.as_ref(ctx).buffer_text(ctx);
self.set_custom_pane_name(locator, title, ctx);
self.clear_pane_name_editor(ctx);
self.focus_pane(locator, ctx);
ctx.dispatch_global_action("workspace:save_app", ());
ctx.notify();
}
fn cancel_tab_rename(&mut self, ctx: &mut ViewContext<Self>) {
if self.current_workspace_state.is_tab_being_renamed() {
self.current_workspace_state.clear_tab_being_renamed();
self.clear_tab_name_editor(ctx);
self.focus_active_tab(ctx);
ctx.notify();
}
}
fn cancel_pane_rename(&mut self, ctx: &mut ViewContext<Self>) {
if let Some(locator) = self.current_workspace_state.pane_being_renamed() {
self.current_workspace_state.clear_pane_being_renamed();
self.clear_pane_name_editor(ctx);
self.focus_pane(locator, ctx);
ctx.notify();
}
}
fn build_import_modal(ctx: &mut ViewContext<Self>) -> ViewHandle<ImportModal> {
let modal = ctx.add_typed_action_view(ImportModal::new);
ctx.subscribe_to_view(&modal, |me, _, event, ctx| {
me.handle_import_modal_event(event, ctx);
});
modal
}
fn handle_import_modal_event(&mut self, event: &ImportModalEvent, ctx: &mut ViewContext<Self>) {
match event {
ImportModalEvent::OpenTargetWithHashedId(server_id) => {
self.current_workspace_state.is_import_modal_open = false;
let mut id_to_force_expand = None;
if let Some(notebook) = CloudModel::as_ref(ctx).get_notebook_by_uid(server_id) {
// Note that we had to call each CloudModel individually here because the IDs all have different typings.
// TODO: @ianhodge - clean this up once generic is cleared.
id_to_force_expand = Some(notebook.id);
}
if let Some(id) = id_to_force_expand {
self.open_notebook(
&NotebookSource::Existing(id),
&OpenWarpDriveObjectSettings::default(),
ctx,
true,
);
CloudModel::handle(ctx).update(ctx, |cloud_model, ctx| {
cloud_model.force_expand_object_and_ancestors(id, ctx);
});
}
let mut id_to_force_expand = None;
if let Some(workflow) = CloudModel::as_ref(ctx).get_workflow_by_uid(server_id) {
id_to_force_expand = Some(workflow.id);
}
if let Some(id) = id_to_force_expand {
self.open_workflow_with_existing(
id,
&OpenWarpDriveObjectSettings::default(),
ctx,
);
CloudModel::handle(ctx).update(ctx, |cloud_model, ctx| {
cloud_model.force_expand_object_and_ancestors(id, ctx);
});
}
let mut id_to_force_expand = None;
if let Some(folder) = CloudModel::as_ref(ctx).get_folder_by_uid(server_id) {
id_to_force_expand = Some(folder.id);
}
if let Some(id) = id_to_force_expand {
CloudModel::handle(ctx).update(ctx, |cloud_model, ctx| {
cloud_model.force_expand_object_and_ancestors(id, ctx);
});
}
ctx.notify();
}
ImportModalEvent::Close => {
self.current_workspace_state.is_import_modal_open = false;
ctx.notify();
}
}
}
fn build_prompt_editor_modal(ctx: &mut ViewContext<Self>) -> ViewHandle<PromptEditorModal> {
let modal = ctx.add_typed_action_view(PromptEditorModal::new);
ctx.subscribe_to_view(&modal, |me, _, event, ctx| {
me.handle_prompt_editor_modal_event(event, ctx);
});
modal
}
fn build_agent_toolbar_editor_modal(
ctx: &mut ViewContext<Self>,
) -> ViewHandle<AgentToolbarEditorModal> {
let modal = ctx.add_typed_action_view(AgentToolbarEditorModal::new);
ctx.subscribe_to_view(&modal, |me, _, event, ctx| {
me.handle_agent_toolbar_editor_modal_event(event, ctx);
});
modal
}
fn build_reward_modal(ctx: &mut ViewContext<Self>) -> ViewHandle<Modal<RewardView>> {
let reward_view = ctx.add_typed_action_view(|_| RewardView::new());
ctx.subscribe_to_view(&reward_view, |me, _, event, ctx| {
me.handle_reward_view_event(event, ctx);
});
let modal = ctx.add_typed_action_view(|ctx| {
Modal::new(Some(String::new()), reward_view, ctx)
.with_modal_style(UiComponentStyles {
width: Some(316.),
height: Some(389.),
..Default::default()
})
.with_body_style(UiComponentStyles {
height: Some(319.),
padding: Some(Coords {
// Default padding values except for the top, which is too much for the
// reward modal
top: 0.,
bottom: 28.,
left: 28.,
right: 28.,
}),
..Default::default()
})
.with_dismiss_on_click()
});
ctx.subscribe_to_view(&modal, |me, _, event, ctx| {
me.handle_reward_modal_event(event, ctx);
});
modal
}
fn build_welcome_tips(
tips_completed: ModelHandle<TipsCompleted>,
ctx: &mut ViewContext<Self>,
) -> (ViewHandle<TipsView>, WelcomeTipsViewState) {
let welcome_tips_view = ctx.add_typed_action_view(|ctx| {
TipsView::new(tips_completed.clone(), WELCOME_TIPS_POSITION_ID.into(), ctx)
});
ctx.subscribe_to_view(&welcome_tips_view, move |me, _, event, ctx| {
me.handle_welcome_tips_event(event, ctx);
});
let show_welcome_tips = !tips_completed.as_ref(ctx).skipped_or_completed;
let welcome_tips_view_state = if show_welcome_tips {
WelcomeTipsViewState::Available {
is_popup_open: false,
}
} else {
WelcomeTipsViewState::Unavailable
};
(welcome_tips_view, welcome_tips_view_state)
}
fn build_ai_assistant_panel_view(
ctx: &mut ViewContext<Self>,
server_api: Arc<ServerApi>,
ai_client: Arc<dyn AIClient>,
) -> ViewHandle<AIAssistantPanelView> {
let ai_assistant_panel =
ctx.add_typed_action_view(|ctx| AIAssistantPanelView::new(server_api, ai_client, ctx));
ctx.subscribe_to_view(&ai_assistant_panel, |me, _, event, ctx| {
me.handle_ai_assistant_panel_event(event, ctx);
});
ai_assistant_panel
}
fn build_resource_center_view(
ctx: &mut ViewContext<Self>,
tips_completed: ModelHandle<TipsCompleted>,
changelog_model_handle: ModelHandle<ChangelogModel>,
) -> ViewHandle<ResourceCenterView> {
let resource_center_view = ctx.add_typed_action_view(|ctx| {
ResourceCenterView::new(ctx, tips_completed.clone(), changelog_model_handle)
});
ctx.subscribe_to_view(&resource_center_view, |me, _, event, ctx| {
me.handle_resource_center_event(event, ctx);
});
resource_center_view
}
fn build_settings_views(
global_resource_handles: GlobalResourceHandles,
tips_completed: ModelHandle<TipsCompleted>,
ctx: &mut ViewContext<Self>,
) -> (ViewHandle<SettingsView>, ViewHandle<ThemeChooser>) {
let theme_chooser_view = ctx.add_typed_action_view(|ctx| {
ThemeChooser::new(
global_resource_handles.referral_theme_status,
ctx,
tips_completed,
)
});
ctx.subscribe_to_view(&theme_chooser_view, |me, _, event, ctx| {
me.handle_theme_chooser_event(event, ctx);
});
let settings_pane = ctx.add_typed_action_view(move |ctx| SettingsView::new(None, ctx));
ctx.subscribe_to_view(&settings_pane, move |me, _, event, ctx| {
me.handle_settings_pane_event(event, ctx);
});
let window_id = ctx.window_id();
SettingsPaneManager::handle(ctx).update(ctx, |manager, _| {
manager.register_view(window_id, settings_pane.clone());
});
(settings_pane, theme_chooser_view)
}
fn build_require_login_modal(ctx: &mut ViewContext<Self>) -> ViewHandle<AuthView> {
let require_login_modal = ctx.add_typed_action_view(|ctx| {
AuthView::new(AuthViewVariant::RequireLoginCloseable, ctx)
});
ctx.subscribe_to_view(&require_login_modal, move |me, _, event, ctx| {
me.handle_require_login_modal_event(event, ctx);
});
require_login_modal
}
fn build_auth_override_warning_modal(
ctx: &mut ViewContext<Self>,
) -> ViewHandle<AuthOverrideWarningModal> {
let auth_override_warning_modal = ctx.add_typed_action_view(|ctx| {
AuthOverrideWarningModal::new(ctx, AuthOverrideWarningModalVariant::WorkspaceModal)
});
ctx.subscribe_to_view(&auth_override_warning_modal, |me, _, event, ctx| {
me.handle_auth_override_warning_modal_event(event, ctx);
});
auth_override_warning_modal
}
fn handle_auth_override_warning_modal_event(
&mut self,
event: &AuthOverrideWarningModalEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
AuthOverrideWarningModalEvent::Close => {
self.current_workspace_state.is_auth_override_modal_open = false;
ctx.notify();
}
AuthOverrideWarningModalEvent::BulkExport => {
self.export_all_warp_drive_objects(ctx);
}
}
}
fn build_workflow_modal(
ai_client: Arc<dyn AIClient>,
ctx: &mut ViewContext<Self>,
) -> ViewHandle<WorkflowModal> {
let workflow_modal =
ctx.add_typed_action_view(|ctx| WorkflowModal::new(ai_client.clone(), ctx));
ctx.subscribe_to_view(&workflow_modal, |me, _, event, ctx| {
me.handle_workflow_modal_event(event, ctx);
});
workflow_modal
}
fn build_theme_creator_modal(ctx: &mut ViewContext<Self>) -> ViewHandle<ThemeCreatorModal> {
let theme_creator_modal = ctx.add_typed_action_view(ThemeCreatorModal::new);
ctx.subscribe_to_view(&theme_creator_modal, move |me, _, event, ctx| {
me.handle_theme_creator_modal_event(event, ctx);
});
theme_creator_modal
}
fn build_theme_deletion_modal(ctx: &mut ViewContext<Self>) -> ViewHandle<ThemeDeletionModal> {
let theme_deletion_modal = ctx.add_typed_action_view(ThemeDeletionModal::new);
ctx.subscribe_to_view(&theme_deletion_modal, move |me, _, event, ctx| {
me.handle_theme_deletion_modal_event(event, ctx);
});
theme_deletion_modal
}
fn build_suggested_agent_mode_workflow_modal(
ctx: &mut ViewContext<Self>,
) -> ViewHandle<SuggestedAgentModeWorkflowModal> {
let suggested_agent_mode_workflow_modal =
ctx.add_typed_action_view(|_| SuggestedAgentModeWorkflowModal::default());
ctx.subscribe_to_view(&suggested_agent_mode_workflow_modal, |me, _, event, ctx| {
me.handle_suggested_agent_mode_workflow_modal_event(event, ctx);
});
suggested_agent_mode_workflow_modal
}
fn build_suggested_rule_modal(ctx: &mut ViewContext<Self>) -> ViewHandle<SuggestedRuleModal> {
let suggested_rule_modal = ctx.add_typed_action_view(SuggestedRuleModal::new);
ctx.subscribe_to_view(&suggested_rule_modal, |me, _, event, ctx| {
me.handle_suggested_rule_modal_event(event, ctx);
});
suggested_rule_modal
}
fn handle_suggested_rule_modal_event(
&mut self,
event: &SuggestedRuleModalEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
SuggestedRuleModalEvent::AddNewRule { rule } => {
self.current_workspace_state.is_suggested_rule_modal_open = false;
send_telemetry_from_ctx!(
TelemetryEvent::AISuggestedRuleAdded {
rule_id: rule.logging_id.clone(),
},
ctx
);
}
SuggestedRuleModalEvent::OpenRuleForEditing { rule } => {
self.current_workspace_state.is_suggested_rule_modal_open = false;
self.open_ai_fact_collection_pane(Some(Direction::Right), None, ctx);
send_telemetry_from_ctx!(
TelemetryEvent::AISuggestedRuleEdited {
rule_id: rule.logging_id.clone(),
},
ctx
);
}
SuggestedRuleModalEvent::Close => {
self.current_workspace_state.is_suggested_rule_modal_open = false;
self.focus_active_tab(ctx);
}
}
ctx.notify();
}
fn build_close_session_confirmation_dialog(
ctx: &mut ViewContext<Self>,
) -> ViewHandle<CloseSessionConfirmationDialog> {
let close_session_confirmation_dialog =
ctx.add_typed_action_view(|_| CloseSessionConfirmationDialog::new());
ctx.subscribe_to_view(
&close_session_confirmation_dialog,
move |me, _, event, ctx| {
me.handle_close_session_confirmation_dialog_event(event, ctx);
},
);
close_session_confirmation_dialog
}
fn build_rewind_confirmation_dialog(
ctx: &mut ViewContext<Self>,
) -> ViewHandle<RewindConfirmationDialog> {
let rewind_confirmation_dialog =
ctx.add_typed_action_view(|_| RewindConfirmationDialog::new());
ctx.subscribe_to_view(&rewind_confirmation_dialog, move |me, _, event, ctx| {
me.handle_rewind_confirmation_dialog_event(event, ctx);
});
rewind_confirmation_dialog
}
fn build_delete_conversation_confirmation_dialog(
ctx: &mut ViewContext<Self>,
) -> ViewHandle<DeleteConversationConfirmationDialog> {
let delete_conversation_confirmation_dialog =
ctx.add_typed_action_view(DeleteConversationConfirmationDialog::new);
ctx.subscribe_to_view(
&delete_conversation_confirmation_dialog,
move |me, _, event, ctx| {
me.handle_delete_conversation_confirmation_dialog_event(event, ctx);
},
);
delete_conversation_confirmation_dialog
}
fn build_native_modal_view(ctx: &mut ViewContext<Self>) -> ViewHandle<NativeModal> {
let native_modal = ctx.add_typed_action_view(NativeModal::new);
ctx.subscribe_to_view(&native_modal, move |me, _, event, ctx| {
me.handle_native_modal_event(event, ctx);
});
native_modal
}
fn build_tab_bar_overflow_menu(
ctx: &mut ViewContext<Self>,
) -> ViewHandle<Menu<WorkspaceAction>> {
let tab_bar_overflow_menu = ctx.add_typed_action_view(|_| {
Menu::new()
.with_width(TAB_BAR_OVERFLOW_MENU_WIDTH)
.with_drop_shadow()
});
ctx.subscribe_to_view(&tab_bar_overflow_menu, move |me, _, event, ctx| {
me.handle_tab_bar_overflow_menu_event(event, ctx);
});
tab_bar_overflow_menu
}
fn build_menus(ctx: &mut ViewContext<Self>) -> WorkspaceMenuHandles {
let tab_right_click_menu = ctx.add_typed_action_view(|_| Menu::new());
ctx.subscribe_to_view(&tab_right_click_menu, move |me, _, event, ctx| {
me.handle_tab_right_click_menu_event(event, ctx);
});
// Currently setting the width to 300 px as a middle ground that looks
// ok when the shells show the path to the executables, and when they
// don't. Going forward we may want to enhance the menu to allow for a
// `max_width` and `min_width` instead, so we can allow the menu to
// grow as needed.
const NEW_SESSION_MENU_WIDTH: f32 = 300.;
let new_session_menu = ctx.add_typed_action_view(|ctx| {
if FeatureFlag::ShellSelector.is_enabled() {
let theme = Appearance::as_ref(ctx).theme();
Menu::new()
.with_width(NEW_SESSION_MENU_WIDTH)
.with_border(Border::all(1.).with_border_color(theme.outline().into()))
.with_drop_shadow()
.with_safe_triangle()
.with_ignore_hover_when_covered()
.prevent_interaction_with_other_elements()
} else {
Menu::new()
.with_safe_triangle()
.with_ignore_hover_when_covered()
}
});
ctx.subscribe_to_view(&new_session_menu, move |me, _, event, ctx| {
me.handle_new_session_menu_event(event, ctx);
});
let new_session_sidecar = ctx.add_typed_action_view(|_ctx| {
let mut menu = Menu::new()
.without_item_action_dispatch()
.with_width(NEW_SESSION_SIDECAR_WIDTH)
.with_menu_variant(crate::menu::MenuVariant::scrollable());
menu.set_height(400.);
menu
});
ctx.subscribe_to_view(&new_session_sidecar, move |me, _, event, ctx| {
me.handle_new_session_sidecar_event(event, ctx);
});
(tab_right_click_menu, new_session_menu, new_session_sidecar)
}
fn build_launch_config_save_modal(
ctx: &mut ViewContext<Self>,
) -> ModalViewState<LaunchConfigSaveModal> {
let launch_config_save_modal = ctx.add_typed_action_view(LaunchConfigSaveModal::new);
ctx.subscribe_to_view(&launch_config_save_modal, move |me, _, event, ctx| {
me.handle_launch_config_save_modal_event(event, ctx);
});
ModalViewState::new(launch_config_save_modal)
}
fn build_tab_config_params_modal(
ctx: &mut ViewContext<Self>,
) -> ModalViewState<Modal<TabConfigParamsModal>> {
let body = ctx.add_typed_action_view(TabConfigParamsModal::new);
// Subscribe to body events before moving `body` into the Modal closure.
ctx.subscribe_to_view(&body, |me, _, event, ctx| {
me.handle_tab_config_params_modal_body_event(event, ctx);
});
let modal = ctx.add_typed_action_view(|ctx| {
Modal::new(None, body, ctx)
.with_modal_style(UiComponentStyles {
width: Some(460.),
height: Some(480.),
..Default::default()
})
.with_body_style(UiComponentStyles {
padding: Some(Coords::uniform(0.)),
height: Some(480.),
background: Some(ElementFill::None),
..Default::default()
})
.with_dismiss_on_click()
});
ctx.subscribe_to_view(&modal, |me, _, event, ctx| {
me.handle_tab_config_params_modal_event(event, ctx);
});
ModalViewState::new(modal)
}
fn build_session_config_modal(
ctx: &mut ViewContext<Self>,
) -> ModalViewState<Modal<SessionConfigModal>> {
let body = ctx.add_typed_action_view(SessionConfigModal::new);
ctx.subscribe_to_view(&body, |me, _, event, ctx| {
me.handle_session_config_modal_event(event, ctx);
});
let modal = ctx.add_typed_action_view(|ctx| {
Modal::new(None, body, ctx)
.close_modal_button_disabled()
.with_modal_style(UiComponentStyles {
width: Some(424.),
..Default::default()
})
.with_background_opacity(0)
.with_body_style(UiComponentStyles {
padding: Some(Coords::uniform(0.)),
..Default::default()
})
.with_header_style(UiComponentStyles {
height: Some(0.),
padding: Some(Coords::uniform(0.)),
..Default::default()
})
});
ctx.subscribe_to_view(&modal, |me, _, event, ctx| {
if matches!(event, ModalEvent::Close) {
me.close_session_config_modal(ctx);
}
});
ModalViewState::new(modal)
}
fn build_new_worktree_modal(
ctx: &mut ViewContext<Self>,
) -> ModalViewState<Modal<NewWorktreeModal>> {
let body = ctx.add_typed_action_view(NewWorktreeModal::new);
ctx.subscribe_to_view(&body, |me, _, event, ctx| {
me.handle_new_worktree_modal_body_event(event, ctx);
});
let modal = ctx.add_typed_action_view(|ctx| {
// We intentionally pass `None` for the title so the Modal renders
// no built-in header — the body view renders its own header to
// match the Figma mock exactly (bold title + X close + ESC badge).
Modal::new(None, body, ctx)
.with_modal_style(UiComponentStyles {
width: Some(460.),
height: Some(480.),
..Default::default()
})
.with_body_style(UiComponentStyles {
padding: Some(Coords {
top: 0.,
bottom: 0.,
left: 0.,
right: 0.,
}),
height: Some(480.),
background: Some(ElementFill::None),
..Default::default()
})
});
ctx.subscribe_to_view(&modal, |me, _, event, ctx| {
me.handle_new_worktree_modal_event(event, ctx);
});
ModalViewState::new(modal)
}
fn build_remove_tab_config_confirmation_dialog(
ctx: &mut ViewContext<Self>,
) -> ViewHandle<RemoveTabConfigConfirmationDialog> {
let dialog = ctx.add_typed_action_view(RemoveTabConfigConfirmationDialog::new);
ctx.subscribe_to_view(&dialog, |me, _, event, ctx| {
me.handle_remove_tab_config_confirmation_event(event, ctx);
});
dialog
}
#[cfg(feature = "local_fs")]
fn handle_remove_tab_config_confirmation_event(
&mut self,
event: &RemoveTabConfigConfirmationEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
RemoveTabConfigConfirmationEvent::Confirm { path } => {
// If the removed config was the default, revert to Terminal.
let ai_settings = AISettings::as_ref(ctx);
let is_removed_default = ai_settings.default_session_mode(ctx)
== DefaultSessionMode::TabConfig
&& ai_settings.default_tab_config_path() == path.to_string_lossy();
if is_removed_default {
AISettings::handle(ctx).update(ctx, |settings, ctx| {
report_if_error!(settings
.default_session_mode_internal
.set_value(DefaultSessionMode::Terminal, ctx));
report_if_error!(settings
.default_tab_config_path
.set_value(String::new(), ctx));
});
}
if let Err(e) = std::fs::remove_file(path) {
log::warn!("Failed to remove tab config file: {e:?}");
self.toast_stack.update(ctx, |toast_stack, ctx| {
toast_stack.add_ephemeral_toast(
DismissibleToast::error(format!("Failed to remove tab config: {e}")),
ctx,
);
});
} else {
WarpConfig::handle(ctx).update(ctx, |warp_config, ctx| {
warp_config.remove_tab_config_by_path(path, ctx);
});
}
self.current_workspace_state
.is_remove_tab_config_dialog_open = false;
ctx.notify();
}
RemoveTabConfigConfirmationEvent::Cancel => {
self.current_workspace_state
.is_remove_tab_config_dialog_open = false;
ctx.notify();
}
}
}
#[cfg(not(feature = "local_fs"))]
fn handle_remove_tab_config_confirmation_event(
&mut self,
_event: &RemoveTabConfigConfirmationEvent,
_ctx: &mut ViewContext<Self>,
) {
log::error!("Cannot delete a tab config from the web");
}
fn handle_session_config_modal_event(
&mut self,
event: &SessionConfigModalEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
SessionConfigModalEvent::Completed(selection) => {
let pending_intention = self.pending_onboarding_intention.take();
send_telemetry_from_ctx!(
TabConfigsTelemetryEvent::GuidedModalSubmitted {
session_type: GuidedModalSessionType::from(&selection.session_type),
enable_worktree: selection.enable_worktree,
autogenerate_worktree_branch_name: selection
.autogenerate_worktree_branch_name,
},
ctx
);
self.close_session_config_modal(ctx);
let has_worktree = selection.enable_worktree;
let has_params = {
use crate::tab_configs::session_config::build_tab_config;
let config = build_tab_config(
&selection.session_type,
&selection.directory,
selection.enable_worktree,
selection.autogenerate_worktree_branch_name,
);
!config.params.is_empty()
};
self.handle_session_config_completed(selection, ctx);
if let Some(intention) = pending_intention {
if has_worktree && has_params {
// Worktree with params modal: the tab hasn't been
// created yet. Keep the intention so the params modal
// handler can queue the tutorial after it closes.
self.pending_onboarding_intention = Some(intention);
} else if has_worktree {
self.queue_onboarding_tutorial_after_session_config_tab_config_chip(
PendingSessionConfigTabConfigChipTutorial::AfterSetupCommands {
intention,
},
ctx,
);
} else {
// No worktree: tab is ready. Start the tutorial after
// the tab-config chip is dismissed.
// TODO(roland): We do have a directory in this case so we could consider passing has_project = true
// which has an optional /init flow. But the behavior of /init needs to be revisited:
// 1. Sends /init as a query which differs in behavior from /init slash command
// 2. Sends /init even if not in a git repo - unclear if this should happen (depends on desired behavior from 1)
// 3. With no free AI, /init will not work.
self.queue_onboarding_tutorial_after_session_config_tab_config_chip(
PendingSessionConfigTabConfigChipTutorial::WhenBootstrapped {
has_project: false,
intention,
},
ctx,
);
}
}
// Show the chip only when no params modal followed.
if !self.current_workspace_state.is_tab_config_params_modal_open {
self.promote_session_config_tab_config_chip(ctx);
}
}
SessionConfigModalEvent::Dismissed => {
let pending_intention = self.pending_onboarding_intention.take();
// No tab config was created, so don't show the chip.
self.pending_session_config_tab_config_chip = false;
self.close_session_config_modal(ctx);
// Start the onboarding tutorial without project context.
if let Some(intention) = pending_intention {
self.dispatch_tutorial_when_bootstrapped(false, intention, ctx);
}
}
}
}
/// Stores an onboarding intention so the guided tutorial starts after the
/// session config modal is closed.
pub(crate) fn set_pending_onboarding_intention(&mut self, intention: OnboardingIntention) {
self.pending_onboarding_intention = Some(intention);
}
#[cfg(feature = "local_fs")]
fn handle_session_config_completed(
&mut self,
selection: &crate::tab_configs::session_config::SessionConfigSelection,
ctx: &mut ViewContext<Self>,
) {
use crate::tab_configs::session_config::{build_tab_config, write_tab_config};
// Build a TabConfig.
let config = build_tab_config(
&selection.session_type,
&selection.directory,
selection.enable_worktree,
selection.autogenerate_worktree_branch_name,
);
let old_pane_group_id = self.active_tab_pane_group().id();
let has_params = !config.params.is_empty();
// Save and open the tab config. The user's `default_session_mode`
// is intentionally left untouched: creating a tab config should not
// change the global default for new tabs.
// Agent view entry for Oz is handled by PaneMode::Agent in the tab config,
// so no manual enter_agent_view call is needed.
let dir = crate::user_config::tab_configs_dir();
if let Err(e) = write_tab_config(&config, &dir, "startup_config") {
log::warn!("Failed to write startup tab config: {e:?}");
}
if has_params {
// When the config has params (worktree), open_tab_config shows the
// params modal instead of creating the tab immediately.
// Store the replacement context so we can finish when the modal completes.
self.pending_session_config_replacement =
Some(PendingSessionConfigReplacement { old_pane_group_id });
self.open_tab_config(config, ctx);
} else {
let worktree_branch_name = self.maybe_generate_worktree_name(&config);
let param_values = config.default_param_values();
self.open_tab_config_with_params(
config,
param_values,
worktree_branch_name.as_deref(),
ctx,
);
self.remove_tab_by_pane_group_id(old_pane_group_id, ctx);
}
}
#[cfg(not(feature = "local_fs"))]
fn handle_session_config_completed(
&mut self,
_selection: &crate::tab_configs::session_config::SessionConfigSelection,
_ctx: &mut ViewContext<Self>,
) {
}
/// Opens the vertical tabs panel if the setting was enabled.
/// Called from the onboarding flow before the session config modal is shown.
pub(crate) fn open_vertical_tabs_panel_if_enabled(&mut self, ctx: &mut ViewContext<Self>) {
if FeatureFlag::VerticalTabs.is_enabled() && *TabSettings::as_ref(ctx).use_vertical_tabs {
self.vertical_tabs_panel_open = true;
self.sync_window_button_visibility(ctx);
ctx.notify();
}
}
fn show_hoa_onboarding_flow(&mut self, ctx: &mut ViewContext<Self>) {
// Mark as completed immediately so the flow is never shown again,
// even if the user quits mid-flow.
mark_hoa_onboarding_completed(ctx);
// Enable vertical tabs and open the panel so Step 2 has something to anchor to.
TabSettings::handle(ctx).update(ctx, |settings, ctx| {
let _ = settings.use_vertical_tabs.set_value(true, ctx);
});
self.vertical_tabs_panel_open = true;
self.sync_window_button_visibility(ctx);
// The pinned position is captured lazily on the first step change
// (when the user advances past the welcome banner). At that point the
// vertical tabs panel has been rendered for several frames and the save
// position is accurate.
self.hoa_vtabs_callout_pinned_position = None;
let flow = ctx.add_typed_action_view(HoaOnboardingFlow::new);
ctx.subscribe_to_view(&flow, |me, _, event, ctx| match event {
HoaOnboardingFlowEvent::StepChanged | HoaOnboardingFlowEvent::TabLayoutToggled => {
if me.hoa_vtabs_callout_pinned_position.is_none() {
me.hoa_vtabs_callout_pinned_position = ctx
.element_position_by_id(VERTICAL_TABS_PANEL_POSITION_ID)
.map(|rect| vec2f(rect.max_x(), rect.min_y() + 8.));
}
if let Some(flow) = &me.hoa_onboarding_flow {
ctx.focus(flow);
}
ctx.notify();
}
_ => me.handle_hoa_onboarding_event(event, ctx),
});
self.hoa_onboarding_flow = Some(flow.clone());
ctx.focus(&flow);
ctx.notify();
}
fn handle_hoa_onboarding_event(
&mut self,
event: &HoaOnboardingFlowEvent,
ctx: &mut ViewContext<Self>,
) {
OneTimeModalModel::handle(ctx).update(ctx, |model, ctx| {
model.mark_hoa_onboarding_dismissed(ctx);
});
match event {
HoaOnboardingFlowEvent::Completed(Some(selection)) => {
self.hoa_onboarding_flow = None;
self.handle_session_config_completed(selection, ctx);
}
HoaOnboardingFlowEvent::Completed(None) | HoaOnboardingFlowEvent::Dismissed => {
self.hoa_onboarding_flow = None;
}
HoaOnboardingFlowEvent::StepChanged | HoaOnboardingFlowEvent::TabLayoutToggled => {
return;
}
}
self.focus_active_tab(ctx);
ctx.notify();
}
pub(crate) fn show_session_config_modal(&mut self, ctx: &mut ViewContext<Self>) {
// Configure the modal to hide Oz when AI is disabled.
let show_oz = AISettings::as_ref(ctx).is_any_ai_enabled(ctx);
self.session_config_modal.view.update(ctx, |modal, ctx| {
modal.body().update(ctx, |body, ctx| {
body.configure(show_oz);
ctx.notify();
});
});
self.session_config_modal.open();
self.current_workspace_state.is_session_config_modal_open = true;
self.pending_session_config_tab_config_chip = self.pending_onboarding_intention.is_some();
self.show_session_config_tab_config_chip = false;
ctx.focus(&self.session_config_modal.view);
send_telemetry_from_ctx!(TabConfigsTelemetryEvent::GuidedModalOpened, ctx);
ctx.notify();
}
fn close_session_config_modal(&mut self, ctx: &mut ViewContext<Self>) {
self.session_config_modal.close();
self.current_workspace_state.is_session_config_modal_open = false;
// Don't promote pending → show here. The caller is responsible for
// calling `promote_session_config_tab_config_chip` once all
// intermediate modals (e.g. params modal) have closed.
self.focus_active_tab(ctx);
ctx.notify();
}
/// Promotes the pending tab-config chip to visible. This must be called
/// only after **all** intermediate modals (session config modal, params
/// modal) are closed. The chip is non-blocking: the user can still
/// interact with the terminal and must click the chip's close button or
/// press Escape/Enter to dismiss it.
fn promote_session_config_tab_config_chip(&mut self, ctx: &mut ViewContext<Self>) {
if self.pending_session_config_tab_config_chip {
self.show_session_config_tab_config_chip = true;
self.pending_session_config_tab_config_chip = false;
ctx.notify();
}
}
fn should_show_session_config_tab_config_chip(&self) -> bool {
self.show_session_config_tab_config_chip
&& !self.current_workspace_state.is_session_config_modal_open
&& !self.current_workspace_state.is_tab_config_params_modal_open
}
fn queue_onboarding_tutorial_after_session_config_tab_config_chip(
&mut self,
pending_tutorial: PendingSessionConfigTabConfigChipTutorial,
ctx: &mut ViewContext<Self>,
) {
if matches!(
pending_tutorial,
PendingSessionConfigTabConfigChipTutorial::AfterSetupCommands { .. }
) {
if let Some(terminal_view) = self.active_session_view(ctx) {
terminal_view.update(ctx, |view, _| {
view.clear_enter_agent_view_after_pending_commands();
});
}
}
self.pending_session_config_tab_config_chip_tutorial = Some(pending_tutorial);
}
fn dismiss_session_config_tab_config_chip(&mut self, ctx: &mut ViewContext<Self>) {
self.pending_session_config_tab_config_chip = false;
self.show_session_config_tab_config_chip = false;
if let Some(pending_tutorial) = self.pending_session_config_tab_config_chip_tutorial.take()
{
match pending_tutorial {
PendingSessionConfigTabConfigChipTutorial::WhenBootstrapped {
has_project,
intention,
} => {
self.dispatch_tutorial_when_bootstrapped(has_project, intention, ctx);
}
PendingSessionConfigTabConfigChipTutorial::AfterSetupCommands { intention } => {
self.dispatch_tutorial_after_setup_commands(intention, ctx);
}
}
}
ctx.notify();
}
fn render_session_config_tab_config_chip(
&self,
use_vertical: bool,
appearance: &Appearance,
) -> Box<dyn Element> {
let close_button = Hoverable::new(
self.mouse_states
.session_config_tab_config_chip_close
.clone(),
|hover_state| {
let icon = ConstrainedBox::new(
icons::Icon::X
.to_galaxyui_icon(Fill::Solid(PhenomenonStyle::modal_close_button_text()))
.finish(),
)
.with_width(16.)
.with_height(16.)
.finish();
let mut button = Container::new(icon)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)));
if hover_state.is_hovered() {
button =
button.with_background_color(PhenomenonStyle::modal_close_button_hover());
}
button.finish()
},
)
.with_cursor(Cursor::PointingHand)
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(WorkspaceAction::DismissSessionConfigTabConfigChip);
})
.finish();
let text = Text::new_inline(
SESSION_CONFIG_TAB_CONFIG_CHIP_TEXT.to_string(),
appearance.ui_font_family(),
12.,
)
.with_color(PhenomenonStyle::body_text())
.with_selectable(false)
.finish();
let content = Flex::row()
.with_main_axis_size(MainAxisSize::Min)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_spacing(4.)
.with_child(text)
.with_child(close_button)
.finish();
let chip_content = Container::new(content)
.with_padding_left(16.)
.with_padding_right(12.)
.with_padding_top(12.)
.with_padding_bottom(12.)
.finish();
let (arrow_direction, arrow_position) = if use_vertical {
(CalloutArrowDirection::Left, CalloutArrowPosition::Center)
} else {
(CalloutArrowDirection::Up, CalloutArrowPosition::Center)
};
render_callout_bubble(
chip_content,
&CalloutBubbleConfig {
width: SESSION_CONFIG_TAB_CONFIG_CHIP_WIDTH,
arrow_direction,
arrow_position,
},
appearance,
)
}
fn build_enable_auto_reload_modal(
ctx: &mut ViewContext<Self>,
) -> ViewHandle<EnableAutoReloadModal> {
let enable_auto_reload_modal = ctx.add_typed_action_view(EnableAutoReloadModal::new);
ctx.subscribe_to_view(&enable_auto_reload_modal, move |me, _, event, ctx| {
me.handle_enable_auto_reload_modal_event(event, ctx);
});
enable_auto_reload_modal
}
/// Subscribe to the [`ServerApiProvider`] model to report status changes.
fn observe_server_api(ctx: &mut ViewContext<Self>) {
let server_api_events = ServerApiProvider::handle(ctx);
ctx.subscribe_to_model(&server_api_events, |me, _, event, ctx| {
if let ServerApiEvent::StagingAccessBlocked = event {
if ChannelState::uses_staging_server() && me.shown_staging_banner_count < 5 {
me.shown_staging_banner_count += 1;
me.toast_stack.update(ctx, |toast_stack, ctx| {
let toast = DismissibleToast::error(
"Staging API call failed. Did your IP address change?".to_string(),
)
.with_object_id("staging_access_blocked_toast".to_string());
toast_stack.add_ephemeral_toast(toast, ctx);
});
}
}
});
}
fn subscribe_to_workspace_toast_stack(
toast_stack: ViewHandle<DismissibleToastStack<WorkspaceAction>>,
ctx: &mut ViewContext<Self>,
) {
let workspace_toast_stack = WorkspaceToastStack::handle(ctx);
ctx.subscribe_to_model(
&workspace_toast_stack,
move |_me, _, event, ctx| match event {
WorkspaceToastStackEvent::AddEphemeralToast { window_id, toast }
if *window_id == ctx.window_id() =>
{
toast_stack.update(ctx, |toast_stack, ctx| {
toast_stack.add_ephemeral_toast(toast.clone(), ctx)
});
}
WorkspaceToastStackEvent::AddPersistentToast { window_id, toast }
if *window_id == ctx.window_id() =>
{
toast_stack.update(ctx, |toast_stack, ctx| {
toast_stack.add_persistent_toast(toast.clone(), ctx)
});
}
WorkspaceToastStackEvent::RemoveToast {
window_id,
identifier,
} if *window_id == ctx.window_id() => {
toast_stack.update(ctx, |toast_stack, ctx| {
toast_stack.dismiss_older_toasts(identifier, ctx)
});
}
_ => {}
},
);
}
/// Subscribes to `WarpConfigUpdateEvent::TabConfigErrors` and shows a persistent
/// error toast for each tab config file that failed to parse. Uses `object_id`
/// keyed by file path so that re-saving the same file auto-dismisses the stale
/// toast.
fn subscribe_to_tab_config_errors(
toast_stack: ViewHandle<DismissibleToastStack<WorkspaceAction>>,
ctx: &mut ViewContext<Self>,
) {
ctx.subscribe_to_model(&WarpConfig::handle(ctx), move |_me, _, event, ctx| {
match event {
WarpConfigUpdateEvent::TabConfigs => {
// On every tab config reload, dismiss error toasts for
// files that now parse successfully. The model has already
// been updated with the current error set before this event
// fires, so we just need to clear stale toasts.
//
// `TabConfigErrors` is only emitted when errors exist, so
// when all files are fixed we only get `TabConfigs` — this
// branch handles that case by prefix-dismissing all
// tab-config-error toasts and letting `TabConfigErrors`
// re-add any that still apply.
toast_stack.update(ctx, |toast_stack, ctx| {
toast_stack.dismiss_toasts_by_prefix("tab_config_error:", ctx);
});
}
WarpConfigUpdateEvent::TabConfigErrors(errors) => {
let home_dir = dirs::home_dir();
for error in errors {
let object_id = format!("tab_config_error:{}", error.file_path.display());
let raw_path = error.file_path.display().to_string();
let friendly_path = user_friendly_path(
&raw_path,
home_dir.as_ref().and_then(|h| h.to_str()),
);
let message = format!(
"Failed to load tab config {friendly_path}: {}",
error.error_message
);
let path = error.file_path.clone();
let toast = DismissibleToast::error(message)
.with_object_id(object_id.clone())
.with_link(
ToastLink::new("Open file".to_string()).with_onclick_action(
WorkspaceAction::OpenTabConfigErrorFile {
path,
toast_object_id: object_id,
},
),
);
toast_stack.update(ctx, |toast_stack, ctx| {
toast_stack.add_persistent_toast(toast, ctx);
});
}
}
_ => {}
}
});
}
/// Subscribes to `WarpConfigUpdateEvent::SettingsErrors` and
/// `SettingsErrorsCleared` to update the workspace settings-error banner
/// and mirror the state into the settings pane for its nav-rail footer.
fn subscribe_to_settings_errors(ctx: &mut ViewContext<Self>) {
ctx.subscribe_to_model(&WarpConfig::handle(ctx), |me, _, event, ctx| match event {
WarpConfigUpdateEvent::SettingsErrors(error) => {
me.settings_file_error = Some(error.clone());
me.sync_settings_error_state_into_settings_pane(ctx);
ctx.notify();
}
WarpConfigUpdateEvent::SettingsErrorsCleared => {
me.settings_file_error = None;
me.sync_settings_error_state_into_settings_pane(ctx);
ctx.notify();
}
_ => {}
});
}
/// Pushes the current settings-file error + banner-dismissal state into
/// the settings pane so its nav-rail footer ("Open settings file" button
/// or inline error alert) stays in sync with the workspace banner.
fn sync_settings_error_state_into_settings_pane(&mut self, ctx: &mut ViewContext<Self>) {
let error = self.settings_file_error.clone();
let dismissed = self.settings_error_banner_dismissed;
self.settings_pane.update(ctx, |view, ctx| {
view.set_settings_error_state(error, dismissed, ctx);
});
}
pub fn dismiss_older_toasts(&mut self, object_id: &str, ctx: &mut ViewContext<Self>) {
self.toast_stack.update(ctx, |toast_stack, ctx| {
toast_stack.dismiss_older_toasts(object_id, ctx);
});
}
fn on_tips_model_changed(
&mut self,
_: ModelHandle<TipsCompleted>,
ctx: &mut ViewContext<Self>,
) {
ctx.notify();
}
pub fn new(
global_resource_handles: GlobalResourceHandles,
server_time: Option<Arc<ServerTime>>,
workspace_setting: NewWorkspaceSource,
ctx: &mut ViewContext<Self>,
) -> Self {
let GlobalResourceHandles {
model_event_sender,
tips_completed,
user_default_shell_unsupported_banner_model_handle,
referral_theme_status,
settings_file_error,
} = global_resource_handles.clone();
let server_api_provider = ServerApiProvider::as_ref(ctx);
let server_api = server_api_provider.get();
let ai_client = server_api_provider.get_ai_client();
// Inserting a (window, ModalSizes) pair to the ResizableData singleton. A restored window
// reads the sizes from the window snapshot. A new window initializes with all default sizes.
let resizable_data = ResizableData::handle(ctx);
let window_id = ctx.window_id();
let has_horizontal_split = workspace_setting.has_horizontal_split();
let (left_panel_size, right_panel_size) =
compute_default_panel_widths(ctx, window_id, has_horizontal_split);
let new_resizable_modal_sizes = match workspace_setting.clone() {
NewWorkspaceSource::Restored {
window_snapshot, ..
} => ModalSizes::from_restored(&window_snapshot, left_panel_size, right_panel_size),
_ => ModalSizes::default_with_panel_defaults(left_panel_size, right_panel_size),
};
resizable_data.update(ctx, |model, _| {
model.insert(window_id, new_resizable_modal_sizes)
});
terminal::platform::init().expect("Terminal platform initialized");
let tab_bar_overflow_menu = Self::build_tab_bar_overflow_menu(ctx);
let (tab_right_click_menu, new_session_dropdown_menu, new_session_sidecar_menu) =
Self::build_menus(ctx);
// Subscribe to network changes
ctx.subscribe_to_model(
&NetworkStatus::handle(ctx),
Self::handle_network_status_event,
);
let palette =
ctx.add_typed_action_view(|ctx| CommandPalette::new(NavigationMode::Normal, ctx));
ctx.subscribe_to_view(&palette, |me, _, event, ctx| {
me.handle_palette_event(event, ctx);
});
let ctrl_tab_palette =
ctx.add_typed_action_view(|ctx| CommandPalette::new(NavigationMode::CtrlTab, ctx));
ctx.subscribe_to_view(&ctrl_tab_palette, |me, _, event, ctx| {
me.handle_palette_event(event, ctx);
});
let auth_manager = AuthManager::handle(ctx);
ctx.subscribe_to_model(&auth_manager, Self::handle_auth_manager_event);
// Handle theme updates when there is a cloud update to themes while the picker is open.
ctx.subscribe_to_model(&ThemeSettings::handle(ctx), |me, _, _, ctx| {
if me.is_theme_chooser_open() {
me.theme_chooser_view.update(ctx, |view, ctx| {
view.handle_theme_change(ctx);
});
}
});
ctx.subscribe_to_model(&referral_theme_status, |me, _, event, ctx| {
me.handle_referral_theme_status_event(event, ctx);
});
let referrals_client = ServerApiProvider::as_ref(ctx).get_referrals_client();
// On startup, check if the user has earned a referral theme by referring other users
referral_theme_status.update(ctx, |model, ctx| {
model.query_referral_status(referrals_client, ctx);
});
let bindings_notifier = KeybindingChangedNotifier::handle(ctx);
ctx.subscribe_to_model(&bindings_notifier, |me, _, event, ctx| {
me.handle_keybinding_changed(event, ctx);
});
let state_handle = WindowManager::handle(ctx);
ctx.subscribe_to_model(&state_handle, |me, _, event, ctx| {
me.handle_window_state_change(event, ctx);
});
ctx.observe(&RelaunchModel::handle(ctx), |_, _, ctx| {
ctx.notify();
});
let changelog_model = ChangelogModel::handle(ctx);
ctx.subscribe_to_model(&changelog_model, |me, _, event, ctx| {
me.handle_changelog_event(event, ctx);
});
let reward_modal = Self::build_reward_modal(ctx);
let (welcome_tips_view, welcome_tips_view_state) =
Self::build_welcome_tips(tips_completed.clone(), ctx);
let (settings_pane, theme_chooser_view) =
Self::build_settings_views(global_resource_handles, tips_completed.clone(), ctx);
let resource_center_view =
Self::build_resource_center_view(ctx, tips_completed.clone(), changelog_model.clone());
let enable_auto_reload_modal = ctx.add_typed_action_view(EnableAutoReloadModal::new);
ctx.subscribe_to_view(&enable_auto_reload_modal, |me, _, event, ctx| {
me.handle_enable_auto_reload_modal_event(event, ctx);
});
let build_plan_migration_modal = ctx.add_typed_action_view(BuildPlanMigrationModal::new);
ctx.subscribe_to_view(&build_plan_migration_modal, |me, _, event, ctx| {
me.handle_build_plan_migration_modal_event(event, ctx);
});
let codex_modal = ctx.add_typed_action_view(CodexModal::new);
ctx.subscribe_to_view(&codex_modal, |me, _, event, ctx| {
me.handle_codex_modal_event(event, ctx);
});
let cloud_agent_capacity_modal =
ctx.add_typed_action_view(|_| CloudAgentCapacityModal::new());
ctx.subscribe_to_view(&cloud_agent_capacity_modal, |me, _, event, ctx| {
me.handle_cloud_agent_capacity_modal_event(event, ctx);
});
let free_tier_limit_hit_modal = ctx.add_typed_action_view(FreeTierLimitHitModal::new);
ctx.subscribe_to_view(&free_tier_limit_hit_modal, |me, _, event, ctx| {
me.handle_free_tier_limit_modal_event(event, ctx);
});
let require_login_modal = Self::build_require_login_modal(ctx);
let auth_override_warning_modal = Self::build_auth_override_warning_modal(ctx);
let workflow_modal = Self::build_workflow_modal(ai_client.clone(), ctx);
let theme_creator_modal = Self::build_theme_creator_modal(ctx);
let theme_deletion_modal = Self::build_theme_deletion_modal(ctx);
let suggested_agent_mode_workflow_modal =
Self::build_suggested_agent_mode_workflow_modal(ctx);
let suggested_rule_modal = Self::build_suggested_rule_modal(ctx);
let oz_launch_view = ctx.add_typed_action_view(LaunchModal::<OzLaunchSlide>::new);
ctx.subscribe_to_view(&oz_launch_view, |me, _, event, ctx| {
me.handle_oz_launch_modal_event(event, ctx);
});
let openwarp_launch_view = ctx.add_typed_action_view(OpenWarpLaunchModal::new);
ctx.subscribe_to_view(&openwarp_launch_view, |me, _, event, ctx| {
me.handle_openwarp_launch_modal_event(event, ctx);
});
let launch_config_save_modal = Self::build_launch_config_save_modal(ctx);
let tab_config_params_modal = Self::build_tab_config_params_modal(ctx);
let new_worktree_modal = Self::build_new_worktree_modal(ctx);
let session_config_modal = Self::build_session_config_modal(ctx);
let enable_auto_reload_modal = Self::build_enable_auto_reload_modal(ctx);
let close_session_confirmation_dialog = Self::build_close_session_confirmation_dialog(ctx);
let rewind_confirmation_dialog = Self::build_rewind_confirmation_dialog(ctx);
let delete_conversation_confirmation_dialog =
Self::build_delete_conversation_confirmation_dialog(ctx);
let command_search_view =
ctx.add_typed_action_view(|ctx| CommandSearchView::new(ai_client.clone(), ctx));
ctx.subscribe_to_view(&command_search_view, |me, _, event, ctx| {
me.handle_command_search_event(event, ctx);
});
let ai_fact_view = ctx.add_typed_action_view(AIFactView::new);
ctx.subscribe_to_view(&ai_fact_view, move |me, _, event, ctx| {
me.handle_ai_fact_view_event(event, ctx);
});
AIFactManager::handle(ctx).update(ctx, |manager, _| {
manager.register_view(window_id, ai_fact_view.clone());
});
let working_directories_model =
ctx.add_model(|_| pane_group::WorkingDirectoriesModel::new());
let left_panel_views = Self::compute_left_panel_views(ctx);
let left_panel_view = ctx.add_typed_action_view(|ctx| {
LeftPanelView::new(
working_directories_model.clone(),
left_panel_views.clone(),
ctx,
)
});
ctx.subscribe_to_view(&left_panel_view, |me, _, event, ctx| {
me.handle_left_panel_event(event, ctx);
});
let right_panel_view = ctx.add_typed_action_view(|ctx| {
RightPanelView::new(working_directories_model.clone(), ctx)
});
ctx.subscribe_to_view(&right_panel_view, |me, _, event, ctx| {
me.handle_right_panel_event(event.clone(), ctx);
});
// Get persisted filters from window snapshot if restoring.
let agent_management_filters = match workspace_setting {
NewWorkspaceSource::Restored {
ref window_snapshot,
..
} => window_snapshot.agent_management_filters.clone(),
_ => None,
};
let agent_management_view = ctx
.add_typed_action_view(|ctx| AgentManagementView::new(agent_management_filters, ctx));
ctx.subscribe_to_view(&agent_management_view, |me, _, event, ctx| {
me.handle_agent_management_view_event(event, ctx);
});
let notification_mailbox_view = if FeatureFlag::HOANotifications.is_enabled() {
let view = ctx.add_typed_action_view(NotificationMailboxView::new);
ctx.subscribe_to_view(&view, move |me, _, event, ctx| match event {
NotificationMailboxViewEvent::NavigateToTerminal {
terminal_view_id, ..
} => {
me.current_workspace_state.is_notification_mailbox_open = false;
me.tab_bar_pinned_by_popup = false;
me.sync_window_button_visibility(ctx);
if let Some(stack) = &me.notification_toast_stack {
stack.update(ctx, |stack, ctx| stack.set_mailbox_open(false, ctx));
}
me.handle_action(
&WorkspaceAction::FocusTerminalViewInWorkspace {
terminal_view_id: *terminal_view_id,
},
ctx,
);
ctx.notify();
}
NotificationMailboxViewEvent::Dismissed => {
me.current_workspace_state.is_notification_mailbox_open = false;
me.tab_bar_pinned_by_popup = false;
me.sync_window_button_visibility(ctx);
if let Some(stack) = &me.notification_toast_stack {
stack.update(ctx, |stack, ctx| stack.set_mailbox_open(false, ctx));
}
me.focus_active_tab(ctx);
ctx.notify();
}
});
Some(view)
} else {
None
};
let notification_toast_stack = if FeatureFlag::HOANotifications.is_enabled() {
Some(ctx.add_typed_action_view(AgentNotificationToastStack::new))
} else {
None
};
let ai_assistant_panel =
Self::build_ai_assistant_panel_view(ctx, server_api.clone(), ai_client.clone());
ctx.observe(&tips_completed, Workspace::on_tips_model_changed);
let autoupdate_handle = AutoupdateState::handle(ctx);
ctx.subscribe_to_model(&autoupdate_handle, |_view, _handle, evt, ctx| {
if let AutoupdateStateEvent::UpdateAvailable = evt {
ctx.notify();
}
});
ctx.subscribe_to_model(
&BlocklistAIHistoryModel::handle(ctx),
Self::handle_history_model_event,
);
ctx.subscribe_to_model(&CLIAgentSessionsModel::handle(ctx), |me, _, event, ctx| {
me.handle_cli_agent_sessions_event(event, ctx);
});
ctx.subscribe_to_model(
&AgentNotificationsModel::handle(ctx),
Self::handle_agent_management_event,
);
ctx.subscribe_to_model(
&SessionSettings::handle(ctx),
Self::handle_session_settings_event,
);
// When a remote server session finishes its initialize handshake, re-run
// update_active_session so navigate_to_directory fires now that the
// client is connected (it may have been skipped on the initial pwd change
// because the handshake was still in progress).
#[cfg(feature = "local_fs")]
if FeatureFlag::SshRemoteServer.is_enabled() {
ctx.subscribe_to_model(
&RemoteServerManager::handle(ctx),
|me, _handle, event, ctx| {
if matches!(event, RemoteServerManagerEvent::SessionConnected { .. }) {
me.update_active_session(ctx);
}
},
);
}
ctx.subscribe_to_model(&WindowSettings::handle(ctx), |me, _handle, event, ctx| {
me.handle_window_settings_changed_event(event, ctx);
});
// Show the Warp AI warm welcome iff the user hasn't dismissed it nor interacted with Warp AI before.
// Also, avoid showing it in integration tests to prevent interaction with other tests.
let mut should_show_ai_assistant_warm_welcome: bool = !FeatureFlag::AgentMode.is_enabled()
&& AISettings::as_ref(ctx).is_any_ai_enabled(ctx)
&& !matches!(ChannelState::channel(), Channel::Integration)
&& ctx
.private_user_preferences()
.read_value(settings::DISMISSED_AI_ASSISTANT_WELCOME_KEY)
.unwrap_or_default()
.and_then(|s| serde_json::from_str(&s).ok())
.map(|dismissed: bool| !dismissed)
.unwrap_or(true);
// Don't automatically show the Warp AI welcome during onboarding if the block onboarding flow is being used.
// This way, we can delay the reveal until the end of the onboarding flow so as not to overwhelm the user.
if matches!(
BlockOnboarding::get_group(ctx),
Some(BlockOnboarding::VariantOne) | Some(BlockOnboarding::VariantTwo)
) {
should_show_ai_assistant_warm_welcome = false;
}
let tab_settings_handle = TabSettings::handle(ctx);
ctx.subscribe_to_model(&tab_settings_handle, |me, _, event, ctx| {
me.handle_tab_settings_change(event, ctx)
});
ctx.subscribe_to_model(&CodeSettings::handle(ctx), |me, _, event, ctx| {
if matches!(
event,
CodeSettingsChangedEvent::ShowProjectExplorer { .. }
| CodeSettingsChangedEvent::ShowGlobalSearch { .. }
) {
me.update_left_panel_available_views(ctx);
ctx.notify();
}
});
ctx.subscribe_to_model(&WarpDriveSettings::handle(ctx), |me, _, event, ctx| {
if let WarpDriveSettingsChangedEvent::EnableWarpDrive { .. } = event {
me.update_left_panel_available_views(ctx);
ctx.notify();
}
});
let toast_stack =
ctx.add_typed_action_view(|_| DismissibleToastStack::new(Duration::from_secs(4)));
let agent_toast_stack =
ctx.add_typed_action_view(|ctx| AgentToastStack::new(Duration::from_secs(4), ctx));
let update_toast_stack =
ctx.add_typed_action_view(|_| DismissibleToastStack::new(Duration::from_secs(4)));
#[cfg(target_family = "wasm")]
let wasm_nux_dialog = Self::build_wasm_nux_dialog(ctx);
#[cfg(target_family = "wasm")]
let open_in_warp_button = Self::build_open_in_warp_button(ctx);
#[cfg(target_family = "wasm")]
let transcript_info_button = Self::build_transcript_info_button(ctx);
#[cfg(target_family = "wasm")]
let view_cloud_runs_button = Self::build_view_cloud_runs_button(ctx);
#[cfg(target_family = "wasm")]
let transcript_details_panel = Self::build_transcript_details_panel(ctx);
// Subscribe to task updates so the transcript details panel can refresh when task data arrives
#[cfg(target_family = "wasm")]
ctx.subscribe_to_model(
&AgentConversationsModel::handle(ctx),
|me, _, event, ctx| match event {
// Update transcript details if task or conversation data is updated
AgentConversationsModelEvent::NewTasksReceived
| AgentConversationsModelEvent::TasksUpdated
| AgentConversationsModelEvent::ConversationUpdated
| AgentConversationsModelEvent::ConversationArtifactsUpdated { .. } => {
me.update_transcript_details_panel_data(ctx);
}
_ => {}
},
);
let update_manager = UpdateManager::handle(ctx);
ctx.subscribe_to_model(&update_manager, |me, _handle, event, ctx| {
me.handle_update_manager_event(event, ctx);
});
let cached_keybindings = KEYBINDINGS_TO_CACHE
.iter()
.map(|name| {
(
String::from(*name),
keybinding_name_to_display_string(name, ctx),
)
})
.collect();
let prompt_editor_modal = Self::build_prompt_editor_modal(ctx);
let agent_toolbar_editor_modal = Self::build_agent_toolbar_editor_modal(ctx);
let import_modal = Self::build_import_modal(ctx);
Self::observe_server_api(ctx);
Self::subscribe_to_workspace_toast_stack(toast_stack.clone(), ctx);
Self::subscribe_to_tab_config_errors(toast_stack.clone(), ctx);
Self::subscribe_to_settings_errors(ctx);
Self::subscribe_to_shared_session_manager(ctx);
let user_menu = ctx.add_typed_action_view(|_| {
Menu::new()
.with_drop_shadow()
.prevent_interaction_with_other_elements()
});
ctx.subscribe_to_view(&user_menu, |me, _, event, ctx| {
if let MenuEvent::Close { .. } = event {
me.is_user_menu_open = false;
ctx.notify();
}
});
let native_modal = Self::build_native_modal_view(ctx);
let shared_objects_creation_denied_modal =
ctx.add_typed_action_view(|ctx| SharedObjectsCreationDeniedModal::new(None, ctx));
ctx.subscribe_to_view(
&shared_objects_creation_denied_modal,
|me, _, event, ctx| match event {
SharedObjectsCreationDeniedModalEvent::Close => {
me.current_workspace_state
.is_shared_objects_creation_denied_modal_open = false;
ctx.notify();
}
SharedObjectsCreationDeniedModalEvent::TeamSettings => {
me.show_settings_with_section(Some(SettingsSection::Teams), ctx);
me.current_workspace_state
.is_shared_objects_creation_denied_modal_open = false;
ctx.notify();
}
},
);
ctx.subscribe_to_model(&AISettings::handle(ctx), |me, _, event, ctx| match event {
AISettingsChangedEvent::IsAnyAIEnabled { .. }
| AISettingsChangedEvent::ShowConversationHistory { .. } => {
me.update_left_panel_available_views(ctx);
ctx.notify();
}
AISettingsChangedEvent::IsActiveAIEnabled { .. }
| AISettingsChangedEvent::ThinkingDisplayMode { .. } => {
ctx.notify();
}
AISettingsChangedEvent::ShowAgentNotifications { .. } => {
// When agent notifications are turned off, close the mailbox if it's open.
if !*AISettings::as_ref(ctx).show_agent_notifications {
me.current_workspace_state.is_notification_mailbox_open = false;
}
ctx.notify();
}
_ => (),
});
ctx.subscribe_to_model(&OneTimeModalModel::handle(ctx), |me, model, event, ctx| {
let OneTimeModalEvent::VisibilityChanged { is_open } = event;
if *is_open {
// Only trigger modal actions if this is the target window.
// The model has already determined which window should show the modal.
let model_ref = model.as_ref(ctx);
if model_ref.target_window_id() == Some(ctx.window_id()) {
if model_ref.is_oz_launch_modal_open() {
me.open_tab_and_focus_oz_launch_modal(ctx);
} else if model_ref.is_openwarp_launch_modal_open() {
me.focus_openwarp_launch_modal(ctx);
} else if model_ref.is_hoa_onboarding_open() {
me.show_hoa_onboarding_flow(ctx);
} else if model_ref.is_build_plan_migration_modal_open() {
me.focus_build_plan_migration_modal(ctx);
}
}
}
ctx.notify();
});
ctx.subscribe_to_model(
&crate::workspace::bonus_grant_notification_model::BonusGrantNotificationModel::handle(
ctx,
),
|me, _, event, ctx| {
let BonusGrantNotificationEvent::ShowNotification { message, .. } = event;
me.toast_stack.update(ctx, |toast_stack, ctx| {
toast_stack
.add_persistent_toast(DismissibleToast::success(message.clone()), ctx);
});
},
);
let mut ws = Self {
tabs: Vec::new(),
active_tab_index: 0,
hovered_tab_index: None,
tab_bar_hover_state: Default::default(),
traffic_light_mouse_states: Default::default(),
tab_rename_editor: Self::tab_rename_editor(ctx),
pane_rename_editor: Self::pane_rename_editor(ctx),
vertical_tabs_search_input: Self::vertical_tabs_search_input(ctx),
tips_completed,
user_default_shell_unsupported_banner_model_handle,
server_api,
auth_state: AuthStateProvider::as_ref(ctx).get().clone(),
server_time,
tab_bar_overflow_menu,
show_tab_bar_overflow_menu: false,
tab_right_click_menu,
show_tab_right_click_menu: None,
new_session_dropdown_menu,
show_new_session_dropdown_menu: None,
changelog_model,
welcome_tips_view_state,
welcome_tips_view,
palette,
ctrl_tab_palette,
mouse_states: Default::default(),
previous_theme: None,
settings_pane,
theme_chooser_view,
reward_modal,
reward_modal_pending: None,
current_workspace_state: Default::default(),
previous_workspace_state: None,
model_event_sender,
launch_config_save_modal,
tab_config_params_modal,
session_config_modal,
pending_session_config_replacement: None,
pending_onboarding_intention: None,
pending_session_config_tab_config_chip: false,
show_session_config_tab_config_chip: false,
pending_session_config_tab_config_chip_tutorial: None,
new_worktree_modal,
close_session_confirmation_dialog,
rewind_confirmation_dialog,
delete_conversation_confirmation_dialog,
resource_center_view,
command_search_view,
autoupdate_unable_to_update_banner_dismissed: false,
autoupdate_unable_to_launch_new_version: false,
reauth_banner_dismissed: false,
settings_file_error,
settings_error_banner_dismissed: false,
ai_assistant_panel,
should_show_ai_assistant_warm_welcome,
ai_assistant_close_warm_welcome_mouse_state_handle: Default::default(),
auth_override_warning_modal,
suggested_agent_mode_workflow_modal,
suggested_rule_modal,
build_plan_migration_modal,
require_login_modal,
workflow_modal,
theme_creator_modal,
theme_deletion_modal,
import_modal,
window_id: ctx.window_id(),
toast_stack,
agent_toast_stack,
update_toast_stack,
cached_keybindings,
prompt_editor_modal,
agent_toolbar_editor_modal,
header_toolbar_editor_modal: Self::build_header_toolbar_editor_modal(ctx),
header_toolbar_context_menu: Self::build_header_toolbar_context_menu(ctx),
show_header_toolbar_context_menu: None,
is_user_menu_open: false,
tab_bar_pinned_by_popup: false,
user_menu,
native_modal,
shared_objects_creation_denied_modal,
file_upload_sessions: Default::default(),
ai_fact_view,
left_panel_open: false,
vertical_tabs_panel_open: false,
vertical_tabs_panel: Default::default(),
left_panel_view,
left_panel_views,
right_panel_view,
working_directories_model,
shown_staging_banner_count: 0,
#[cfg(target_family = "wasm")]
show_wasm_nux_dialog: WasmNUXDialog::should_display(ctx),
#[cfg(target_family = "wasm")]
wasm_nux_dialog,
#[cfg(target_family = "wasm")]
open_in_warp_button,
#[cfg(target_family = "wasm")]
transcript_info_button,
#[cfg(target_family = "wasm")]
view_cloud_runs_button,
#[cfg(target_family = "wasm")]
transcript_details_panel,
tab_fixed_width: None,
oz_launch_modal: ModalWithTab {
view: oz_launch_view,
tab_pane_group_id: None,
},
openwarp_launch_modal: openwarp_launch_view,
enable_auto_reload_modal,
agent_management_view,
notification_mailbox_view,
notification_toast_stack,
codex_modal,
cloud_agent_capacity_modal,
free_tier_limit_hit_modal,
free_tier_limit_check_triggered: false,
lightbox_view: None,
hoa_onboarding_flow: None,
hoa_vtabs_callout_pinned_position: None,
pending_pane_group_transfer: false,
is_drag_preview_workspace: false,
new_session_sidecar_menu,
show_new_session_sidecar: false,
worktree_sidecar_active: false,
worktree_sidecar_search_editor: Self::build_worktree_sidecar_search_input(ctx),
worktree_sidecar_search_query: String::new(),
new_session_sidecar_add_repo_mouse_state: Default::default(),
tab_config_action_sidecar_item: None,
tab_config_action_sidecar_mouse_states: Default::default(),
remove_tab_config_confirmation_dialog:
Self::build_remove_tab_config_confirmation_dialog(ctx),
};
ws.configure_new_workspace(workspace_setting, ctx);
ws.sync_panel_positions_from_config(ctx);
ws.sync_window_button_visibility(ctx);
ws.update_titlebar_height(ctx);
// Seed the settings pane with the initial settings-file error (if
// any) read from `GlobalResourceHandles`. Subsequent updates are
// pushed by `subscribe_to_settings_errors` and `dismiss_workspace_banner`.
ws.sync_settings_error_state_into_settings_pane(ctx);
let weak_handle = ctx.handle();
WorkspaceRegistry::handle(ctx).update(ctx, |registry, _| {
registry.register(window_id, weak_handle);
});
#[cfg(feature = "bedrock_smoke_test")]
{
crate::bedrock_smoke_test::schedule(ctx);
}
ws
}
#[cfg(any(test, feature = "integration_tests"))]
pub fn command_palette_view(&self) -> ViewHandle<crate::search::command_palette::View> {
self.palette.clone()
}
#[cfg(any(test, feature = "integration_tests"))]
pub fn ai_fact_view(&self) -> ViewHandle<AIFactView> {
self.ai_fact_view.clone()
}
fn handle_task_status_reset(&mut self, pane_group_id: EntityId, ctx: &mut ViewContext<Self>) {
// Re-render the workspace so the tab indicator picks up the new state.
let has_tab = self
.tabs
.iter()
.any(|tab| tab.pane_group.id() == pane_group_id);
if has_tab {
ctx.notify();
}
}
fn handle_agent_management_event(
&mut self,
_handle: ModelHandle<AgentNotificationsModel>,
event: &AgentManagementEvent,
ctx: &mut ViewContext<Self>,
) {
// Only process events for the active window.
if ctx
.windows()
.active_window()
.is_none_or(|id| id != ctx.window_id())
{
return;
}
match event {
AgentManagementEvent::ConversationNeedsAttention {
window_id: source_window_id,
tab_index,
terminal_view_id,
conversation_id,
} => {
if FeatureFlag::HOANotifications.is_enabled() {
return;
}
let history_model = BlocklistAIHistoryModel::as_ref(ctx);
let Some(conversation) = history_model.conversation(conversation_id) else {
return;
};
let Some(latest_query) = conversation
.latest_user_query()
.map(|latest_query| latest_query.to_owned())
else {
return;
};
let icon = conversation.status().render_icon(Appearance::as_ref(ctx));
self.agent_toast_stack
.update(ctx, |agent_toast_stack, ctx| {
let toast = AgentToast::new(
latest_query,
icon,
*source_window_id,
*tab_index,
*terminal_view_id,
);
agent_toast_stack.add_toast(toast.clone(), ctx)
});
ctx.notify();
}
AgentManagementEvent::NotificationAdded { .. }
| AgentManagementEvent::NotificationUpdated
| AgentManagementEvent::AllNotificationsMarkedRead => {
// Re-render so the vertical tabs panel can update unread-activity dots.
ctx.notify();
}
}
}
/// Handles updating the tab status when an agent task status changes.
fn handle_history_model_event(
&mut self,
_: ModelHandle<BlocklistAIHistoryModel>,
event: &BlocklistAIHistoryEvent,
ctx: &mut ViewContext<Self>,
) {
#[cfg(target_family = "wasm")]
if self
.current_workspace_state
.is_transcript_details_panel_open
{
let focused_terminal_view_id = self
.active_tab_pane_group()
.as_ref(ctx)
.focused_session_view(ctx)
.map(|view| view.id());
let is_relevant_update = matches!(
event,
BlocklistAIHistoryEvent::SetActiveConversation { .. }
| BlocklistAIHistoryEvent::UpdatedConversationMetadata { .. }
| BlocklistAIHistoryEvent::RestoredConversations { .. }
| BlocklistAIHistoryEvent::UpdatedConversationArtifacts { .. }
| BlocklistAIHistoryEvent::UpdatedConversationStatus { .. }
| BlocklistAIHistoryEvent::UpdatedStreamingExchange { .. }
);
if is_relevant_update
&& event.terminal_view_id().is_some_and(|event_id| {
focused_terminal_view_id.is_some_and(|id| id == event_id)
})
{
self.update_transcript_details_panel_data(ctx);
ctx.notify();
}
}
if matches!(
event,
BlocklistAIHistoryEvent::UpdatedConversationStatus { .. }
) {
ctx.notify();
}
if self.agent_conversation_event_affects_vertical_tabs(event, ctx) {
ctx.notify();
}
}
fn workspace_contains_terminal_view(
&self,
terminal_view_id: EntityId,
ctx: &AppContext,
) -> bool {
self.tabs.iter().any(|tab| {
tab.pane_group
.as_ref(ctx)
.contains_terminal_view(terminal_view_id, ctx)
})
}
fn agent_conversation_event_affects_vertical_tabs(
&self,
event: &BlocklistAIHistoryEvent,
ctx: &AppContext,
) -> bool {
matches!(
event,
BlocklistAIHistoryEvent::StartedNewConversation { .. }
| BlocklistAIHistoryEvent::AppendedExchange { .. }
| BlocklistAIHistoryEvent::UpdatedStreamingExchange { .. }
| BlocklistAIHistoryEvent::SetActiveConversation { .. }
| BlocklistAIHistoryEvent::ClearedActiveConversation { .. }
| BlocklistAIHistoryEvent::ClearedConversationsInTerminalView { .. }
| BlocklistAIHistoryEvent::SplitConversation { .. }
| BlocklistAIHistoryEvent::RestoredConversations { .. }
| BlocklistAIHistoryEvent::UpdatedConversationMetadata { .. }
) && event.terminal_view_id().is_some_and(|terminal_view_id| {
self.workspace_contains_terminal_view(terminal_view_id, ctx)
})
}
fn handle_cli_agent_sessions_event(
&mut self,
event: &CLIAgentSessionsModelEvent,
ctx: &mut ViewContext<Self>,
) {
if matches!(
event,
CLIAgentSessionsModelEvent::Started { .. }
| CLIAgentSessionsModelEvent::StatusChanged { .. }
| CLIAgentSessionsModelEvent::Ended { .. }
| CLIAgentSessionsModelEvent::SessionUpdated { .. }
) && self.workspace_contains_terminal_view(event.terminal_view_id(), ctx)
{
ctx.notify();
}
}
/// Handle session settings changes.
fn handle_session_settings_event(
&mut self,
session_settings: ModelHandle<SessionSettings>,
event: &SessionSettingsChangedEvent,
ctx: &mut ViewContext<Self>,
) {
if let SessionSettingsChangedEvent::HonorPS1 { .. } = event {
let honor_ps1 = *session_settings.as_ref(ctx).honor_ps1;
for tab in &self.tabs {
// Each tab has a pane group.
tab.pane_group.update(ctx, |pane_group, ctx| {
pane_group.send_prompt_change_bindkey_to_all_sessions(honor_ps1, ctx);
});
}
}
// When Notifications settings change, request system notification permissions if needed.
if let SessionSettingsChangedEvent::Notifications { .. } = event {
self.request_notification_permissions_if_needed(ctx);
}
}
/// Handle a change to the tab settings.
fn handle_tab_settings_change(
&mut self,
event: &TabSettingsChangedEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
TabSettingsChangedEvent::WorkspaceDecorationVisibility { .. } => {
self.sync_window_button_visibility(ctx);
ctx.notify();
}
TabSettingsChangedEvent::ShowIndicatorsButton { .. }
| TabSettingsChangedEvent::NewTabPlacement { .. }
| TabSettingsChangedEvent::TabCloseButtonPosition { .. }
| TabSettingsChangedEvent::PreserveActiveTabColor { .. } => {
self.sync_window_button_visibility(ctx);
ctx.notify();
}
TabSettingsChangedEvent::UseVerticalTabs { .. } => {
let vertical_tabs_enabled = *TabSettings::as_ref(ctx).use_vertical_tabs;
// During HOA onboarding, keep the vertical tabs panel open
// regardless of the setting so the callout stays anchored.
if self.hoa_onboarding_flow.is_none() {
self.vertical_tabs_panel_open = vertical_tabs_enabled;
}
if vertical_tabs_enabled {
Self::ensure_tabs_panel_in_config(ctx);
}
let appearance = Appearance::as_ref(ctx);
let font_family = appearance.ui_font_family();
let font_size = Self::tab_rename_editor_font_size(ctx, appearance);
self.tab_rename_editor.update(ctx, |editor, ctx| {
editor.set_font_family(font_family, ctx);
editor.set_font_size(font_size, ctx);
});
if !vertical_tabs_enabled {
self.close_vertical_tabs_settings_popup();
}
self.sync_panel_positions_from_config(ctx);
self.sync_window_button_visibility(ctx);
ctx.notify();
}
TabSettingsChangedEvent::ShowCodeReviewButton { .. } => {
// Close the right panel if it's open and the setting was just disabled.
if !*TabSettings::as_ref(ctx).show_code_review_button {
let pane_group = self.active_tab_pane_group().clone();
if pane_group.as_ref(ctx).right_panel_open {
self.close_right_panel(&pane_group, ctx);
}
}
ctx.notify();
}
TabSettingsChangedEvent::ShowCodeReviewDiffStats { .. } => {
ctx.notify();
}
TabSettingsChangedEvent::DirectoryTabColors { .. } => {
if FeatureFlag::DirectoryTabColors.is_enabled() {
for tab in &mut self.tabs {
Self::sync_codebase_tab_color(tab, ctx);
}
}
ctx.notify();
}
TabSettingsChangedEvent::VerticalTabsViewMode { .. }
| TabSettingsChangedEvent::VerticalTabsTabItemMode { .. }
| TabSettingsChangedEvent::VerticalTabsPrimaryInfo { .. }
| TabSettingsChangedEvent::VerticalTabsCompactSubtitle { .. }
| TabSettingsChangedEvent::UseLatestUserPromptAsConversationTitleInTabNames {
..
}
| TabSettingsChangedEvent::VerticalTabsShowPrLink { .. }
| TabSettingsChangedEvent::VerticalTabsShowDiffStats { .. } => {
ctx.notify();
}
TabSettingsChangedEvent::VerticalTabsShowDetailsOnHover { .. } => {
if !*TabSettings::as_ref(ctx).vertical_tabs_show_details_on_hover {
self.vertical_tabs_panel.clear_detail_sidecar();
}
ctx.notify();
}
TabSettingsChangedEvent::VerticalTabsDisplayGranularity { .. } => {
let appearance = Appearance::as_ref(ctx);
let font_size = Self::tab_rename_editor_font_size(ctx, appearance);
self.tab_rename_editor.update(ctx, |editor, ctx| {
editor.set_font_size(font_size, ctx);
});
ctx.notify();
}
TabSettingsChangedEvent::HeaderToolbarChipSelection { .. } => {
self.sync_panel_positions_from_config(ctx);
ctx.notify();
}
}
}
/// Opens a launch config window into the workspace.
pub fn open_launch_config_window(
&mut self,
window: WindowTemplate,
ctx: &mut ViewContext<Self>,
) {
let start_index = self.tabs.len();
window
.tabs
.iter()
.enumerate()
.for_each(|(tab_index, tab_template)| {
self.add_tab_with_pane_layout(
PanesLayout::Template(tab_template.layout.clone()),
Arc::new(HashMap::new()),
tab_template.title.clone(),
ctx,
);
self.tabs[start_index + tab_index].selected_color = tab_template
.color
.map_or(SelectedTabColor::Unset, SelectedTabColor::Color);
});
if !window.tabs.is_empty() {
// Focus the active tab from the launch config.
let mut index = start_index + window.active_tab_index.unwrap_or_default();
if index >= self.tab_count() {
index = start_index;
}
self.activate_tab_internal(index, ctx);
}
}
fn configure_new_workspace(
&mut self,
workspace_setting: NewWorkspaceSource,
ctx: &mut ViewContext<Self>,
) {
self.vertical_tabs_panel_open =
Self::initial_vertical_tabs_panel_open(&workspace_setting, ctx);
match workspace_setting {
NewWorkspaceSource::Empty {
previous_active_window,
shell,
} => {
self.configure_empty_workspace(previous_active_window, shell, ctx);
self.maybe_auto_open_conversation_list(ctx);
}
NewWorkspaceSource::Restored {
window_snapshot,
block_lists,
} => {
let active_tab_index = window_snapshot.active_tab_index;
let restored_left_panel_open = window_snapshot.left_panel_open;
window_snapshot
.tabs
.iter()
.enumerate()
.for_each(|(tab_index, saved_tab)| {
let custom_title = saved_tab.custom_title.clone();
self.add_tab_with_pane_layout(
PanesLayout::Snapshot(Box::new(saved_tab.root.clone())),
block_lists.clone(),
custom_title,
ctx,
);
self.tabs[tab_index].default_directory_color =
saved_tab.default_directory_color;
self.tabs[tab_index].selected_color = saved_tab.selected_color;
let pane_group = self.tabs[tab_index].pane_group.clone();
if let Some(left_panel_snapshot) = &saved_tab.left_panel {
self.restore_left_panel_for_tab(&pane_group, left_panel_snapshot, ctx);
}
if let Some(right_panel_snapshot) = &saved_tab.right_panel {
self.restore_right_panel_for_tab(
&pane_group,
right_panel_snapshot,
ctx,
);
}
});
if self.tab_count() == 0 {
if self.should_trigger_get_started_onboarding(ctx) {
self.trigger_get_started_onboarding(ctx);
return;
}
// If we still haven't created any tabs after attempting to restore, create a new tab
// with sensible defaults.
self.add_new_session_tab_with_default_mode(
NewSessionSource::Window,
None, /* previous_active_window */
None, /* chosen_shell */
None, /* ai_conversation */
false, /* hide_homepage */
ctx,
);
} else if self.left_panel_visibility_across_tabs_enabled(ctx) {
self.left_panel_open = restored_left_panel_open;
}
self.activate_tab_internal(active_tab_index, ctx);
self.check_and_trigger_onboarding(ctx);
self.maybe_auto_open_conversation_list(ctx);
}
NewWorkspaceSource::FromTemplate { window_template } => {
self.open_launch_config_window(window_template, ctx);
self.check_and_trigger_onboarding(ctx);
}
NewWorkspaceSource::Session { options } => {
self.add_tab_with_pane_layout(
PanesLayout::SingleTerminal(options),
Arc::new(HashMap::new()),
None,
ctx,
);
self.check_and_trigger_onboarding(ctx);
}
NewWorkspaceSource::SharedSessionAsViewer { session_id } => {
self.add_tab_for_joining_shared_session(session_id, ctx);
}
NewWorkspaceSource::FromCloudConversationId { conversation_id } => {
self.open_cloud_conversation_from_server_token(conversation_id, ctx);
}
NewWorkspaceSource::AgentSession {
options,
initial_query,
} => {
self.add_tab_with_pane_layout(
PanesLayout::SingleTerminal(options),
Arc::new(HashMap::new()),
None,
ctx,
);
// Enter agent mode with the environment creation query.
self.active_tab_pane_group().update(ctx, |pane_group, ctx| {
pane_group.start_agent_mode_in_new_pane(initial_query.as_deref(), None, ctx);
});
self.check_and_trigger_onboarding(ctx);
}
NewWorkspaceSource::NotebookFromFilePath { file_path } => {
self.add_tab_for_file_notebook(file_path, ctx);
}
NewWorkspaceSource::NotebookById { id, settings } => {
self.add_tab_for_cloud_notebook(id, &settings, ctx);
}
NewWorkspaceSource::WorkflowById { id, settings } => {
self.open_workflow_from_intent(id, &settings, ctx);
}
#[cfg(feature = "local_fs")]
NewWorkspaceSource::TransferredTab {
tab_color,
custom_title,
left_panel_open,
right_panel_open,
is_right_panel_maximized,
for_drag_preview,
..
} => {
self.is_drag_preview_workspace = for_drag_preview;
self.add_tab_with_pane_layout(
Default::default(),
Arc::new(HashMap::new()),
custom_title,
ctx,
);
if let (Some(color), Some(tab)) = (tab_color, self.tabs.last_mut()) {
tab.selected_color = SelectedTabColor::Color(color);
}
if self.left_panel_visibility_across_tabs_enabled(ctx) {
self.left_panel_open = left_panel_open;
}
if right_panel_open {
self.right_panel_view.update(ctx, |rp, ctx| {
rp.set_maximized(is_right_panel_maximized, ctx);
});
self.setup_code_review_panel(None, ctx);
}
self.pending_pane_group_transfer = true;
}
#[cfg(not(feature = "local_fs"))]
NewWorkspaceSource::TransferredTab {
tab_color,
custom_title,
left_panel_open,
for_drag_preview,
..
} => {
self.is_drag_preview_workspace = for_drag_preview;
self.add_tab_with_pane_layout(
Default::default(),
Arc::new(HashMap::new()),
custom_title,
ctx,
);
if let (Some(color), Some(tab)) = (tab_color, self.tabs.last_mut()) {
tab.selected_color = SelectedTabColor::Color(color);
}
if self.left_panel_visibility_across_tabs_enabled(ctx) {
self.left_panel_open = left_panel_open;
}
self.pending_pane_group_transfer = true;
}
};
debug_assert!(
self.tab_count() > 0,
"Workspace should have at least one tab upon configuration"
);
if self.left_panel_visibility_across_tabs_enabled(ctx) {
self.reconcile_left_panel_open_for_active_tab(ctx);
}
let active_pane_group = self.active_tab_pane_group().clone();
let working_directories_model = self.working_directories_model.clone();
self.left_panel_view.update(ctx, |left_panel, ctx| {
left_panel.set_active_pane_group(active_pane_group, &working_directories_model, ctx);
});
}
fn initial_vertical_tabs_panel_open(
workspace_setting: &NewWorkspaceSource,
ctx: &AppContext,
) -> bool {
let should_default_open =
FeatureFlag::VerticalTabs.is_enabled() && *TabSettings::as_ref(ctx).use_vertical_tabs;
match workspace_setting {
NewWorkspaceSource::Restored {
window_snapshot, ..
} => window_snapshot.vertical_tabs_panel_open,
NewWorkspaceSource::TransferredTab {
vertical_tabs_panel_open,
..
} => *vertical_tabs_panel_open,
NewWorkspaceSource::Empty { .. }
| NewWorkspaceSource::FromTemplate { .. }
| NewWorkspaceSource::Session { .. }
| NewWorkspaceSource::AgentSession { .. }
| NewWorkspaceSource::NotebookFromFilePath { .. } => should_default_open,
#[cfg(not(target_family = "wasm"))]
NewWorkspaceSource::SharedSessionAsViewer { .. }
| NewWorkspaceSource::FromCloudConversationId { .. }
| NewWorkspaceSource::NotebookById { .. }
| NewWorkspaceSource::WorkflowById { .. } => should_default_open,
#[cfg(target_family = "wasm")]
NewWorkspaceSource::SharedSessionAsViewer { .. }
| NewWorkspaceSource::FromCloudConversationId { .. }
| NewWorkspaceSource::NotebookById { .. }
| NewWorkspaceSource::WorkflowById { .. } => {
// Web opens these as single-purpose views without exposed multi-tab UI, so keep
// the tabs panel closed even though native windows still expose workspace chrome.
false
}
}
}
fn restore_left_panel_for_tab(
&mut self,
pane_group: &ViewHandle<PaneGroup>,
left_panel_snapshot: &LeftPanelSnapshot,
ctx: &mut ViewContext<Self>,
) {
pane_group.update(ctx, |pg, ctx| {
pg.set_left_panel_open(true, ctx);
});
let resizable = ResizableData::handle(ctx);
if let Some(modal_sizes) = resizable.as_ref(ctx).get_all_handles(self.window_id) {
if let Ok(mut handle) = modal_sizes.left_panel_width.lock() {
handle.set_size(left_panel_snapshot.width as f32);
}
}
self.left_panel_view.update(ctx, |lp, ctx| {
// Restore which panel tab was active
let active_view = match left_panel_snapshot.left_panel_displayed_tab {
LeftPanelDisplayedTab::FileTree => ToolPanelView::ProjectExplorer,
LeftPanelDisplayedTab::GlobalSearch => ToolPanelView::GlobalSearch {
entry_focus: GlobalSearchEntryFocus::Results,
},
LeftPanelDisplayedTab::WarpDrive => ToolPanelView::WarpDrive,
LeftPanelDisplayedTab::ConversationListView => ToolPanelView::ConversationListView,
};
lp.restore_active_view_from_snapshot(active_view, ctx);
lp.set_active_pane_group(pane_group.clone(), &self.working_directories_model, ctx);
});
ctx.notify();
}
fn restore_right_panel_for_tab(
&mut self,
pane_group: &ViewHandle<PaneGroup>,
right_panel_snapshot: &RightPanelSnapshot,
ctx: &mut ViewContext<Self>,
) {
pane_group.update(ctx, |pg, _| {
pg.right_panel_open = true;
pg.is_right_panel_maximized = right_panel_snapshot.is_maximized;
});
let resizable = ResizableData::handle(ctx);
if let Some(modal_sizes) = resizable.as_ref(ctx).get_all_handles(self.window_id) {
if let Ok(mut handle) = modal_sizes.right_panel_width.lock() {
handle.set_size(right_panel_snapshot.width as f32);
}
}
self.right_panel_view.update(ctx, |rp, ctx| {
rp.set_active_pane_group(pane_group.clone(), &self.working_directories_model, ctx);
rp.set_maximized(right_panel_snapshot.is_maximized, ctx);
});
ctx.notify();
}
// Configure an empty workspace. The behavior here is platform-specific.
fn configure_empty_workspace(
&mut self,
previous_active_window: Option<WindowId>,
shell: Option<AvailableShell>,
ctx: &mut ViewContext<Self>,
) {
let show_warp_home = !ContextFlag::CreateNewSession.is_enabled();
let mut placeholder_pane = None;
let open_warp_drive = if !show_warp_home {
if self.should_trigger_get_started_onboarding(ctx) {
self.trigger_get_started_onboarding(ctx);
} else if FeatureFlag::WelcomeTab.is_enabled() {
self.add_welcome_tab(ctx);
} else {
self.add_new_session_tab_with_default_mode(
NewSessionSource::Window,
previous_active_window,
shell,
None, /* ai_conversation */
false, /* hide_homepage */
ctx,
);
self.check_and_trigger_onboarding(ctx);
}
false
} else {
let home_pane = super::home::create_home_pane(ctx);
placeholder_pane = Some(home_pane.as_pane().id());
self.add_tab_from_existing_pane(home_pane, 0, ctx);
// If we can't start a terminal session to run the onboarding flow, show the Warp Home
// placeholder along with Warp Drive.
true
};
let initial_tab = self.active_tab_pane_group().clone();
if open_warp_drive {
// We open Warp Drive automatically in two cases:
// * The user is new to Warp, and went through the overall onboarding flow
// * The user is on the web, so we can't open a terminal session.
let initial_load_complete = UpdateManager::as_ref(ctx).initial_load_complete();
ctx.spawn(initial_load_complete, move |me, _, ctx| {
// New Warp users can have non-welcome objects if they were directly invited OR if
// linked objects were copied over from an anonymous user.
if CloudModel::as_ref(ctx).has_non_welcome_objects() {
me.open_or_toggle_warp_drive(false, false, ctx);
// After opening Warp Drive, if we rendered the Warp Home placeholder panel, replace it with one of
// the user's own objects.
if show_warp_home {
let cloud_model = CloudModel::as_ref(ctx);
let candidate_objects = cloud_model
.cloud_objects()
.filter(|object| {
!object.is_trashed(cloud_model)
&& object.renders_in_warp_drive()
&& !object.metadata().is_welcome_object
})
.map(|object| object.cloud_object_type_and_id())
.collect_vec();
// Collect into a temporary Vec so that we can create a pane for the first
// supported object.
let target_object_pane = candidate_objects
.into_iter()
.find_map(|object_id| me.create_cloud_object_pane(object_id, ctx));
if let Some((target_object_pane, placeholder_pane)) =
target_object_pane.zip(placeholder_pane)
{
initial_tab.update(ctx, |pane_group, ctx| {
pane_group.add_pane_sibling(
placeholder_pane,
Direction::Left,
target_object_pane,
true,
ctx,
);
pane_group.close_pane(placeholder_pane, ctx);
});
}
}
}
});
}
}
pub fn add_tab_for_joining_shared_session(
&mut self,
session_id: SharedSessionId,
ctx: &mut ViewContext<Self>,
) {
let new_pane_group = ctx.add_typed_action_view(|ctx| {
PaneGroup::new_for_shared_session_viewer(
session_id,
self.tips_completed.clone(),
self.user_default_shell_unsupported_banner_model_handle
.clone(),
self.server_api.clone(),
self.model_event_sender.clone(),
ctx,
)
});
ctx.subscribe_to_view(&new_pane_group, move |me, pane_group, event, ctx| {
me.handle_file_tree_event(pane_group, event, ctx)
});
self.tabs.push(TabData::new(new_pane_group));
self.activate_tab_internal(self.tab_count() - 1, ctx);
}
/// Opens a cloud conversation by server token.
/// If the current user owns or created it, navigate to its open pane or restore it
/// into a new tab. Otherwise, open the read-only transcript viewer.
pub fn open_cloud_conversation_from_server_token(
&mut self,
server_token: ServerConversationToken,
ctx: &mut ViewContext<Self>,
) {
let history = BlocklistAIHistoryModel::as_ref(ctx);
let Some(conversation_id) = history.find_conversation_id_by_server_token(&server_token)
else {
self.load_cloud_conversation_into_new_transcript_viewer(server_token, ctx);
return;
};
// Check whether the conversation was started/is owned by by the current user.
let user_id = AuthStateProvider::as_ref(ctx).get().user_id();
let server_metadata = history.get_server_conversation_metadata(&conversation_id);
let conversation_is_owned_by_current_user = match (user_id, server_metadata) {
(Some(user_uid), Some(metadata)) => {
let is_creator =
metadata.metadata.creator_uid.as_deref() == Some(&*user_uid.to_string());
let is_owner = matches!(
metadata.permissions.space,
Owner::User { user_uid: ref owner } if *owner == user_uid
);
is_creator || is_owner
}
_ => false,
};
if !conversation_is_owned_by_current_user {
self.load_cloud_conversation_into_new_transcript_viewer(server_token, ctx);
return;
}
// If the conversation is open in a pane this session, grab its nav data so we can
// navigate directly to it. Otherwise we'll restore from scratch into a new tab.
let nav_data = AgentConversationsModel::as_ref(ctx)
.get_conversation(&conversation_id)
.and_then(|entry| match entry {
ConversationOrTask::Conversation(metadata) => Some(&metadata.nav_data),
ConversationOrTask::Task(_) => None,
});
if let Some(nav_data) = nav_data {
let is_active =
ActiveAgentViewsModel::as_ref(ctx).is_conversation_open(nav_data.id, ctx);
let pane_view_locator = is_active.then_some(nav_data.pane_view_locator).flatten();
self.restore_or_navigate_to_conversation(
nav_data.id,
nav_data.window_id,
pane_view_locator,
nav_data.terminal_view_id,
Some(RestoreConversationLayout::NewTab),
ctx,
);
} else {
self.restore_or_navigate_to_conversation(
conversation_id,
None,
None,
None,
Some(RestoreConversationLayout::NewTab),
ctx,
);
}
}
/// Load the conversation into a transcript viewer in a new tab (with no input/backing shell)
pub fn load_cloud_conversation_into_new_transcript_viewer(
&mut self,
conversation_id: ServerConversationToken,
ctx: &mut ViewContext<Self>,
) {
// Create the tab immediately with a loading state
let new_pane_group = ctx.add_typed_action_view(|ctx| {
PaneGroup::new_for_conversation_transcript_viewer_loading(
self.tips_completed.clone(),
self.user_default_shell_unsupported_banner_model_handle
.clone(),
self.server_api.clone(),
self.model_event_sender.clone(),
ctx,
)
});
ctx.subscribe_to_view(&new_pane_group, move |me, pane_group, event, ctx| {
me.handle_file_tree_event(pane_group, event, ctx)
});
self.tabs.push(TabData::new(new_pane_group.clone()));
let new_tab_index = self.tab_count() - 1;
self.activate_tab_internal(new_tab_index, ctx);
let ai_client = ServerApiProvider::as_ref(ctx).get_ai_client();
let server_token = conversation_id;
ctx.spawn(
async move {
load_conversation_from_server(AIConversationId::default(), server_token, ai_client)
.await
},
move |me, cloud_conversation, ctx| {
let Some(cloud_conversation) = cloud_conversation else {
log::error!("Failed to load conversation from server");
me.toast_stack.update(ctx, |view, ctx| {
let new_toast = DismissibleToast::error(
"Failed to load conversation data.".to_string(),
);
view.add_ephemeral_toast(new_toast, ctx);
});
return;
};
// Update the pane group with the loaded conversation
new_pane_group.update(ctx, |pane_group, ctx| {
pane_group
.load_data_into_conversation_transcript_viewer(cloud_conversation, ctx);
});
// Open the transcript details panel by default on WASM (unless on mobile)
#[cfg(target_family = "wasm")]
{
if !galaxyui::platform::wasm::is_mobile_device() {
me.current_workspace_state.is_transcript_details_panel_open = true;
me.transcript_info_button.update(ctx, |button, ctx| {
button.set_active(true, ctx);
});
}
me.update_transcript_details_panel_data(ctx);
}
// Refresh the focused conversation state.
if me.active_tab_pane_group().id() == new_pane_group.id() {
let focused_terminal_view_id = me
.active_tab_pane_group()
.as_ref(ctx)
.active_session_view(ctx)
.map(|view| view.id());
let ambient_agent_task_id = me
.get_active_session_terminal_model(ctx)
.and_then(|model| model.lock().ambient_agent_task_id());
me.notify_terminal_focus_change(
focused_terminal_view_id,
ambient_agent_task_id,
ctx,
);
}
},
);
}
fn open_share_session_modal(&mut self, index: usize, ctx: &mut ViewContext<Self>) {
// Focus on the clicked tab
if index >= self.tab_count() {
return;
}
self.set_active_tab_index(index, ctx);
// Open the share session modal
if let Some(terminal_view) = self
.active_tab_pane_group()
.as_ref(ctx)
.focused_session_view(ctx)
{
terminal_view.update(ctx, |view, ctx| {
view.open_share_session_modal(SharedSessionActionSource::Tab, ctx);
});
}
}
fn stop_sharing_all_panes_in_tab(
&mut self,
pane_group: &WeakViewHandle<PaneGroup>,
ctx: &mut ViewContext<Self>,
) {
if let Some(pane_group) = pane_group.upgrade(ctx) {
let shared_views = pane_group.as_ref(ctx).shared_session_view_ids(ctx);
for shared_view_id in shared_views {
self.stop_sharing_session(&shared_view_id, SharedSessionActionSource::Tab, ctx);
}
}
}
fn stop_sharing_session(
&mut self,
terminal_view_id: &EntityId,
source: SharedSessionActionSource,
ctx: &mut ViewContext<Self>,
) {
use terminal::shared_session::manager::Manager;
let manager = Manager::as_ref(ctx);
if let Some(terminal_view) = manager.shared_view_by_id(terminal_view_id, ctx) {
terminal_view.update(ctx, |view, ctx| {
view.stop_sharing_session(source, ctx);
});
}
}
fn copy_shared_session_link_from_tab(&mut self, tab_index: usize, ctx: &mut ViewContext<Self>) {
// Get the pane group for the specified tab
let Some(pane_group) = self.tabs.get(tab_index).map(|tab| tab.pane_group.clone()) else {
return;
};
// Get the focused terminal view in that tab
let Some(terminal_view) = pane_group.as_ref(ctx).focused_session_view(ctx) else {
return;
};
// Copy the shared session link from that terminal view
terminal_view.update(ctx, |view, ctx| {
view.copy_shared_session_link(SharedSessionActionSource::Tab, ctx);
});
}
fn subscribe_to_shared_session_manager(ctx: &mut ViewContext<Self>) {
use terminal::shared_session::manager::{Manager, ManagerEvent};
let manager = Manager::handle(ctx);
ctx.subscribe_to_model(&manager, move |me, _, event, ctx| {
match event {
ManagerEvent::StartedShare {
window_id,
session_id,
} => {
if *window_id == ctx.window_id() {
me.copy_shared_session_link(session_id, ctx);
}
}
#[cfg(target_family = "wasm")]
ManagerEvent::JoinedSession { view_id, .. } => {
// Check if this session is in the current window and has an ambient agent task
let manager = Manager::as_ref(ctx);
if let Some(terminal_view) = manager.joined_view_by_id(view_id, ctx) {
let task_id = terminal_view
.as_ref(ctx)
.model
.lock()
.ambient_agent_task_id();
if task_id.is_some() {
// Open the details panel for shared ambient agent sessions (unless on mobile)
if !galaxyui::platform::wasm::is_mobile_device() {
me.current_workspace_state.is_transcript_details_panel_open = true;
me.transcript_info_button.update(ctx, |button, ctx| {
button.set_active(true, ctx);
});
}
me.update_transcript_details_panel_data(ctx);
}
}
}
#[cfg(not(target_family = "wasm"))]
ManagerEvent::JoinedSession { .. } => {}
_ => {}
}
ctx.notify();
});
}
fn copy_shared_session_link(
&mut self,
session_id: &SharedSessionId,
ctx: &mut ViewContext<Self>,
) {
ctx.clipboard().write(ClipboardContent::plain_text(
terminal::shared_session::join_link(session_id),
));
self.toast_stack.update(ctx, |toast_stack, ctx| {
let toast = DismissibleToast::default("Remote control link copied.".to_string());
toast_stack.add_ephemeral_toast(toast, ctx);
});
}
// Returns true if the focused pane is the viewer of a shared session
pub fn is_shared_session_viewer_focused(&self, app: &AppContext) -> bool {
self.active_tab_pane_group()
.as_ref(app)
.active_session_view(app)
.is_some_and(|view| {
view.as_ref(app)
.model
.lock()
.shared_session_status()
.is_viewer()
})
}
pub fn is_conversation_transcript_viewer_focused(&self, app: &AppContext) -> bool {
self.active_tab_pane_group()
.as_ref(app)
.active_session_view(app)
.is_some_and(|view| {
view.as_ref(app)
.model
.lock()
.is_conversation_transcript_viewer()
})
}
/// Returns the type of simplified WASM tab bar content to display, if any.
/// Used to determine whether to show the simplified tab bar layout on WASM.
#[cfg(target_family = "wasm")]
fn get_simplified_wasm_tab_bar_content(
&self,
ctx: &AppContext,
) -> Option<SimplifiedWasmTabBarContent> {
let pane_group = self.active_tab_pane_group().as_ref(ctx);
// Check if focused pane is a terminal with special state
if let Some(terminal_view) = pane_group.focused_session_view(ctx) {
let model = terminal_view.as_ref(ctx).model.lock();
// Conversation transcript viewer takes priority
if model.is_conversation_transcript_viewer() {
return Some(SimplifiedWasmTabBarContent::ConversationTranscript {
task_id: model.ambient_agent_task_id(),
});
}
// Check for shared session (viewer or writer)
if model.shared_session_status().is_sharer_or_viewer() {
return Some(SimplifiedWasmTabBarContent::SharedSession {
task_id: model.ambient_agent_task_id(),
});
}
}
// Check if focused pane is a Warp Drive object
let focused_pane_id = pane_group.focused_pane_id(ctx);
if focused_pane_id.is_warp_drive_object_pane() {
return Some(SimplifiedWasmTabBarContent::WarpDriveObject);
}
None
}
pub fn check_for_changelog(
&self,
request_type: ChangelogRequestType,
ctx: &mut ViewContext<Self>,
) {
self.changelog_model.update(ctx, |changelog_model, ctx| {
changelog_model.check_for_changelog(request_type, ctx);
ctx.notify();
});
}
fn dismiss_ai_assistant_warm_welcome(&mut self, ctx: &mut ViewContext<Self>) {
self.should_show_ai_assistant_warm_welcome = false;
let _ = ctx.private_user_preferences().write_value(
settings::DISMISSED_AI_ASSISTANT_WELCOME_KEY,
true.to_string(),
);
ctx.notify();
}
/// Add and focus a new terminal pane in AI mode in a new tab.
fn add_terminal_tab_in_ai_mode(
&mut self,
zero_state_prompt_suggestion_type: Option<ZeroStatePromptSuggestionType>,
ctx: &mut ViewContext<Self>,
) {
self.add_new_session_tab_internal_with_default_session_mode_behavior(
NewSessionSource::Tab,
Some(ctx.window_id()),
None,
None,
false,
DefaultSessionModeBehavior::Ignore,
ctx,
);
self.active_tab_pane_group().update(ctx, |pane_group, ctx| {
pane_group.start_agent_mode_in_new_pane(None, zero_state_prompt_suggestion_type, ctx);
});
}
/// Add and focus a new terminal pane in AI mode. Add the terminal pane to the right of
/// all other panes, as a split on the root node.
fn add_terminal_pane_in_ai_mode(
&mut self,
zero_state_prompt_suggestion_type: Option<ZeroStatePromptSuggestionType>,
ctx: &mut ViewContext<Self>,
) {
self.active_tab_pane_group().update(ctx, |pane_group, ctx| {
pane_group.add_terminal_pane_in_agent_mode(
None,
zero_state_prompt_suggestion_type,
ctx,
);
});
}
/// Add a new terminal tab and enter the agent view with a new conversation.
fn add_terminal_tab_with_new_agent_view(&mut self, ctx: &mut ViewContext<Self>) {
let was_left_panel_open = self.active_tab_pane_group().as_ref(ctx).left_panel_open;
self.add_new_session_tab_internal_with_default_session_mode_behavior(
NewSessionSource::Tab,
Some(ctx.window_id()),
None,
None,
false,
DefaultSessionModeBehavior::Ignore,
ctx,
);
self.active_tab_pane_group().update(ctx, |pane_group, ctx| {
if was_left_panel_open {
pane_group.set_left_panel_open(true, ctx);
}
if let Some(terminal_view) = pane_group.active_session_view(ctx) {
terminal_view.update(ctx, |view, ctx| {
view.enter_agent_view_for_new_conversation(
None,
AgentViewEntryOrigin::ConversationListView,
ctx,
);
});
}
});
}
fn toggle_ai_assistant_panel(&mut self, ctx: &mut ViewContext<Self>) {
// Now that the user has interacted with the panel, we can close
// the dialogue and mark it as dismissed.
if self.should_show_ai_assistant_warm_welcome {
self.dismiss_ai_assistant_warm_welcome(ctx);
}
self.tips_completed.update(ctx, |tips_completed, ctx| {
mark_feature_used_and_write_to_user_defaults(
Tip::Action(TipAction::WarpAI),
tips_completed,
ctx,
);
ctx.notify();
});
// The panel is already open and no models are open, so just refocus the panel.
// If there is a modal open, it would sit above the Warp AI panel and we would end up
// focusing the Warp AI panel _behind_ the floating modal. Instead, we opt for the normal
// toggle behavior which will close the current modal view and then toggle Warp AI.
if self.current_workspace_state.is_ai_assistant_panel_open
&& !self.ai_assistant_panel.is_self_or_child_focused(ctx)
&& !self.current_workspace_state.is_any_modal_open(ctx)
{
ctx.focus(&self.ai_assistant_panel);
return;
}
// Otherwise, open / close the panel accordingly.
self.current_workspace_state.is_ai_assistant_panel_open =
!self.current_workspace_state.is_ai_assistant_panel_open;
// Close any other modals that could be floating on top of the Warp AI panel.
self.current_workspace_state.close_all_modals();
if self.current_workspace_state.is_ai_assistant_panel_open {
// Close the resource center panel if we open the AI Assistant panel.
self.current_workspace_state.is_resource_center_open = false;
ctx.focus(&self.ai_assistant_panel);
} else {
self.focus_active_tab(ctx);
}
ctx.notify();
}
/// Sets focused to the index of either the selected object or the first item in WD
fn reset_focused_index_in_warp_drive(
&mut self,
should_scroll: bool,
ctx: &mut ViewContext<Self>,
) {
ctx.focus(&self.left_panel_view);
self.update_warp_drive_view(ctx, |drive_panel, ctx| {
drive_panel.reset_focused_index_in_warp_drive(should_scroll, ctx);
});
}
pub fn has_warp_drive_initialized_sections(
&self,
app: &AppContext,
) -> impl Future<Output = ()> {
self.left_panel_view
.as_ref(app)
.warp_drive_view()
.as_ref(app)
.has_warp_drive_initialized_sections(app)
}
/// Check if Warp Drive view is focused within.
/// Routes to the appropriate Warp Drive panel.
fn is_warp_drive_view_focused(&self, ctx: &mut ViewContext<Self>) -> bool {
let app = ctx;
self.left_panel_view.is_self_or_child_focused(app)
}
fn current_focus_region(&self, ctx: &mut ViewContext<Self>) -> FocusRegion {
let app = ctx;
if self.active_tab_pane_group().is_self_or_child_focused(app) {
return FocusRegion::PaneGroup;
}
if self.left_panel_view.is_self_or_child_focused(app) {
return FocusRegion::LeftPanel;
}
if self.right_panel_view.is_self_or_child_focused(app) {
return FocusRegion::RightPanel;
}
if self.ai_assistant_panel.is_self_or_child_focused(app)
|| self.resource_center_view.is_self_or_child_focused(app)
{
return FocusRegion::RightPanel;
}
FocusRegion::Other
}
fn has_left_region(&self, app: &AppContext) -> bool {
self.active_tab_pane_group().as_ref(app).left_panel_open
}
fn has_right_region(&self, app: &AppContext) -> bool {
let group = self.active_tab_pane_group().as_ref(app);
group.right_panel_open || self.current_workspace_state.is_right_panel_open()
}
fn focus_next_pane_in_group(&mut self, ctx: &mut ViewContext<Self>) -> bool {
let handle = self.active_tab_pane_group().clone();
handle.update(ctx, |pane_group, ctx| pane_group.try_navigate_next(ctx))
}
fn focus_prev_pane_in_group(&mut self, ctx: &mut ViewContext<Self>) -> bool {
let handle = self.active_tab_pane_group().clone();
handle.update(ctx, |pane_group, ctx| pane_group.try_navigate_prev(ctx))
}
fn focus_first_visible_pane_in_group(&mut self, ctx: &mut ViewContext<Self>) -> bool {
let handle = self.active_tab_pane_group().clone();
handle.update(ctx, |pane_group, ctx| pane_group.focus_first_pane(ctx))
}
fn focus_last_visible_pane_in_group(&mut self, ctx: &mut ViewContext<Self>) -> bool {
let handle = self.active_tab_pane_group().clone();
handle.update(ctx, |pane_group, ctx| pane_group.focus_last_pane(ctx))
}
fn focus_left_region_entry(&mut self, ctx: &mut ViewContext<Self>) {
if self.has_left_region(ctx) {
self.left_panel_view.update(ctx, |left_panel, ctx| {
left_panel.focus_active_view_on_entry(ctx);
});
}
}
fn focus_right_region_entry(&mut self, ctx: &mut ViewContext<Self>) {
let group = self.active_tab_pane_group().as_ref(ctx);
if group.right_panel_open {
ctx.focus(&self.right_panel_view);
return;
}
if self.current_workspace_state.is_ai_assistant_panel_open {
ctx.focus(&self.ai_assistant_panel);
} else if self.current_workspace_state.is_resource_center_open {
ctx.focus(&self.resource_center_view);
}
}
fn navigate_pane_or_panel(
&mut self,
direction: PanePanelDirection,
ctx: &mut ViewContext<Self>,
) {
let current_region = self.current_focus_region(ctx);
let has_left_panel = self.has_left_region(ctx);
let has_right_panel = self.has_right_region(ctx);
let target_region = self.compute_target_focus_region(
current_region,
direction,
has_left_panel,
has_right_panel,
ctx,
);
self.set_pane_dimming_for_region(target_region, ctx);
ctx.notify();
}
fn compute_target_focus_region(
&mut self,
region: FocusRegion,
direction: PanePanelDirection,
has_left_panel: bool,
has_right_panel: bool,
ctx: &mut ViewContext<Self>,
) -> FocusRegion {
match (region, direction) {
// NEXT: Left panel to first pane
(FocusRegion::LeftPanel, PanePanelDirection::Next) => {
// Always attempt to focus the first pane in the group and ensure the pane group
// regains application focus.
self.focus_first_visible_pane_in_group(ctx);
self.focus_active_tab(ctx);
FocusRegion::PaneGroup
}
// NEXT: Right panel to left panel if open, else first pane
(FocusRegion::RightPanel, PanePanelDirection::Next) => {
if has_left_panel {
self.focus_left_region_entry(ctx);
FocusRegion::LeftPanel
} else {
self.focus_first_visible_pane_in_group(ctx);
FocusRegion::PaneGroup
}
}
// NEXT: Pane group to next pane, or at end to right panel, left panel, first pane
// Included Other here for cases like the command palette action "Activate next Pane"
(FocusRegion::PaneGroup, PanePanelDirection::Next)
| (FocusRegion::Other, PanePanelDirection::Next) => {
let moved = self.focus_next_pane_in_group(ctx);
if moved {
FocusRegion::PaneGroup
} else if has_right_panel {
self.focus_right_region_entry(ctx);
FocusRegion::RightPanel
} else if has_left_panel {
self.focus_left_region_entry(ctx);
FocusRegion::LeftPanel
} else {
// No panels, wrap within panes.
self.focus_first_visible_pane_in_group(ctx);
FocusRegion::PaneGroup
}
}
// PREV: Right panel to last pane
(FocusRegion::RightPanel, PanePanelDirection::Prev) => {
// Always attempt to focus the last pane in the group and ensure the pane group
// regains application focus.
self.focus_last_visible_pane_in_group(ctx);
self.focus_active_tab(ctx);
FocusRegion::PaneGroup
}
// PREV: Left panel to right panel if open, else last pane
(FocusRegion::LeftPanel, PanePanelDirection::Prev) => {
if has_right_panel {
self.focus_right_region_entry(ctx);
FocusRegion::RightPanel
} else {
self.focus_last_visible_pane_in_group(ctx);
FocusRegion::PaneGroup
}
}
// PREV: Pane group to prev pane, or at beginning to left panel to right panel to last pane
// Included Other here for cases like the command palette action "Activate next Pane"
(FocusRegion::PaneGroup, PanePanelDirection::Prev)
| (FocusRegion::Other, PanePanelDirection::Prev) => {
let did_move = self.focus_prev_pane_in_group(ctx);
if did_move {
FocusRegion::PaneGroup
} else if has_left_panel {
self.focus_left_region_entry(ctx);
FocusRegion::LeftPanel
} else if has_right_panel {
self.focus_right_region_entry(ctx);
FocusRegion::RightPanel
} else {
// No panels, wrap within panes.
self.focus_last_visible_pane_in_group(ctx);
FocusRegion::PaneGroup
}
}
}
}
fn update_pane_dimming_for_current_focus_region(&mut self, ctx: &mut ViewContext<Self>) {
let current_region = self.current_focus_region(ctx);
self.set_pane_dimming_for_region(current_region, ctx);
}
fn set_pane_dimming_for_region(&mut self, region: FocusRegion, ctx: &mut ViewContext<Self>) {
let dim_even_if_focused =
matches!(region, FocusRegion::LeftPanel | FocusRegion::RightPanel);
let handle = self.active_tab_pane_group().clone();
handle.update(ctx, |pane_group, ctx| {
pane_group.set_dim_even_if_focused_for_all_panes(dim_even_if_focused, ctx);
});
}
/// This function shifts focus to the panel on the left.
/// The current focusable panels are: Warp Drive, theme chooser, AI, and resource center (keyboard shortcuts page only)
fn focus_left_panel(&mut self, ctx: &mut ViewContext<Self>) {
// Starts from terminal
if self.active_tab_pane_group().is_self_or_child_focused(ctx) {
if self.current_workspace_state.is_warp_drive_open {
self.reset_focused_index_in_warp_drive(true, ctx);
} else if self.is_theme_chooser_open() {
ctx.focus(&self.theme_chooser_view);
} else if self.current_workspace_state.is_ai_assistant_panel_open {
ctx.focus(&self.ai_assistant_panel);
} else if self.current_workspace_state.is_resource_center_open {
ctx.focus(&self.resource_center_view);
}
}
// Starts from a right panel: AI panel, resource center (keyboard shortcuts page only)
else if self.ai_assistant_panel.is_self_or_child_focused(ctx)
|| self.resource_center_view.is_self_or_child_focused(ctx)
{
self.focus_active_tab(ctx);
}
// Starts from a left panel: Warp Drive
else if self.is_warp_drive_view_focused(ctx) {
if self.current_workspace_state.is_right_panel_open() {
self.set_selected_object(None, ctx);
if self.current_workspace_state.is_ai_assistant_panel_open {
ctx.focus(&self.ai_assistant_panel);
} else if self.current_workspace_state.is_resource_center_open {
ctx.focus(&self.resource_center_view);
}
} else {
self.focus_active_tab(ctx);
}
}
// Starts from a left panel: theme chooser
else if self.theme_chooser_view.is_self_or_child_focused(ctx) {
if self.current_workspace_state.is_right_panel_open() {
if self.current_workspace_state.is_ai_assistant_panel_open {
ctx.focus(&self.ai_assistant_panel);
} else if self.current_workspace_state.is_resource_center_open {
ctx.focus(&self.resource_center_view);
}
} else {
self.focus_active_tab(ctx);
}
}
self.update_pane_dimming_for_current_focus_region(ctx);
ctx.notify();
}
/// This function shifts focus to the panel on the right.
fn focus_right_panel(&mut self, ctx: &mut ViewContext<Self>) {
// Starts from terminal
if self.active_tab_pane_group().is_self_or_child_focused(ctx) {
if self.current_workspace_state.is_ai_assistant_panel_open {
ctx.focus(&self.ai_assistant_panel);
} else if self.current_workspace_state.is_resource_center_open {
ctx.focus(&self.resource_center_view);
} else if self.current_workspace_state.is_warp_drive_open {
self.reset_focused_index_in_warp_drive(true, ctx);
} else if self.is_theme_chooser_open() {
ctx.focus(&self.theme_chooser_view);
}
}
// Starts from a left panel: Warp Drive, theme chooser
else if self.is_warp_drive_view_focused(ctx)
|| self.theme_chooser_view.is_self_or_child_focused(ctx)
{
self.focus_active_tab(ctx);
}
// Starts from a right panel: AI panel, resource center (keyboard shortcuts page only)
else if self.ai_assistant_panel.is_self_or_child_focused(ctx)
|| self.resource_center_view.is_self_or_child_focused(ctx)
{
if self.current_workspace_state.is_left_panel_open() {
if self.current_workspace_state.is_warp_drive_open {
self.reset_focused_index_in_warp_drive(true, ctx);
} else if self.is_theme_chooser_open() {
ctx.focus(&self.theme_chooser_view);
}
} else {
self.focus_active_tab(ctx);
}
}
self.update_pane_dimming_for_current_focus_region(ctx);
ctx.notify();
}
pub fn active_tab_index(&self) -> usize {
self.active_tab_index
}
pub fn is_overflow_menu_showing(&self) -> bool {
self.show_tab_bar_overflow_menu
}
pub fn is_resource_center_showing(&self) -> bool {
self.current_workspace_state.is_resource_center_open
}
#[cfg(feature = "integration_tests")]
pub fn is_command_search_open(&self) -> bool {
self.current_workspace_state.is_command_search_open
}
/// Retrieves the Pane Group view for the passed tab index.
pub fn get_pane_group_view(&self, index: usize) -> Option<&ViewHandle<PaneGroup>> {
self.tabs.get(index).map(|s| &s.pane_group)
}
/// Retrieves the Pane Group view for the passed tab index. Unlike the other
/// method, this does not check for out of bounds.
pub fn get_pane_group_view_unchecked(&self, index: usize) -> &ViewHandle<PaneGroup> {
&self.tabs[index].pane_group
}
pub fn tab_count(&self) -> usize {
self.tabs.len()
}
pub fn tab_views(&self) -> impl Iterator<Item = &ViewHandle<PaneGroup>> {
self.tabs.iter().map(|s| &s.pane_group)
}
/// Get the tab color for a given tab index.
pub fn get_tab_color(&self, index: usize) -> Option<AnsiColorIdentifier> {
self.tabs.get(index).and_then(|tab| tab.color())
}
/// Get information needed for transferring a tab to another window.
/// Returns None if the index is invalid or if this is the last tab.
pub fn get_tab_transfer_info(&self, index: usize, ctx: &AppContext) -> Option<TransferredTab> {
if self.tabs.len() <= 1 {
return None;
}
let tab = self.tabs.get(index)?;
let pane_group = tab.pane_group.clone();
let color = tab.color();
let custom_title = pane_group.read(ctx, |pg, ctx| pg.custom_title(ctx));
let left_panel_open = pane_group.read(ctx, |pg, _| pg.left_panel_open);
let vertical_tabs_panel_open = self.vertical_tabs_panel_open;
let right_panel_open = pane_group.read(ctx, |pg, _| pg.right_panel_open);
let is_right_panel_maximized = pane_group.read(ctx, |pg, _| pg.is_right_panel_maximized);
Some(TransferredTab {
pane_group,
color,
custom_title,
left_panel_open,
vertical_tabs_panel_open,
right_panel_open,
is_right_panel_maximized,
})
}
/// Finds the tab index containing a terminal viewing the given ambient agent conversation,
/// returning None if the ambient conversation is not open in any tab.
fn find_tab_with_ambient_agent_conversation(
&self,
task_id: AmbientAgentTaskId,
ctx: &AppContext,
) -> Option<usize> {
// First, check ActiveAgentViewsModel for the terminal view that has this task registered.
// This is the authoritative source since it's updated when the session is joined.
let active_terminal_view_id =
ActiveAgentViewsModel::as_ref(ctx).get_terminal_view_id_for_ambient_task(task_id);
self.tabs.iter().enumerate().find_map(|(index, tab)| {
let pane_group = tab.pane_group.as_ref(ctx);
let has_task = pane_group.terminal_pane_ids().into_iter().any(|pane_id| {
pane_group
.terminal_view_from_pane_id(pane_id, ctx)
.is_some_and(|tv| {
// Check if this is the terminal view registered in ActiveAgentViewsModel
if active_terminal_view_id == Some(tv.id()) {
return true;
}
// Fall back to checking the terminal model directly
tv.as_ref(ctx).model.lock().ambient_agent_task_id() == Some(task_id)
})
});
has_task.then_some(index)
})
}
/// Gets all sessions in the current workspace.
pub fn workspace_sessions<'a>(
&'a self,
window_id: WindowId,
app: &'a AppContext,
) -> impl Iterator<Item = SessionNavigationData> + 'a {
self.tabs.iter().flat_map(move |tab| {
// Each tab has a pane group
let pane_group_id = tab.pane_group.id();
let view = tab.pane_group.as_ref(app);
view.pane_sessions(pane_group_id, window_id, app)
})
}
pub fn set_server_time(&mut self, server_time: Arc<ServerTime>) {
self.server_time = Some(server_time);
}
/// Returns the PaneGroup view handle for the currently active tab.
pub fn active_tab_pane_group(&self) -> &ViewHandle<PaneGroup> {
self.get_pane_group_view(self.active_tab_index)
.expect("Active tab index entry should exist")
}
/// Attempts to get selected text from the focused pane.
/// Returns None if there is no selection, multiple selections, or an empty selection.
/// Supports code, notebook, AI document, and terminal panes.
fn get_selected_text_from_focused_view(&self, ctx: &AppContext) -> Option<String> {
self.active_tab_pane_group()
.as_ref(ctx)
.selected_text_from_focused_pane(ctx)
}
/// This is meant to be dispatched directly by actions.
pub fn activate_tab(&mut self, index: usize, ctx: &mut ViewContext<Self>) {
self.activate_tab_internal(index, ctx);
ctx.notify();
}
/// This function is meant to be used by other actions to perform the logic to update the
/// view's state. It's not meant to be invoked directly by an action.
pub fn activate_tab_internal(&mut self, index: usize, ctx: &mut ViewContext<Self>) {
if index < self.tab_count() {
// If the command palette is open when the tab is switched using a keybinding,
// we want to close the palette so that we don't get into a state where the palette
// is open but doesn't have focus.
if self.is_palette_open() {
self.close_palette(false, None, ctx);
}
// If the agent management view is open, we want to close it when we activate a new tab.
if FeatureFlag::AgentManagementView.is_enabled() {
self.set_is_agent_management_view_open(false, ctx);
}
self.set_active_tab_index(index, ctx);
self.focus_active_tab(ctx);
self.update_window_title(ctx);
}
}
fn left_panel_visibility_across_tabs_enabled(&self, ctx: &AppContext) -> bool {
*WindowSettings::as_ref(ctx)
.left_panel_visibility_across_tabs
.value()
}
/// Reconciles the active tab's tools panel open/closed state to match the window-scoped desired state
/// (syncing left panel open/closed state across tabs).
fn reconcile_left_panel_open_for_active_tab(&mut self, ctx: &mut ViewContext<Self>) {
let pane_group = self.active_tab_pane_group().clone();
let pane_group_supports_tools_panel = pane_group.read(ctx, |pane_group, _| {
Self::should_enable_file_tree_and_global_search_for_pane_group(pane_group)
});
if !pane_group_supports_tools_panel {
return;
}
let desired_open = self.left_panel_open;
pane_group.update(ctx, |pane_group, ctx| {
pane_group.set_left_panel_open(desired_open, ctx);
});
}
/// Notifies the agent views model and notifications model that a terminal view gained focus.
fn notify_terminal_focus_change(
&self,
focused_terminal_view_id: Option<EntityId>,
ambient_agent_task_id: Option<AmbientAgentTaskId>,
ctx: &mut ViewContext<Self>,
) {
let window_id = ctx.window_id();
ActiveAgentViewsModel::handle(ctx).update(ctx, |model, ctx| {
model.handle_pane_focus_change(
window_id,
focused_terminal_view_id,
ambient_agent_task_id,
ctx,
);
});
if let Some(terminal_view_id) = focused_terminal_view_id {
let is_active_window = ctx.windows().active_window() == Some(ctx.window_id());
if is_active_window {
AgentNotificationsModel::handle(ctx).update(ctx, |model, ctx| {
model.mark_items_from_terminal_view_read(terminal_view_id, ctx);
});
}
}
}
/// Change the active tab index. This must be used instead of setting `self.active_tab_index`
/// directly, as it updates related state.
fn set_active_tab_index(&mut self, index: usize, ctx: &mut ViewContext<Self>) {
let index = if index >= self.tab_count() {
log::warn!(
"Attempted to set active tab index {index} but only {} tabs exist, clamping",
self.tab_count()
);
self.tab_count().saturating_sub(1)
} else {
index
};
self.active_tab_index = index;
if self.vertical_tabs_panel_open
&& FeatureFlag::VerticalTabs.is_enabled()
&& *TabSettings::as_ref(ctx).use_vertical_tabs
{
self.vertical_tabs_panel.scroll_to_tab(index);
}
if self.left_panel_visibility_across_tabs_enabled(ctx) {
self.reconcile_left_panel_open_for_active_tab(ctx);
}
let left_active_pane_group = self.active_tab_pane_group().clone();
let right_active_pane_group = self.active_tab_pane_group().clone();
let working_directories_model = self.working_directories_model.clone();
self.left_panel_view.update(ctx, |left_panel, ctx| {
left_panel.set_active_pane_group(
left_active_pane_group,
&working_directories_model,
ctx,
);
});
self.right_panel_view.update(ctx, |right_pane, ctx| {
right_pane.set_active_pane_group(
right_active_pane_group,
&working_directories_model,
ctx,
);
});
let pane_group = self.active_tab_pane_group();
let focused_terminal_view_id = self
.active_tab_pane_group()
.as_ref(ctx)
.terminal_view_from_pane_id(pane_group.as_ref(ctx).focused_pane_id(ctx), ctx)
.map(|tv| tv.id());
let ambient_agent_task_id = self
.get_active_session_terminal_model(ctx)
.and_then(|model| model.lock().ambient_agent_task_id());
self.notify_terminal_focus_change(focused_terminal_view_id, ambient_agent_task_id, ctx);
self.update_active_session(ctx);
}
fn update_window_title(&self, ctx: &mut ViewContext<Self>) {
let Some(tab) = self.tabs.get(self.active_tab_index) else {
log::warn!(
"Tried to update window title but active tab index ({}) was out of range 0..{}",
self.active_tab_index,
self.tabs.len()
);
return;
};
let tab_title = tab.pane_group.as_ref(ctx).display_title(ctx);
let window_title = truncate_from_end(&tab_title, MAX_WINDOW_TITLE_LENGTH);
let window_id = ctx.window_id();
ctx.windows().set_window_title(window_id, &window_title);
}
fn rename_tab_internal(&mut self, index: usize, title: &str, ctx: &mut ViewContext<Self>) {
// Focusing on the clicked tab
if index >= self.tab_count() {
return;
}
// If the agent management view is open, we want to close it when we change focus to rename a tab.
// This function doesn't call `activate_tab_internal`, which is why we need the extra check here.
if FeatureFlag::AgentManagementView.is_enabled() {
self.set_is_agent_management_view_open(false, ctx);
}
self.set_active_tab_index(index, ctx);
self.current_workspace_state.set_tab_being_renamed(index);
// Clear the tab name editor to handle the case when another tab is already being renamed
self.clear_tab_name_editor(ctx);
let font_size = Self::tab_rename_editor_font_size(ctx, Appearance::as_ref(ctx));
self.tab_rename_editor.update(ctx, move |editor, ctx| {
editor.set_font_size(font_size, ctx);
editor.insert_selected_text(title, ctx);
});
ctx.focus(&self.tab_rename_editor);
ctx.notify();
}
pub fn rename_tab(&mut self, index: usize, ctx: &mut ViewContext<Self>) {
let tab = &self.tabs[index];
let title = tab.pane_group.as_ref(ctx).display_title(ctx);
self.rename_tab_internal(index, &title, ctx);
send_telemetry_from_ctx!(
TelemetryEvent::TabRenamed(TabRenameEvent::OpenedEditor),
ctx
);
}
fn set_active_tab_name(&mut self, title: &str, ctx: &mut ViewContext<Self>) {
let Some(pane_group) = self
.tabs
.get(self.active_tab_index)
.map(|tab| tab.pane_group.clone())
else {
log::warn!(
"Tried to set active tab name but active tab index ({}) was out of range 0..{}",
self.active_tab_index,
self.tabs.len()
);
return;
};
if self.current_workspace_state.is_tab_being_renamed() {
self.current_workspace_state.clear_tab_being_renamed();
self.clear_tab_name_editor(ctx);
}
let title = title.trim();
if title.is_empty() {
ctx.notify();
return;
}
pane_group.update(ctx, |pane_group, ctx| {
if pane_group.display_title(ctx) != title {
pane_group.set_title(title, ctx);
send_telemetry_from_ctx!(
TelemetryEvent::TabRenamed(TabRenameEvent::CustomNameSet),
ctx
);
}
});
ctx.notify();
}
pub fn toggle_tab_color(
&mut self,
index: usize,
color: AnsiColorIdentifier,
ctx: &mut ViewContext<Self>,
) {
if self.tabs.get(index).is_none() {
log::warn!(
"Not toggling tab color: index was {index} but len is {}",
self.tabs.len()
);
return;
}
let is_same = self.tabs[index].color() == Some(color);
self.tabs[index].selected_color = if is_same {
send_telemetry_from_ctx!(
TelemetryEvent::TabOperations {
action: TabTelemetryAction::ResetColor,
},
ctx
);
if FeatureFlag::DirectoryTabColors.is_enabled() {
SelectedTabColor::Cleared
} else {
SelectedTabColor::Unset
}
} else {
send_telemetry_from_ctx!(
TelemetryEvent::TabOperations {
action: TabTelemetryAction::SetColor,
},
ctx
);
SelectedTabColor::Color(color)
};
ctx.notify();
}
/// Syncs the tab color for the given tab based on the active terminal's CWD.
/// If the CWD is within a directory that has a configured color, applies it.
/// If the CWD moves outside all configured directories, the directory color is cleared.
fn sync_codebase_tab_color(tab: &mut TabData, ctx: &mut ViewContext<Self>) {
let cwd = tab
.pane_group
.as_ref(ctx)
.active_session_view(ctx)
.and_then(|tv| tv.as_ref(ctx).pwd_if_local(ctx));
let Some(cwd) = cwd else {
return;
};
let cwd_path = Path::new(&cwd);
let color = TabSettings::as_ref(ctx)
.directory_tab_colors
.value()
.color_for_directory(cwd_path)
.and_then(|c| c.ansi_color());
tab.default_directory_color = color;
ctx.notify();
}
fn clear_tab_name_editor(&mut self, ctx: &mut ViewContext<Self>) {
self.tab_rename_editor.update(ctx, move |editor, ctx| {
editor.clear_buffer_and_reset_undo_stack(ctx);
});
}
fn clear_pane_name_editor(&mut self, ctx: &mut ViewContext<Self>) {
self.pane_rename_editor.update(ctx, move |editor, ctx| {
editor.clear_buffer_and_reset_undo_stack(ctx);
});
}
pub fn clear_tab_name(&mut self, index: usize, ctx: &mut ViewContext<Self>) {
let tab = &self.tabs[index];
tab.pane_group.update(ctx, |view, ctx| {
view.clear_title(ctx);
});
send_telemetry_from_ctx!(
TelemetryEvent::TabRenamed(TabRenameEvent::CustomNameCleared),
ctx
);
self.update_window_title(ctx);
ctx.notify();
}
fn set_custom_pane_name(
&mut self,
locator: PaneViewLocator,
title: String,
ctx: &mut ViewContext<Self>,
) {
let Some(pane_group_view) = self.get_pane_group_view_with_id(locator.pane_group_id) else {
log::warn!("Tried to rename pane in a missing pane group");
return;
};
pane_group_view.update(ctx, |pane_group, ctx| {
let Some(pane) = pane_group.pane_by_id(locator.pane_id) else {
log::warn!("Tried to rename a missing pane");
return;
};
pane.pane_configuration().update(ctx, |configuration, ctx| {
configuration.set_custom_vertical_tabs_title(title, ctx);
});
ctx.emit(pane_group::Event::AppStateChanged);
});
}
pub fn clear_pane_name(&mut self, locator: PaneViewLocator, ctx: &mut ViewContext<Self>) {
let Some(pane_group_view) = self.get_pane_group_view_with_id(locator.pane_group_id) else {
log::warn!("Tried to clear pane name in a missing pane group");
return;
};
pane_group_view.update(ctx, |pane_group, ctx| {
let Some(pane) = pane_group.pane_by_id(locator.pane_id) else {
log::warn!("Tried to clear a missing pane name");
return;
};
pane.pane_configuration().update(ctx, |configuration, ctx| {
configuration.clear_custom_vertical_tabs_title(ctx);
});
ctx.emit(pane_group::Event::AppStateChanged);
});
ctx.dispatch_global_action("workspace:save_app", ());
ctx.notify();
}
pub fn rename_pane(&mut self, locator: PaneViewLocator, ctx: &mut ViewContext<Self>) {
let Some((index, tab)) = self
.tabs
.iter()
.enumerate()
.find(|(_, tab_data)| tab_data.pane_group.id() == locator.pane_group_id)
else {
log::warn!("Tried to rename pane in a missing tab");
return;
};
let Some(title) = tab
.pane_group
.as_ref(ctx)
.pane_by_id(locator.pane_id)
.map(|pane| {
let configuration = pane.pane_configuration();
let configuration = configuration.as_ref(ctx);
configuration
.custom_vertical_tabs_title()
.map(str::to_owned)
.unwrap_or_else(|| {
let title = configuration.title().trim();
if title.is_empty() {
"Untitled pane".to_string()
} else {
title.to_string()
}
})
})
else {
log::warn!("Tried to rename a missing pane");
return;
};
tab.pane_group.update(ctx, |pane_group, ctx| {
pane_group.focus_pane_by_id(locator.pane_id, ctx);
});
self.set_active_tab_index(index, ctx);
self.current_workspace_state.set_pane_being_renamed(locator);
self.clear_pane_name_editor(ctx);
self.pane_rename_editor.update(ctx, move |editor, ctx| {
editor.insert_selected_text(&title, ctx);
});
ctx.focus(&self.pane_rename_editor);
ctx.notify();
}
pub fn list_tab_pane_groups(&self, app: &AppContext) -> Vec<TabPaneGroupIdentifiers> {
self.tabs
.iter()
.enumerate()
.map(|(tab_idx, tab)| {
let pane_group_id = tab.pane_group.id();
let pane_group = tab.pane_group.as_ref(app);
let pane_ids = pane_group.terminal_pane_ids();
let terminal_ids = pane_ids
.into_iter()
.filter_map(|pane_id| {
let terminal_view = pane_group.terminal_view_from_pane_id(pane_id, app)?;
Some(terminal_view.id())
})
.collect::<Vec<_>>();
TabPaneGroupIdentifiers {
tab_idx,
pane_group_id,
terminal_ids,
}
})
.collect::<Vec<_>>()
}
/// Focuses the given pane within the pane group.
pub fn focus_pane(&mut self, pane_view_locator: PaneViewLocator, ctx: &mut ViewContext<Self>) {
if let Some((index, tab)) = self
.tabs
.iter()
.enumerate()
.find(|(_, tab_data)| tab_data.pane_group.id() == pane_view_locator.pane_group_id)
{
// Update the pane group to focus the active pane,
// and then focus the pane group (tab). The order is important
// because if we otherwise focus the tab first and another pane
// was focused in the mean time, that pane will be the one that will
// remain focused (as opposed to the pane with pane_id) since its
// input would remain focused.
tab.pane_group.update(ctx, |view, ctx| {
view.focus_pane_by_id(pane_view_locator.pane_id, ctx);
});
self.activate_tab_internal(index, ctx);
ctx.notify();
}
}
/// Searches this workspace's tabs for the given terminal view and focuses it.
/// Returns true if the terminal view was found and focused.
fn focus_terminal_view_locally(
&mut self,
terminal_view_id: EntityId,
ctx: &mut ViewContext<Self>,
) -> bool {
for tab in self.tabs.iter() {
let pane_group_handle = &tab.pane_group;
let pane_group = pane_group_handle.as_ref(ctx);
if let Some(pane_id) = pane_group.find_pane_id_for_terminal_view(terminal_view_id, ctx)
{
self.focus_pane(
PaneViewLocator {
pane_group_id: pane_group_handle.id(),
pane_id,
},
ctx,
);
return true;
}
}
false
}
/// Searches other windows for the given terminal view and focuses it there.
/// (Uses the same cross-window dispatch pattern as open_notebook/open_workflow.)
fn focus_terminal_view_in_other_window(
&self,
terminal_view_id: EntityId,
ctx: &mut ViewContext<Self>,
) {
let current_window = ctx.window_id();
let result = WorkspaceRegistry::as_ref(ctx)
.all_workspaces(ctx)
.iter()
.filter(|(win_id, _)| *win_id != current_window)
.find_map(|(win_id, workspace)| {
workspace.as_ref(ctx).tab_views().find_map(|pane_group| {
let pane_id = pane_group
.as_ref(ctx)
.find_pane_id_for_terminal_view(terminal_view_id, ctx)?;
Some((
*win_id,
PaneViewLocator {
pane_group_id: pane_group.id(),
pane_id,
},
))
})
});
if let Some((window_id, locator)) = result {
ctx.windows().show_window_and_focus_app(window_id);
if let Some(root_view_id) = ctx.root_view_id(window_id) {
ctx.dispatch_action_for_view(
window_id,
root_view_id,
"root_view:handle_pane_navigation_event",
&locator,
);
}
}
}
/// Shows the notification error in the specific pane.
pub fn show_notification_error(
&mut self,
notification_error: NotificationSendError,
pane_group_id: EntityId,
pane_id: PaneId,
ctx: &mut ViewContext<Self>,
) {
if let Some(tab) = self
.tabs
.iter()
.find(|tab_data| tab_data.pane_group.id() == pane_group_id)
{
tab.pane_group.update(ctx, |view, ctx| {
view.show_notification_error(notification_error, pane_id, ctx);
});
ctx.notify();
}
}
/// Handle the close event from the reward modal
fn handle_reward_modal_event(&mut self, event: &ModalEvent, ctx: &mut ViewContext<Self>) {
match event {
ModalEvent::Close => {
self.current_workspace_state.is_reward_modal_open = false;
self.focus_active_tab(ctx);
ctx.notify();
}
}
}
fn handle_suggested_agent_mode_workflow_modal_event(
&mut self,
event: &SuggestedAgentModeWorkflowModalEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
SuggestedAgentModeWorkflowModalEvent::Close
| SuggestedAgentModeWorkflowModalEvent::WorkflowCreated => {
self.current_workspace_state
.is_suggested_agent_mode_workflow_modal_open = false;
self.focus_active_tab(ctx);
ctx.notify();
}
SuggestedAgentModeWorkflowModalEvent::RunWorkflow {
workflow,
source,
argument_override,
workflow_selection_source,
} => {
self.run_workflow_in_active_input(
workflow,
*source.clone(),
*workflow_selection_source,
argument_override.clone(),
TerminalSessionFallbackBehavior::default(),
ctx,
);
}
}
}
/// Handle the call-to-action event from the reward modal view
fn handle_reward_view_event(&mut self, event: &RewardEvent, ctx: &mut ViewContext<Self>) {
match event {
RewardEvent::OpenThemePicker => {
self.current_workspace_state.is_reward_modal_open = false;
self.show_theme_chooser_for_active_theme(ctx);
}
}
}
fn handle_prompt_editor_modal_event(
&mut self,
event: &PromptEditorModalEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
PromptEditorModalEvent::Close => {
self.current_workspace_state.is_prompt_editor_open = false;
self.focus_active_tab(ctx);
ctx.notify();
}
}
}
fn handle_agent_toolbar_editor_modal_event(
&mut self,
event: &AgentToolbarEditorEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
AgentToolbarEditorEvent::Close => {
self.current_workspace_state.is_agent_toolbar_editor_open = false;
self.focus_active_tab(ctx);
ctx.notify();
}
}
}
fn build_header_toolbar_editor_modal(
ctx: &mut ViewContext<Self>,
) -> ViewHandle<HeaderToolbarEditorModal> {
let modal = ctx.add_typed_action_view(HeaderToolbarEditorModal::new);
ctx.subscribe_to_view(&modal, |me, _, event, ctx| {
me.handle_header_toolbar_editor_modal_event(event, ctx);
});
modal
}
fn handle_header_toolbar_editor_modal_event(
&mut self,
event: &HeaderToolbarEditorEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
HeaderToolbarEditorEvent::Close => {
self.current_workspace_state.is_header_toolbar_editor_open = false;
self.focus_active_tab(ctx);
ctx.notify();
}
}
}
fn ensure_tabs_panel_in_config(ctx: &mut ViewContext<Self>) {
let config = TabSettings::as_ref(ctx)
.header_toolbar_chip_selection
.clone();
let left = config.left_items();
let right = config.right_items();
let already_present = left.contains(&HeaderToolbarItemKind::TabsPanel)
|| right.contains(&HeaderToolbarItemKind::TabsPanel);
if already_present {
return;
}
let mut new_left = left;
new_left.insert(0, HeaderToolbarItemKind::TabsPanel);
let selection = HeaderToolbarChipSelection::Custom {
left: new_left,
right,
};
TabSettings::handle(ctx).update(ctx, |settings, ctx| {
report_if_error!(settings
.header_toolbar_chip_selection
.set_value(selection, ctx));
});
}
fn sync_panel_positions_from_config(&mut self, ctx: &mut ViewContext<Self>) {
let config = TabSettings::as_ref(ctx)
.header_toolbar_chip_selection
.clone();
let left_items = config.left_items();
let tools_position = if left_items.contains(&HeaderToolbarItemKind::ToolsPanel) {
PanelPosition::Left
} else {
PanelPosition::Right
};
let code_review_position = if left_items.contains(&HeaderToolbarItemKind::CodeReview) {
PanelPosition::Left
} else {
PanelPosition::Right
};
self.left_panel_view.update(ctx, |view, ctx| {
view.set_panel_position(tools_position, ctx);
});
self.right_panel_view.update(ctx, |view, ctx| {
view.set_panel_position(code_review_position, ctx);
});
}
fn build_header_toolbar_context_menu(
ctx: &mut ViewContext<Self>,
) -> ViewHandle<Menu<WorkspaceAction>> {
let menu = ctx.add_typed_action_view(|_| Menu::new().with_drop_shadow());
ctx.subscribe_to_view(&menu, |me, _, event, ctx| {
if let MenuEvent::Close { .. } = event {
me.show_header_toolbar_context_menu = None;
ctx.notify();
}
});
menu
}
fn show_header_toolbar_context_menu(
&mut self,
position: Vector2F,
ctx: &mut ViewContext<Self>,
) {
if !FeatureFlag::ConfigurableToolbar.is_enabled() {
return;
}
let items = vec![MenuItemFields::new("Re-arrange toolbar items")
.with_on_select_action(WorkspaceAction::OpenHeaderToolbarEditor)
.into_item()];
self.header_toolbar_context_menu
.update(ctx, |menu, ctx| menu.set_items(items, ctx));
self.show_header_toolbar_context_menu = Some(position);
ctx.focus(&self.header_toolbar_context_menu);
ctx.notify();
}
fn open_header_toolbar_editor(&mut self, ctx: &mut ViewContext<Self>) {
if !FeatureFlag::ConfigurableToolbar.is_enabled() {
return;
}
self.header_toolbar_editor_modal
.update(ctx, |modal, ctx| modal.open(ctx));
self.close_all_overlays(ctx);
self.current_workspace_state.is_header_toolbar_editor_open = true;
ctx.focus(&self.header_toolbar_editor_modal);
}
fn handle_ai_fact_view_event(&mut self, event: &AIFactViewEvent, ctx: &mut ViewContext<Self>) {
match event {
AIFactViewEvent::OpenSettings => {
self.show_settings_with_section(Some(SettingsSection::WarpAgent), ctx);
}
#[allow(unused_variables)]
AIFactViewEvent::OpenFile(path) => {
#[cfg(feature = "local_fs")]
self.open_code(
CodeSource::Link {
path: path.clone(),
range_start: None,
range_end: None,
},
*EditorSettings::as_ref(ctx).open_file_layout.value(),
None, // no line/column specified
false, // preview
&[],
ctx,
);
}
AIFactViewEvent::InitializeProject(path) => {
let active_terminal_view = self
.active_tab_pane_group()
.as_ref(ctx)
.active_session_view(ctx);
if let Some(terminal_view) = active_terminal_view {
terminal_view.update(ctx, |terminal_view, ctx| {
terminal_view.open_repo_folder(
path.to_string_lossy().to_string(),
true,
ctx,
);
});
}
}
_ => {}
}
}
fn handle_agent_management_view_event(
&mut self,
event: &AgentManagementViewEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
AgentManagementViewEvent::OpenNewTabAndRunWorkflow(workflow) => {
self.add_terminal_tab(false, ctx);
self.run_workflow_in_active_input(
workflow,
WorkflowSource::App,
WorkflowSelectionSource::Undefined,
None,
TerminalSessionFallbackBehavior::default(),
ctx,
);
}
AgentManagementViewEvent::OpenPlanNotebook { notebook_uid } => {
self.open_notebook(
&NotebookSource::Existing((*notebook_uid).into()),
&OpenWarpDriveObjectSettings::default(),
ctx,
false,
);
}
}
}
#[cfg(feature = "local_fs")]
fn get_active_session(&self, ctx: &mut ViewContext<Self>) -> Option<Arc<Session>> {
let pane_group = self.active_tab_pane_group();
pane_group
.as_ref(ctx)
.active_session_id(ctx)
.and_then(|session_id| {
pane_group
.as_ref(ctx)
.terminal_view_from_pane_id(session_id, ctx)
})
.and_then(|tv| {
let tv_ref = tv.as_ref(ctx);
let session_id = tv_ref.active_block_session_id()?;
tv_ref.sessions_model().as_ref(ctx).get(session_id)
})
}
#[cfg(not(feature = "local_fs"))]
pub fn open_file_with_target(
&mut self,
_path: PathBuf,
_target: FileTarget,
_line_col: Option<LineAndColumnArg>,
_code_source: CodeSource,
_ctx: &mut ViewContext<Self>,
) {
}
#[cfg(feature = "local_fs")]
pub fn open_file_with_target(
&mut self,
path: PathBuf,
target: FileTarget,
line_col: Option<LineAndColumnArg>,
code_source: CodeSource,
ctx: &mut ViewContext<Self>,
) {
// Handle directories for CodeEditor(NewTab) target by opening a new terminal tab
if path.is_dir() && matches!(target, FileTarget::CodeEditor(EditorLayout::NewTab)) {
self.add_tab_with_pane_layout(
PanesLayout::SingleTerminal(Box::new(NewTerminalOptions {
initial_directory: Some(path.clone()),
hide_homepage: true,
..Default::default()
})),
Arc::new(HashMap::new()),
None,
ctx,
);
return;
}
match target {
FileTarget::MarkdownViewer(layout) => {
let session = self.get_active_session(ctx);
self.open_file_notebook(path.clone(), session, layout, ctx);
}
FileTarget::EnvEditor => {
let editor_value: Option<String> = self
.get_active_session(ctx)
.and_then(|session| session.editor().map(|s| s.to_string()));
if let Some(ref editor_env) = editor_value {
if let Ok(editor) = Editor::try_from(editor_env.as_str()) {
crate::util::file::open_file_path_with_editor(
line_col,
path.clone(),
Some(editor),
ctx,
);
return;
}
// If we have an editor string but it's not a known Editor, we try to run it in a new pane
let new_pane_id =
self.active_tab_pane_group().update(ctx, |pane_group, ctx| {
pane_group.add_terminal_pane(
Direction::Right,
None, /*chosen_shell*/
ctx,
)
});
if let Some(terminal_view_handle) = self
.active_tab_pane_group()
.as_ref(ctx)
.terminal_view_from_pane_id(new_pane_id, ctx)
{
let editor_ref = Some(editor_env.as_str());
let path_clone = path.clone();
terminal_view_handle.update(ctx, |terminal, ctx| {
let editor_command =
crate::util::file::external_editor::generate_editor_command(
&path_clone,
line_col,
editor_ref,
);
terminal.set_pending_command(&editor_command, ctx);
});
return;
} else {
log::error!(
"Could not get terminal view handle for new pane when attempting to open file with $EDITOR."
);
}
}
crate::util::file::open_file_path_in_external_editor(line_col, path.clone(), ctx);
}
FileTarget::CodeEditor(layout) => {
let open_as_preview = false;
self.open_code(code_source, layout, line_col, open_as_preview, &[], ctx);
}
FileTarget::ExternalEditor(editor) => {
crate::util::file::open_file_path_with_editor(
line_col,
path.clone(),
Some(editor),
ctx,
);
}
FileTarget::SystemDefault => {
crate::util::file::open_file_path_with_editor(line_col, path.clone(), None, ctx);
}
FileTarget::SystemGeneric => {
ctx.open_file_path(&path);
}
}
}
fn handle_left_panel_event(&mut self, event: &LeftPanelEvent, ctx: &mut ViewContext<Self>) {
match event {
LeftPanelEvent::FileTree(pane_group_event) => {
let pane_group = self.active_tab_pane_group().clone();
self.handle_file_tree_event(pane_group, pane_group_event, ctx);
}
LeftPanelEvent::WarpDrive(drive_event) => {
self.handle_warp_drive_event(drive_event, ctx);
}
LeftPanelEvent::OpenFileWithTarget {
path,
target,
line_col,
} => {
self.open_file_with_target(
path.clone(),
target.clone(),
*line_col,
CodeSource::FileTree { path: path.clone() },
ctx,
);
}
LeftPanelEvent::NewConversationInNewTab => {
self.add_terminal_tab_with_new_agent_view(ctx);
}
LeftPanelEvent::ShowDeleteConfirmationDialog {
conversation_id,
conversation_title,
terminal_view_id,
} => {
self.show_delete_conversation_confirmation_dialog(
DeleteConversationDialogSource {
conversation_id: *conversation_id,
conversation_title: conversation_title.clone(),
terminal_view_id: *terminal_view_id,
},
ctx,
);
}
}
}
fn handle_right_panel_event(&mut self, event: RightPanelEvent, ctx: &mut ViewContext<Self>) {
#[cfg(feature = "local_fs")]
match event {
RightPanelEvent::ToggleMaximize => {
self.toggle_right_panel_maximized(ctx);
}
RightPanelEvent::OpenFileWithTarget {
path,
target,
line_col,
} => {
// Exit maximized mode so the opened file is visible.
if self
.active_tab_pane_group()
.as_ref(ctx)
.is_right_panel_maximized
{
self.toggle_right_panel_maximized(ctx);
}
self.open_file_with_target(
path.clone(),
target,
line_col,
CodeSource::Link {
path,
range_start: None,
range_end: None,
},
ctx,
);
}
RightPanelEvent::OpenFileInNewTab {
path,
line_and_column,
} => {
self.add_tab_for_code_file(path, line_and_column, ctx);
}
#[cfg(not(target_family = "wasm"))]
RightPanelEvent::OpenLspLogs { log_path } => {
self.open_lsp_logs(&log_path, ctx);
}
}
#[cfg(not(feature = "local_fs"))]
let _ = (event, ctx);
}
/// Show the referral reward modal page, informing the user they have earned a theme reward
fn show_reward_modal(&mut self, kind: RewardKind, ctx: &mut ViewContext<Self>) {
// For certain context, like landing on a shared session, we don't want to show the reward modal
// or side panel.
if !ContextFlag::ShowRewardModal.is_enabled() {
return;
}
self.reward_modal.update(ctx, |modal, modal_ctx| {
modal.body().update(modal_ctx, |view, view_ctx| {
view.update_reward_kind(kind, view_ctx);
});
});
ctx.focus(&self.reward_modal);
self.reward_modal_pending = None;
self.current_workspace_state.is_reward_modal_open = true;
ctx.notify();
}
fn join_slack(&mut self, ctx: &mut ViewContext<Self>) {
ctx.open_url(links::SLACK_URL);
}
fn view_user_docs(&mut self, ctx: &mut ViewContext<Self>) {
ctx.open_url(links::USER_DOCS_URL);
}
fn view_latest_changelog(&mut self, ctx: &mut ViewContext<Self>) {
self.update_toast_stack.update(ctx, |stack, ctx| {
stack.clear_toasts(ctx);
});
self.tips_completed.update(ctx, |tips_completed, ctx| {
mark_feature_used_and_write_to_user_defaults(
Tip::Action(TipAction::Changelog),
tips_completed,
ctx,
);
ctx.notify();
});
self.check_for_changelog(ChangelogRequestType::UserAction, ctx);
}
fn view_privacy_policy(&mut self, ctx: &mut ViewContext<Self>) {
ctx.open_url(links::PRIVACY_POLICY_URL);
}
fn send_feedback(&mut self, ctx: &mut ViewContext<Self>) {
// When AI is available (enabled, with remaining requests) and the feedback skill is
// bundled on this channel, open a new agent pane and prime the input with `/feedback `
// so the user can describe their feedback in their own words before submitting. The
// skill is only invoked when they hit enter. Otherwise fall back to the form URL so
// logged-out, credit-exhausted, AI-disabled, and stable-channel users still have a
// way to send feedback.
if !crate::workspace::is_feedback_skill_available(ctx) {
ctx.open_url(&links::feedback_form_url());
return;
}
self.active_tab_pane_group().update(ctx, |pane_group, ctx| {
pane_group.add_terminal_pane_in_agent_mode(Some("/feedback "), None, ctx);
if let Some(terminal_view) = pane_group.focused_session_view(ctx) {
terminal_view.update(ctx, |terminal_view, terminal_view_ctx| {
terminal_view
.input()
.update(terminal_view_ctx, |input, input_ctx| {
input.editor().update(input_ctx, |editor, editor_ctx| {
// Show a muted placeholder after the primed prefix so the user
// knows they can describe their feedback before submitting. The
// placeholder auto-hides as soon as they start typing and is
// cleared on submit alongside any other placeholder text.
editor.set_placeholder_text_with_prefix(
"/feedback ",
"Describe what's broken, confusing, or missing...",
editor_ctx,
);
});
});
});
}
});
}
#[cfg(not(target_family = "wasm"))]
fn view_logs(&mut self, ctx: &mut ViewContext<Self>) {
ctx.spawn(
async { tokio::task::spawn_blocking(galaxy_logging::create_log_bundle_zip).await },
|me, result, ctx| match result {
Ok(Ok(path)) => {
ctx.open_file_path_in_explorer(&path);
}
Ok(Err(err)) => {
let error_message = format!("Failed to create log bundle: {err}");
log::error!("{error_message}");
me.toast_stack.update(ctx, |toast_stack, ctx| {
let toast = DismissibleToast::error(error_message);
toast_stack.add_persistent_toast(toast, ctx);
});
}
Err(err) => {
let error_message = format!("Failed to create log bundle: {err}");
log::error!("{error_message}");
me.toast_stack.update(ctx, |toast_stack, ctx| {
let toast = DismissibleToast::error(error_message);
toast_stack.add_persistent_toast(toast, ctx);
});
}
},
);
}
fn copy_version(&mut self, version: &str, ctx: &mut ViewContext<Self>) {
ctx.clipboard()
.write(ClipboardContent::plain_text(version.to_string()));
}
fn export_all_warp_drive_objects(&mut self, ctx: &mut ViewContext<Self>) {
let window_id = ctx.window_id();
let cloud_model = CloudModel::as_ref(ctx);
let exportable_objects = cloud_model.get_all_exportable_object_ids();
ExportManager::handle(ctx).update(ctx, move |export_manager, ctx| {
export_manager.export(window_id, &exportable_objects, ctx);
});
}
/// Builds the unified new-session menu items
/// tab bar chevron and the vertical tab bar `+` button.
///
/// Order: Agent → Terminal (sidecar) → Cloud Oz → [tab configs] → separator → New worktree config (sidecar) → New tab config.
fn unified_new_session_menu_items(
&self,
ctx: &mut ViewContext<Self>,
) -> Vec<MenuItem<WorkspaceAction>> {
let mut menu_items = vec![];
let is_any_ai_enabled = AISettings::as_ref(ctx).is_any_ai_enabled(ctx);
let ai_settings = AISettings::as_ref(ctx);
let effective_default = ai_settings.default_session_mode(ctx);
let default_tab_config_path = ai_settings.default_tab_config_path().to_string();
let shortcut_label = keybinding_name_to_display_string(NEW_TAB_BINDING_NAME, ctx);
// 1. Agent (if AI enabled)
if is_any_ai_enabled {
let mut agent_item = MenuItemFields::new("Agent")
.with_on_select_action(WorkspaceAction::AddAgentTab)
.with_icon(icons::Icon::LayoutAlt01);
if effective_default == DefaultSessionMode::Agent {
agent_item = agent_item.with_key_shortcut_label(shortcut_label.clone());
}
menu_items.push(agent_item.into_item());
}
// 2. Terminal (+ individual shells on Windows)
{
// On Windows, list the default terminal and each available shell as
// individual top-level items (no submenu) so each gets a sidecar.
#[cfg(target_os = "windows")]
{
let is_terminal_default = effective_default == DefaultSessionMode::Terminal;
let mut terminal_item = MenuItemFields::new("Terminal")
.with_on_select_action(WorkspaceAction::AddTerminalTab {
hide_homepage: false,
})
.with_icon(icons::Icon::LayoutAlt01);
if is_terminal_default {
terminal_item = terminal_item.with_key_shortcut_label(shortcut_label.clone());
}
menu_items.push(terminal_item.into_item());
#[cfg(feature = "local_tty")]
if FeatureFlag::ShellSelector.is_enabled() {
AvailableShells::handle(ctx).read(ctx, |model, _| {
for shell in model.get_available_shells() {
let shell_name = model.display_name_for_shell(shell);
let icon = shell
.get_valid_shell_path_and_type()
.and_then(|shell_launch_data| {
ShellIndicatorType::try_from(&shell_launch_data).ok()
})
.map(|shell_indicator_type| shell_indicator_type.to_icon())
.unwrap_or(icons::Icon::Terminal);
let item = MenuItemFields::new(shell_name)
.with_on_select_action(WorkspaceAction::AddTabWithShell {
shell: shell.clone(),
source: AddTabWithShellSource::ShellSelectorMenu,
})
.with_icon(icon);
menu_items.push(item.into_item());
}
});
}
}
// On other platforms, Terminal is a regular item.
#[cfg(not(target_os = "windows"))]
{
let mut terminal_item = MenuItemFields::new("Terminal")
.with_on_select_action(WorkspaceAction::AddTerminalTab {
hide_homepage: false,
})
.with_icon(icons::Icon::LayoutAlt01);
if effective_default == DefaultSessionMode::Terminal {
terminal_item = terminal_item.with_key_shortcut_label(shortcut_label.clone());
}
menu_items.push(terminal_item.into_item());
}
}
// 3. Cloud Oz (removed — Bedrock-only, no cloud agent)
// 3b. Local Docker Sandbox
if FeatureFlag::LocalDockerSandbox.is_enabled() {
let mut docker_item = MenuItemFields::new("Local Docker Sandbox")
.with_on_select_action(WorkspaceAction::AddDockerSandboxTab)
.with_icon(icons::Icon::Docker);
if effective_default == DefaultSessionMode::DockerSandbox {
docker_item = docker_item.with_key_shortcut_label(shortcut_label.clone());
}
menu_items.push(docker_item.into_item());
}
// 4. User tab configs
if FeatureFlag::TabConfigs.is_enabled() {
let tab_configs = WarpConfig::as_ref(ctx).tab_configs().to_vec();
// Count occurrences of each config name so we can disambiguate
// duplicates in the menu (e.g. "My Tab Config", "My Tab Config (1)").
let mut name_totals: HashMap<String, usize> = HashMap::new();
for config in &tab_configs {
*name_totals.entry(config.name.clone()).or_default() += 1;
}
let mut name_seen: HashMap<String, usize> = HashMap::new();
for tab_config in tab_configs {
let is_worktree = tab_config.is_worktree();
let icon = if is_worktree {
icons::Icon::Dataflow02
} else {
icons::Icon::LayoutAlt01
};
let is_default_config = effective_default == DefaultSessionMode::TabConfig
&& tab_config
.source_path
.as_ref()
.is_some_and(|p| p.to_string_lossy() == default_tab_config_path);
let display_name = if name_totals.get(&tab_config.name).copied().unwrap_or(0) > 1 {
let seen = name_seen.entry(tab_config.name.clone()).or_default();
*seen += 1;
if *seen == 1 {
tab_config.name.clone()
} else {
format!("{} ({})", tab_config.name, *seen - 1)
}
} else {
tab_config.name.clone()
};
let mut item = MenuItemFields::new(display_name)
.with_on_select_action(WorkspaceAction::SelectTabConfig(tab_config))
.with_icon(icon);
if is_default_config {
item = item.with_key_shortcut_label(shortcut_label.clone());
}
menu_items.push(item.into_item());
}
}
// 5. Separator + worktree config entry + new tab config
if FeatureFlag::TabConfigs.is_enabled() {
menu_items.push(MenuItem::Separator);
menu_items.push(
MenuItemFields::new_submenu("New worktree config")
.with_icon(icons::Icon::Dataflow02)
.into_item(),
);
// 6. New tab config — V0: opens the TOML template.
menu_items.push(
MenuItemFields::new("New tab config")
.with_on_select_action(WorkspaceAction::SelectNewSessionMenuItem(
NewSessionMenuItem::CreateNewTabConfig,
))
.with_icon(icons::Icon::Plus)
.into_item(),
);
}
menu_items
}
fn open_tab_configs_menu(
&mut self,
position: Vector2F,
is_vertical_tabs: bool,
open_source: TabConfigsMenuOpenSource,
ctx: &mut ViewContext<Self>,
) {
let menu_items = self.unified_new_session_menu_items(ctx);
ctx.update_view(&self.new_session_dropdown_menu, |context_menu, view_ctx| {
if is_vertical_tabs {
// Match the Figma mock width (OptionMenuItem component is 268px).
context_menu.set_width(268.);
} else {
context_menu.set_width(MENU_DEFAULT_WIDTH);
}
context_menu.set_items(menu_items, view_ctx);
match open_source {
TabConfigsMenuOpenSource::KeyboardShortcut => {
context_menu.set_selected_by_index(0, view_ctx);
}
TabConfigsMenuOpenSource::Pointer => {
context_menu.reset_selection(view_ctx);
}
}
});
self.show_new_session_dropdown_menu = Some(position);
ctx.focus(&self.new_session_dropdown_menu);
ctx.notify();
}
pub fn open_new_session_dropdown_menu(
&mut self,
position: Vector2F,
ctx: &mut ViewContext<Self>,
) {
self.open_tab_configs_menu(position, false, TabConfigsMenuOpenSource::Pointer, ctx);
}
fn toggle_tab_configs_menu(&mut self, ctx: &mut ViewContext<Self>) {
let use_vertical_tabs =
FeatureFlag::VerticalTabs.is_enabled() && *TabSettings::as_ref(ctx).use_vertical_tabs;
if self.show_new_session_dropdown_menu.is_some() {
self.close_new_session_dropdown_menu(ctx);
return;
}
if use_vertical_tabs {
if !self.vertical_tabs_panel_open {
self.vertical_tabs_panel_open = true;
self.sync_window_button_visibility(ctx);
}
self.open_tab_configs_menu(
Vector2F::zero(),
true,
TabConfigsMenuOpenSource::KeyboardShortcut,
ctx,
);
return;
}
let position = ctx
.element_position_by_id_at_last_frame(self.window_id, NEW_TAB_BUTTON_POSITION_ID)
.map(|position| position.lower_left())
.unwrap_or_else(Vector2F::zero);
self.open_tab_configs_menu(
position,
false,
TabConfigsMenuOpenSource::KeyboardShortcut,
ctx,
);
}
pub fn toggle_new_session_dropdown_menu(
&mut self,
position: Vector2F,
is_vertical_tabs: bool,
ctx: &mut ViewContext<Self>,
) {
if self.show_new_session_dropdown_menu.is_some() {
self.close_new_session_dropdown_menu(ctx);
return;
}
self.open_tab_configs_menu(
position,
is_vertical_tabs,
TabConfigsMenuOpenSource::Pointer,
ctx,
);
}
fn open_launch_config_from_menu(
&mut self,
new_session_menu_item: NewSessionMenuItem,
ctx: &mut ViewContext<Self>,
) {
match new_session_menu_item {
NewSessionMenuItem::OpenLaunchConfig(launch_config) => ctx.dispatch_global_action(
"root_view:open_launch_config",
OpenLaunchConfigArg {
launch_config,
ui_location: LaunchConfigUiLocation::TabMenu,
open_in_active_window: false,
},
),
NewSessionMenuItem::OpenLaunchConfigDocs => {
ctx.open_url("https://docs.warp.dev/terminal/sessions/launch-configurations")
}
#[cfg(feature = "local_fs")]
NewSessionMenuItem::CreateNewTabConfig => {
self.create_and_open_new_tab_config(ctx);
}
#[cfg(not(feature = "local_fs"))]
NewSessionMenuItem::CreateNewTabConfig => {}
}
}
/// Opens a tab config after the user has filled in (or confirmed) param values.
fn open_tab_config_with_params(
&mut self,
tab_config: crate::tab_configs::TabConfig,
param_values: HashMap<String, String>,
worktree_branch_name: Option<&str>,
ctx: &mut ViewContext<Self>,
) {
let tab_color = tab_config.color;
let (rendered_title, pane_template) =
crate::tab_configs::render_tab_config(&tab_config, &param_values, worktree_branch_name);
self.add_tab_with_pane_layout(
PanesLayout::Template(pane_template),
Arc::new(HashMap::new()),
rendered_title,
ctx,
);
if let Some(tab) = self.tabs.get_mut(self.active_tab_index) {
// Apply tab color if specified, matching the launch config pattern.
if let Some(color) = tab_color {
tab.selected_color = SelectedTabColor::Color(color);
}
}
}
/// Opens a tab config, showing the param-fill modal when the config has parameters,
/// or opening the tab directly when there are no parameters.
fn open_tab_config(
&mut self,
tab_config: crate::tab_configs::TabConfig,
ctx: &mut ViewContext<Self>,
) {
if tab_config.params.is_empty() {
let is_worktree_config = tab_config.is_worktree();
let worktree_branch_name = self.maybe_generate_worktree_name(&tab_config);
let param_values = tab_config.default_param_values();
self.open_tab_config_with_params(
tab_config,
param_values,
worktree_branch_name.as_deref(),
ctx,
);
send_telemetry_from_ctx!(
TabConfigsTelemetryEvent::ExistingConfigOpened {
open_mode: ExistingTabConfigOpenMode::Direct,
is_worktree_config,
},
ctx
);
} else {
// Pass the active terminal's cwd to seed the branch picker's git lookup.
let cwd = self
.active_session_view(ctx)
.and_then(|view| view.as_ref(ctx).pwd())
.map(PathBuf::from);
let modal_title = format!("Open: {}", tab_config.name);
self.tab_config_params_modal.view.update(ctx, |modal, ctx| {
modal.body().update(ctx, |body, ctx| {
body.set_title(modal_title);
body.on_open(tab_config, cwd, ctx);
});
});
self.tab_config_params_modal.open();
self.current_workspace_state.is_tab_config_params_modal_open = true;
ctx.notify();
}
}
/// Writes the default tab config template to an unused path in `~/.warp-core/tab_configs/`
/// and opens it respecting the user's configured editor setting.
#[cfg(feature = "local_fs")]
fn create_and_open_new_tab_config(&mut self, ctx: &mut ViewContext<Self>) {
let dir = tab_configs_dir();
if let Err(e) = std::fs::create_dir_all(&dir) {
log::warn!("Failed to create tab_configs dir: {e:?}");
return;
}
let path = find_unused_tab_config_path(&dir);
const TEMPLATE: &str =
include_str!("../../resources/tab_configs/new_tab_config_template.toml");
if let Err(e) = std::fs::write(&path, TEMPLATE) {
log::warn!("Failed to write new tab config template: {e:?}");
return;
}
let settings = EditorSettings::as_ref(ctx);
let target = resolve_file_target_with_editor_choice(
&path,
*settings.open_code_panels_file_editor,
*settings.prefer_markdown_viewer,
*settings.open_file_layout,
None,
);
send_telemetry_from_ctx!(TabConfigsTelemetryEvent::MenuCreateNewTabConfigClicked, ctx);
self.open_file_with_target(
path.clone(),
target,
None,
CodeSource::Link {
path,
range_start: None,
range_end: None,
},
ctx,
);
}
/// Snapshots the given tab's pane layout and writes it as a new tab config
/// TOML to `~/.warp-core/tab_configs/`, then opens the file in the user's editor.
#[cfg(feature = "local_fs")]
fn save_current_tab_as_new_config(&mut self, tab_index: usize, ctx: &mut ViewContext<Self>) {
use crate::tab_configs::session_config::{tab_config_from_pane_snapshot, write_tab_config};
let tab = &self.tabs[tab_index];
let snapshot = tab.pane_group.as_ref(ctx).snapshot(ctx);
let custom_title = tab.pane_group.as_ref(ctx).custom_title(ctx);
let color = tab.color();
let config = tab_config_from_pane_snapshot(&snapshot, custom_title, color);
let dir = tab_configs_dir();
match write_tab_config(&config, &dir, "my_tab_config") {
Ok(path) => {
let settings = EditorSettings::as_ref(ctx);
let target = resolve_file_target_with_editor_choice(
&path,
*settings.open_code_panels_file_editor,
*settings.prefer_markdown_viewer,
*settings.open_file_layout,
None,
);
self.open_file_with_target(
path.clone(),
target,
None,
CodeSource::Link {
path,
range_start: None,
range_end: None,
},
ctx,
);
}
Err(e) => log::warn!("Failed to save tab config: {e:?}"),
}
}
#[cfg(not(feature = "local_fs"))]
fn save_current_tab_as_new_config(&mut self, _tab_index: usize, _ctx: &mut ViewContext<Self>) {}
pub fn toggle_tab_right_click_menu(
&mut self,
tab_index: usize,
anchor: TabContextMenuAnchor,
ctx: &mut ViewContext<Self>,
) {
if self.show_tab_right_click_menu.is_some() {
self.show_tab_right_click_menu = None;
ctx.notify();
return;
}
let tab = &self.tabs[tab_index];
let menu_items = tab.menu_items(tab_index, self.tabs.len(), ctx);
ctx.update_view(&self.tab_right_click_menu, |context_menu, view_ctx| {
context_menu.set_items(menu_items, view_ctx);
});
self.show_tab_right_click_menu = Some((tab_index, anchor));
ctx.focus(&self.tab_right_click_menu);
ctx.notify();
}
pub fn toggle_vertical_tabs_pane_context_menu(
&mut self,
tab_index: usize,
target: VerticalTabsPaneContextMenuTarget,
position: Vector2F,
ctx: &mut ViewContext<Self>,
) {
if self.show_tab_right_click_menu.is_some() {
self.show_tab_right_click_menu = None;
ctx.notify();
return;
}
let Some(tab) = self.tabs.get(tab_index) else {
log::warn!("Tried to open pane context menu for a missing tab");
return;
};
let pane = target.locator();
if tab.pane_group.id() != pane.pane_group_id {
log::warn!("Tried to open pane context menu for a pane in another tab");
return;
}
let pane_name_target = match target {
VerticalTabsPaneContextMenuTarget::ClickedPane(locator) => PaneNameMenuTarget {
locator,
rename_label: "Rename pane",
reset_label: "Reset pane name",
},
VerticalTabsPaneContextMenuTarget::ActivePane(locator) => PaneNameMenuTarget {
locator,
rename_label: "Rename active pane",
reset_label: "Reset active pane name",
},
};
let menu_items = tab.menu_items_with_pane_name_target(
tab_index,
self.tabs.len(),
Some(pane_name_target),
ctx,
);
ctx.update_view(&self.tab_right_click_menu, |context_menu, view_ctx| {
context_menu.set_items(menu_items, view_ctx);
});
self.show_tab_right_click_menu = Some((tab_index, TabContextMenuAnchor::Pointer(position)));
ctx.focus(&self.tab_right_click_menu);
ctx.notify();
}
/// The tab bar overflow menu is the context menu that appears when
/// a user clicks "Update Warp" in the top right of the tab bar.
pub fn toggle_tab_bar_overflow_menu(&mut self, ctx: &mut ViewContext<Self>) {
if self.show_tab_bar_overflow_menu {
self.close_tab_bar_overflow_menu(ctx);
return;
}
let mut menu_items = vec![];
if FeatureFlag::Autoupdate.is_enabled() && ChannelState::show_autoupdate_menu_items() {
if let Some(version) = ChannelState::app_version() {
menu_items.push(
MenuItemFields::new(format!("Current version is {version}"))
.with_disabled(true)
.into_item(),
);
match autoupdate::get_update_state(ctx) {
AutoupdateStage::UpdateReady { new_version, .. }
| AutoupdateStage::UpdatedPendingRestart { new_version } => menu_items.push(
MenuItemFields::new(format!("Install update ({})", new_version.version))
.with_on_select_action(WorkspaceAction::ApplyUpdate)
.into_item(),
),
AutoupdateStage::Updating { new_version, .. } => menu_items.push(
MenuItemFields::new(format!("Updating to ({})", new_version.version))
.with_disabled(true)
.into_item(),
),
AutoupdateStage::UnableToUpdateToNewVersion { .. } => menu_items.push(
MenuItemFields::new("Update Galaxy manually")
.with_on_select_action(WorkspaceAction::DownloadNewVersion)
.into_item(),
),
AutoupdateStage::NoUpdateAvailable
| AutoupdateStage::CheckingForUpdate
| AutoupdateStage::DownloadingUpdate
| AutoupdateStage::UnableToLaunchNewVersion { .. } => {}
}
}
}
ctx.update_view(&self.tab_bar_overflow_menu, |context_menu, view_ctx| {
context_menu.set_items(menu_items, view_ctx);
});
self.show_tab_bar_overflow_menu = true;
ctx.focus(&self.tab_bar_overflow_menu);
ctx.notify();
}
fn read_from_active_terminal_view<T>(
&self,
ctx: &AppContext,
accessor: impl FnOnce(&TerminalView) -> T,
) -> Option<T> {
self.get_pane_group_view(self.active_tab_index)
.and_then(|view| {
view.read(ctx, |pane_group, ctx| {
pane_group
.active_session_view(ctx)
.map(|terminal_view_handle| {
terminal_view_handle.read(ctx, |terminal, _| accessor(terminal))
})
})
})
}
pub fn active_terminal_id(&self, app: &AppContext) -> Option<EntityId> {
self.read_from_active_terminal_view(app, |terminal| terminal.id())
}
/// Retrieves the entity id of the active current active input. This is needed
/// by the Welcome Tip View in order to know where to dispatch the actions
/// directly from the tip menu.
fn active_input_id(&self, app: &AppContext) -> Option<EntityId> {
self.read_from_active_terminal_view(app, |terminal| terminal.input().id())
}
/// Gets the ID of the active terminal session, if any.
pub fn active_session_id(&self, ctx: &ViewContext<Self>) -> Option<SessionId> {
self.get_pane_group_view(self.active_tab_index)
.and_then(|view| {
view.read(ctx, |pane_group, ctx| {
pane_group
.active_session_view(ctx)
.and_then(|terminal_view_handle| {
terminal_view_handle
.read(ctx, |terminal, _| terminal.active_block_session_id())
})
})
})
}
/// Triggers the drive sharing onboarding block.
fn check_and_trigger_drive_sharing_onboarding_block(
&mut self,
object_id: CloudObjectTypeAndId,
ctx: &mut ViewContext<Self>,
) {
if self.auth_state.is_anonymous_or_logged_out() {
return;
}
if *WarpDriveSettings::as_ref(ctx)
.sharing_onboarding_block_shown
.value()
{
return;
}
if let Some(terminal_view_handle) = self.active_session_view(ctx) {
let terminal_view_id = terminal_view_handle.id();
// Don't show onboarding block while agent is actively streaming
let is_agent_in_progress = BlocklistAIHistoryModel::handle(ctx)
.as_ref(ctx)
.active_conversation(terminal_view_id)
.is_some_and(|conversation| conversation.status().is_in_progress());
if is_agent_in_progress {
return;
}
terminal_view_handle.update(ctx, |terminal_view, ctx| {
terminal_view.insert_drive_sharing_onboarding_block(object_id, ctx);
});
}
}
fn check_and_trigger_telemetry_banner_for_existing_users(
&mut self,
ctx: &mut ViewContext<Self>,
) {
if FeatureFlag::GlobalAIAnalyticsBanner.is_enabled()
&& PrivacySettings::as_ref(ctx).is_telemetry_enabled
{
if let Some(terminal_view_handle) = self.active_session_view(ctx) {
terminal_view_handle.update(ctx, |terminal_view, ctx| {
terminal_view.insert_telemetry_banner(true, ctx);
});
}
}
}
fn should_trigger_get_started_onboarding(&self, ctx: &mut ViewContext<Self>) -> bool {
if !FeatureFlag::GetStartedTab.is_enabled() {
return false;
}
if self.auth_state.is_onboarded().unwrap_or_default() {
return false;
}
if self.auth_state.is_anonymous_or_logged_out() {
return false;
}
// If AgentOnboarding is enabled and the user is NOT in the control group for the
// AgentOnboarding experiment, don't show Get Started onboarding.
if self.should_show_agent_onboarding(ctx) {
return false;
}
true
}
fn trigger_get_started_onboarding(&mut self, ctx: &mut ViewContext<Self>) {
self.add_get_started_tab(ctx);
// After onboarding is triggered, mark the user as onboarded
AuthManager::handle(ctx).update(ctx, |auth_manager, ctx| {
auth_manager.set_user_onboarded(ctx);
});
}
/// If the user is new and therefore has not seen the in app onboarding,
/// triggers the welcome block to be shown after bootstrapping is completed.
fn check_and_trigger_onboarding(&mut self, ctx: &mut ViewContext<Self>) -> bool {
if !self.auth_state.is_onboarded().unwrap_or_default() {
if self.should_show_agent_onboarding(ctx) {
// If the user is anonymous, we shouldn't trigger agent onboarding.
// It will not display anyway, and we don't want to mark the user as onboarded.
if self.auth_state.is_anonymous_or_logged_out() {
return false;
}
self.trigger_agent_onboarding(ctx);
} else {
self.trigger_legacy_onboarding(ctx);
}
// Add telemetry banner for new users BEFORE the agentic onboarding blocks.
if let Some(terminal_view_handle) = self.active_session_view(ctx) {
terminal_view_handle.update(ctx, |terminal_view, ctx| {
terminal_view.insert_telemetry_banner(false, ctx);
});
}
// After onboarding is triggered, mark the user as onboarded
AuthManager::handle(ctx).update(ctx, |auth_manager, ctx| {
auth_manager.set_user_onboarded(ctx);
});
return true;
}
false
}
fn trigger_legacy_onboarding(&self, ctx: &mut ViewContext<Self>) {
self.dispatch_onboarding(
TerminalAction::OnboardingFlow(OnboardingVersion::Legacy),
ctx,
);
}
fn trigger_agent_onboarding(&self, ctx: &mut ViewContext<Self>) {
log::error!(
"Triggering agent onboarding callout flow but not during initial login. This should not normally happen."
);
let version = if FeatureFlag::AgentView.is_enabled() {
AgentOnboardingVersion::AgentModality {
has_project: false,
intention: OnboardingIntention::AgentDrivenDevelopment,
}
} else {
AgentOnboardingVersion::UniversalInput { has_project: false }
};
self.dispatch_onboarding(
TerminalAction::OnboardingFlow(OnboardingVersion::Agent(version)),
ctx,
);
}
fn dispatch_onboarding(&self, action: TerminalAction, ctx: &mut ViewContext<Self>) {
if let Some(pane_group_handle) = self.get_pane_group_view(self.active_tab_index) {
pane_group_handle.update(ctx, |pane_group, ctx| {
if let Some(terminal_view_handle) = pane_group.active_session_view(ctx) {
let window_id = ctx.window_id();
ctx.dispatch_typed_action_for_view(
window_id,
terminal_view_handle.id(),
&action,
);
}
});
}
}
fn open_suggested_agent_mode_workflow_modal(
&mut self,
workflow_and_id: &SuggestedAgentModeWorkflowAndId,
ctx: &mut ViewContext<Self>,
) {
self.current_workspace_state
.is_suggested_agent_mode_workflow_modal_open = true;
self.suggested_agent_mode_workflow_modal.update(
ctx,
|suggested_agent_mode_workflow_modal, ctx| {
suggested_agent_mode_workflow_modal.open_workflow(workflow_and_id, ctx);
},
);
ctx.notify();
}
fn open_suggested_rule_modal(
&mut self,
rule_and_id: &SuggestedRuleAndId,
ctx: &mut ViewContext<Self>,
) {
self.current_workspace_state.is_suggested_rule_modal_open = true;
self.suggested_rule_modal
.update(ctx, |suggested_rule_modal, ctx| {
suggested_rule_modal.set_rule_and_id(rule_and_id, ctx);
ctx.notify();
});
ctx.notify();
}
/// Opens the Warp Drive object identified by `uid` in a new pane
/// if it has a pane representation.
fn open_warp_drive_object_in_new_pane(&mut self, uid: &ObjectUid, ctx: &mut ViewContext<Self>) {
let Some(object) = CloudModel::as_ref(ctx).get_by_uid(uid) else {
return;
};
let sync_id = object.sync_id();
match object.object_type() {
ObjectType::Notebook => {
self.open_notebook(
&NotebookSource::Existing(sync_id),
&OpenWarpDriveObjectSettings::default(),
ctx,
true,
);
}
ObjectType::Workflow => {
self.open_workflow_in_pane(
&WorkflowOpenSource::Existing(sync_id),
&OpenWarpDriveObjectSettings::default(),
WorkflowViewMode::View,
ctx,
);
}
ObjectType::GenericStringObject(GenericStringObjectFormat::Json(
JsonObjectType::EnvVarCollection,
)) => {
self.open_env_var_collection(
&EnvVarCollectionSource::Existing(sync_id),
false,
ctx,
);
}
ObjectType::GenericStringObject(GenericStringObjectFormat::Json(
JsonObjectType::AIFact,
)) => {
self.open_ai_fact_collection_pane(None, None, ctx);
}
_ => {}
}
}
/// Open the notebook identified by `source`. If the notebook is already open in another pane,
/// that pane is focused. If the notebook is not open, the notebook will be opened in a new
/// pane if default_to_new_pane is true; otherwise, it'll be opened in a new tab.
pub fn open_notebook(
&mut self,
source: &NotebookSource,
settings: &OpenWarpDriveObjectSettings,
ctx: &mut ViewContext<Self>,
default_to_new_pane: bool,
) {
let notebook_manager = NotebookManager::handle(ctx);
let mut notebook_already_open = false;
if let Some((window_id, locator)) = notebook_manager.as_ref(ctx).find_pane(source) {
// If the notebook is already open in _this_ workspace, we can switch to it directly.
// Otherwise, dispatch an action to the appropriate window. We can't unconditionally
// dispatch an action, because that will cause a circular view update.
notebook_already_open = true;
if window_id == ctx.window_id() {
self.focus_pane(locator, ctx);
} else if let Some(root_view) = ctx.root_view_id(window_id) {
ctx.dispatch_action_for_view(
window_id,
root_view,
"root_view:handle_pane_navigation_event",
&locator,
);
}
// If the was an invitee email, open the share dialog as well after focusing the pane.
if let Some(invitee_email) = settings.invitee_email.clone() {
if let NotebookSource::Existing(sync_id) = source {
self.open_object_sharing_settings(
CloudObjectTypeAndId::from_id_and_type(*sync_id, ObjectType::Notebook),
Some(invitee_email),
SharingDialogSource::InviteeRequest,
ctx,
);
}
}
} else if default_to_new_pane {
let window_id = ctx.window_id();
let pane = notebook_manager.update(ctx, |manager, ctx| {
manager.create_pane(source, settings, window_id, ctx)
});
self.active_tab_pane_group().update(ctx, |pane_group, ctx| {
let smart_split_direction =
pane_group.smart_split_direction(ctx, NOTEBOOK_SMART_SPLIT_RATIO);
pane_group.add_pane_with_direction(
smart_split_direction,
pane,
true, /* focus_new_pane */
ctx,
);
});
}
// Get notebook ID to set Warp drive index selected state
if let NotebookSource::Existing(notebook_id) = source {
let focused_folder_id = settings.focused_folder_id.map(SyncId::ServerId);
if !notebook_already_open && !default_to_new_pane {
self.add_tab_for_cloud_notebook(*notebook_id, settings, ctx);
}
if let Some(focused_folder_id) = focused_folder_id {
// Only focus the notebook if we don't want to focus a parent folder instead
self.open_or_toggle_warp_drive(false, false, ctx);
self.set_selected_object(
Some(WarpDriveItemId::Object(
CloudObjectTypeAndId::from_id_and_type(
focused_folder_id,
ObjectType::Folder,
),
)),
ctx,
);
} else {
self.set_selected_object(
Some(WarpDriveItemId::Object(
CloudObjectTypeAndId::from_id_and_type(*notebook_id, ObjectType::Notebook),
)),
ctx,
);
}
}
}
/// Open a Warp Drive workflow in response to an intent URL.
pub fn open_workflow_from_intent(
&mut self,
workflow_id: SyncId,
settings: &OpenWarpDriveObjectSettings,
ctx: &mut ViewContext<Self>,
) {
// If running workflows is supported, do so. Otherwise, or if the workflow isn't in memory,
// fall back to the workflow pane.
// We don't want to run the workflow if the invitee email is set, as we want to open the share dialog instead with the
// workflow open in a pane.
if ContextFlag::RunWorkflow.is_enabled() && settings.invitee_email.is_none() {
match CloudModel::as_ref(ctx).get_workflow(&workflow_id).cloned() {
Some(workflow) => {
self.open_or_toggle_warp_drive(false, false, ctx);
self.run_cloud_workflow_in_active_input(
workflow,
WorkflowSelectionSource::Undefined,
TerminalSessionFallbackBehavior::OpenIfNeeded,
ctx,
);
// If there's a parent folder to focus, do so after running the workflow, as
// that will focus the workflow instead.
if let Some(focused_folder) = settings.focused_folder_id.map(SyncId::ServerId) {
self.set_selected_object(
Some(WarpDriveItemId::Object(
CloudObjectTypeAndId::from_id_and_type(
focused_folder,
ObjectType::Folder,
),
)),
ctx,
);
}
}
None => self.add_tab_for_cloud_workflow(workflow_id, settings, ctx),
}
} else {
self.add_tab_for_cloud_workflow(workflow_id, settings, ctx);
}
}
pub fn open_workflow_in_pane(
&mut self,
source: &WorkflowOpenSource,
settings: &OpenWarpDriveObjectSettings,
mode: WorkflowViewMode,
ctx: &mut ViewContext<Self>,
) {
let workflow_manager = WorkflowManager::handle(ctx);
if let Some((window_id, locator)) = workflow_manager.as_ref(ctx).find_pane(source) {
if window_id == ctx.window_id() {
self.focus_pane(locator, ctx);
} else if let Some(root_view) = ctx.root_view_id(window_id) {
ctx.dispatch_action_for_view(
window_id,
root_view,
"root_view:handle_pane_navigation_event",
&locator,
);
}
} else {
let window_id = ctx.window_id();
let pane = workflow_manager.update(ctx, |manager, ctx| {
manager.create_pane(source, settings, mode, window_id, ctx)
});
self.active_tab_pane_group().update(ctx, |pane_group, ctx| {
let smart_split_direction =
pane_group.smart_split_direction(ctx, WORKFLOW_AND_ENV_VAR_SPLIT_RATIO);
pane_group.add_pane_with_direction(
smart_split_direction,
pane,
true, /* focus_new_pane */
ctx,
);
});
}
}
pub fn open_env_var_collection(
&mut self,
source: &EnvVarCollectionSource,
reload: bool,
ctx: &mut ViewContext<Self>,
) {
let env_var_collection_manager = EnvVarCollectionManager::handle(ctx);
if let Some((window_id, locator)) = env_var_collection_manager.as_ref(ctx).find_pane(source)
{
if reload {
env_var_collection_manager
.update(ctx, |manager, ctx| manager.reload_collection(source, ctx));
}
if window_id == ctx.window_id() {
self.focus_pane(locator, ctx);
} else if let Some(root_view) = ctx.root_view_id(window_id) {
ctx.dispatch_action_for_view(
window_id,
root_view,
"root_view:handle_pane_navigation_event",
&locator,
);
}
} else {
let window_id = ctx.window_id();
let pane = env_var_collection_manager.update(ctx, |manager, ctx| {
manager.create_pane(source, window_id, ctx)
});
self.active_tab_pane_group().update(ctx, |pane_group, ctx| {
let smart_split_direction =
pane_group.smart_split_direction(ctx, WORKFLOW_AND_ENV_VAR_SPLIT_RATIO);
pane_group.add_pane_with_direction(
smart_split_direction,
pane,
true, /* focus_new_pane */
ctx,
);
});
}
if let EnvVarCollectionSource::Existing(env_var_collection_id) = source {
self.set_selected_object(
Some(WarpDriveItemId::Object(
CloudObjectTypeAndId::from_generic_string_object(
GenericStringObjectFormat::Json(
crate::cloud_object::JsonObjectType::EnvVarCollection,
),
*env_var_collection_id,
),
)),
ctx,
);
}
}
/// Create a pane from a cloud object. Returns `None` if the object cannot be opened in a
/// pane. The pane will not be associated with any tab - the caller is responsible for
/// inserting it.
fn create_cloud_object_pane(
&self,
cloud_object: CloudObjectTypeAndId,
ctx: &mut ViewContext<Self>,
) -> Option<Box<dyn AnyPaneContent>> {
let window_id = ctx.window_id();
let object_settings = Default::default();
match cloud_object {
CloudObjectTypeAndId::Notebook(sync_id) => Some(Box::new(
NotebookManager::handle(ctx).update(ctx, |notebook_manager, ctx| {
notebook_manager.create_pane(
&NotebookSource::Existing(sync_id),
&object_settings,
window_id,
ctx,
)
}),
)),
CloudObjectTypeAndId::Workflow(sync_id) => Some(Box::new(
WorkflowManager::handle(ctx).update(ctx, |workflow_manager, ctx| {
workflow_manager.create_pane(
&WorkflowOpenSource::Existing(sync_id),
&object_settings,
WorkflowViewMode::Edit,
window_id,
ctx,
)
}),
)),
CloudObjectTypeAndId::Folder(_) => None,
CloudObjectTypeAndId::GenericStringObject {
object_type: GenericStringObjectFormat::Json(JsonObjectType::EnvVarCollection),
id,
} => Some(Box::new(EnvVarCollectionManager::handle(ctx).update(
ctx,
|evc_manager, ctx| {
evc_manager.create_pane(&EnvVarCollectionSource::Existing(id), window_id, ctx)
},
))),
CloudObjectTypeAndId::GenericStringObject { .. } => None,
}
}
fn open_settings_pane(
&mut self,
page: Option<SettingsSection>,
search_query: Option<&str>,
ctx: &mut ViewContext<Self>,
) {
// Ensure there is only one settings pane per window
let settings_pane_manager = SettingsPaneManager::handle(ctx);
if let Some(locator) = settings_pane_manager.as_ref(ctx).find_pane(ctx.window_id()) {
// Update to new page if specified
if let Some(page) = page {
self.settings_pane.update(ctx, |settings_pane, ctx| {
settings_pane.set_and_refresh_current_page(page, ctx);
if let Some(search_query) = search_query {
settings_pane.set_search_query(search_query, ctx);
}
});
}
// Navigate to and focus existing pane
self.focus_pane(locator, ctx);
return;
}
let ps1_grid_info = self.active_session_ps1_grid_info(ctx);
// Open new tab and update current page
self.settings_pane.update(ctx, move |settings_pane, ctx| {
// TODO: This check shouldn't be necessary, but `active_session_ps1_grid_info` returns
// None when the active tab has no running terminal sessions, e.g. if it contains only
// notebooks/workflow panes.
if ps1_grid_info.is_some() {
settings_pane.set_ps1_info(ps1_grid_info, ctx);
}
});
let panes_layout = PanesLayout::Snapshot(Box::new(PaneNodeSnapshot::Leaf(LeafSnapshot {
is_focused: true,
custom_vertical_tabs_title: None,
contents: LeafContents::Settings(SettingsPaneSnapshot::Local {
current_page: page.unwrap_or_default(),
search_query: search_query.map(|s| s.to_owned()),
}),
})));
self.add_tab_with_pane_layout(
panes_layout,
Arc::new(HashMap::new()),
Some("Settings".to_owned()),
ctx,
);
}
/// Open a file from the given session as a notebook pane.
#[cfg(feature = "local_fs")]
fn open_file_notebook(
&mut self,
path: PathBuf,
session: Option<Arc<Session>>,
layout: EditorLayout,
ctx: &mut ViewContext<Self>,
) {
// TODO(ben): It might be worth managing file-based notebooks via NotebookManager
// (e.g. for open-or-switch behavior). See if it overcomplicates things.
let pane = FilePane::new(
Some(path),
session,
#[cfg(feature = "local_fs")]
None,
ctx,
);
match layout {
EditorLayout::NewTab => {
let new_tab_placement_setting = TabSettings::as_ref(ctx).new_tab_placement;
let new_idx = match new_tab_placement_setting {
NewTabPlacement::AfterAllTabs => self.tab_count(),
// Add tab after current tab
NewTabPlacement::AfterCurrentTab => self.active_tab_index + 1,
};
self.add_tab_from_existing_pane(Box::new(pane), new_idx, ctx);
}
EditorLayout::SplitPane => {
self.active_tab_pane_group().update(ctx, |pane_group, ctx| {
pane_group.add_pane_with_direction(
Direction::Right,
pane,
true, /* focus_new_pane */
ctx,
);
});
}
}
}
fn attach_path_as_context(&mut self, path: PathBuf, ctx: &mut ViewContext<Self>) {
let Some(view) = self.active_session_view(ctx) else {
log::warn!("No active terminal view session when trying to attach path as context");
return;
};
view.update(ctx, |terminal_view, ctx| {
terminal_view.attach_path_as_context(&path, ctx);
});
}
fn cd_to_directory(&mut self, path: PathBuf, ctx: &mut ViewContext<Self>) {
let Some(input_handle) = self.get_active_input_view_handle(ctx) else {
log::warn!("No active input view when trying to cd to directory");
return;
};
let Some(path_str) = path.to_str() else {
log::warn!("Could not convert path to string for cd command");
return;
};
let cd_command = format!("cd {}", shell_words::quote(path_str));
input_handle.update(ctx, |input_view, ctx| {
input_view.replace_buffer_content(&cd_command, ctx);
});
}
fn open_directory_in_new_tab(&mut self, path: PathBuf, ctx: &mut ViewContext<Self>) {
let options = NewTerminalOptions::default().with_initial_directory(path);
self.add_tab_with_pane_layout(
PanesLayout::SingleTerminal(Box::new(options)),
Arc::new(HashMap::new()),
None,
ctx,
);
}
#[cfg(feature = "local_fs")]
fn open_code(
&mut self,
source: CodeSource,
layout: EditorLayout,
line_col: Option<LineAndColumnArg>,
preview: bool,
additional_paths: &[PathBuf],
ctx: &mut ViewContext<Self>,
) {
send_telemetry_from_ctx!(
TelemetryEvent::CodePaneOpened {
source: source.clone(),
layout,
preview
},
ctx
);
let grouping_on = FeatureFlag::TabbedEditorView.is_enabled()
&& *EditorSettings::as_ref(ctx)
.prefer_tabbed_editor_view
.value();
if grouping_on {
let code_view = self
.active_tab_pane_group()
.as_ref(ctx)
.code_panes(ctx)
.find(|(pane_id, _)| {
!self
.active_tab_pane_group()
.as_ref(ctx)
.is_pane_hidden_for_close(*pane_id)
});
// If the tabbed editor view is enabled and there is an existing CodeView, we should group the newly opened file into this view.
if let (Some(path), Some((pane_id, code_view))) = (source.path(), code_view) {
code_view.update(ctx, |code_view, ctx| {
if preview {
code_view.open_in_preview_or_promote_and_jump(path, line_col, ctx);
} else {
code_view.open_or_focus_existing(Some(path), line_col, ctx);
}
for extra in additional_paths {
code_view.open_or_focus_existing(Some(extra.clone()), None, ctx);
}
});
// Only focus the pane for non-preview opens
if !preview {
self.active_tab_pane_group().update(ctx, |pane_group, ctx| {
pane_group.focus_pane(pane_id, true, ctx);
});
}
return;
}
} else {
// When grouping is off, avoid opening duplicate code panes for the same file in the
// current pane group. Instead, focus the existing pane and jump.
if let Some(path) = source.path() {
let pane_group_id = self.active_tab_pane_group().id();
let existing_locator = CodeManager::handle(ctx).read(ctx, |manager, _| {
manager.get_locator_for_path_in_tab(pane_group_id, path.as_path())
});
if let Some(locator) = existing_locator {
self.active_tab_pane_group().update(ctx, |pane_group, ctx| {
pane_group.focus_pane_by_id(locator.pane_id, ctx);
if let Some(code_view) =
pane_group.code_view_from_pane_id(locator.pane_id, ctx)
{
code_view.update(ctx, |code_view, ctx| {
if preview {
code_view.open_in_preview_or_promote_and_jump(
path.clone(),
line_col,
ctx,
);
} else {
code_view.open_or_focus_existing(
Some(path.clone()),
line_col,
ctx,
);
}
for extra in additional_paths {
code_view.open_or_focus_existing(
Some(extra.clone()),
None,
ctx,
);
}
});
}
});
return;
}
}
}
let pane = if preview {
CodePane::new_preview(source, ctx)
} else {
CodePane::new(source, line_col, ctx)
};
match layout {
EditorLayout::NewTab => {
let new_tab_placement_setting = TabSettings::as_ref(ctx).new_tab_placement;
let new_idx = match new_tab_placement_setting {
NewTabPlacement::AfterAllTabs => self.tab_count(),
// Add tab after current tab
NewTabPlacement::AfterCurrentTab => self.active_tab_index + 1,
};
self.add_tab_from_existing_pane(Box::new(pane), new_idx, ctx);
}
EditorLayout::SplitPane => {
self.active_tab_pane_group().update(ctx, |pane_group, ctx| {
pane_group.add_pane_with_direction(
Direction::Right,
pane,
!preview, /* focus_new_pane */
ctx,
);
});
}
}
// Open any additional paths as tabs in the code view we just created.
if !additional_paths.is_empty() {
let code_view_handle = self
.active_tab_pane_group()
.as_ref(ctx)
.code_panes(ctx)
.find(|(pane_id, _)| {
!self
.active_tab_pane_group()
.as_ref(ctx)
.is_pane_hidden_for_close(*pane_id)
})
.map(|(_, view)| view);
if let Some(code_view) = code_view_handle {
code_view.update(ctx, |code_view, ctx| {
for path in additional_paths {
code_view.open_or_focus_existing(Some(path.clone()), None, ctx);
}
});
}
}
}
/// Open a code diff view by temporarily replacing the current pane or in a new tab.
fn open_code_diff(&mut self, view: ViewHandle<CodeDiffView>, ctx: &mut ViewContext<Self>) {
let focused_pane_id = self
.active_tab_pane_group()
.as_ref(ctx)
.focused_pane_id(ctx);
view.update(ctx, |view, _| {
view.set_original_pane_id(Some(focused_pane_id));
});
// Check if the ExpandEditToPane feature flag is enabled
if FeatureFlag::ExpandEditToPane.is_enabled() {
// Try to temporarily replace the current pane with the diff view
let new_pane = CodeDiffPane::from_view(view.clone(), ctx);
self.active_tab_pane_group().update(ctx, |pane_group, ctx| {
if !pane_group.replace_pane(focused_pane_id, new_pane, true, ctx) {
// If replacement failed, remove the pane we just added and fall back
//pane_group.close_pane(new_pane_id, ctx);
log::warn!("Failed to temporarily replace pane, falling back to new tab");
}
});
} else {
// Feature flag disabled: use the original behavior of opening in a new tab
let new_pane = CodeDiffPane::from_view(view, ctx);
let new_tab_placement_setting = TabSettings::as_ref(ctx).new_tab_placement;
let new_idx = match new_tab_placement_setting {
NewTabPlacement::AfterAllTabs => self.tab_count(),
NewTabPlacement::AfterCurrentTab => self.active_tab_index + 1,
};
self.add_tab_from_existing_pane(Box::new(new_pane), new_idx, ctx);
}
}
/// Open the AI Fact Collection pane in a split pane (default direction is left).
pub fn open_ai_fact_collection_pane(
&mut self,
direction: Option<Direction>,
page: Option<AIFactPage>,
ctx: &mut ViewContext<Self>,
) {
// Ensure there is only one AI Fact Collection pane per window
let manager = AIFactManager::handle(ctx);
// Navigate to and focus existing pane
if let Some(locator) = manager.as_ref(ctx).find_pane(ctx.window_id()) {
if let Some(page) = page {
self.ai_fact_view.update(ctx, |view, ctx| {
view.update_page(page, ctx);
});
}
self.focus_pane(locator, ctx);
return;
}
let pane = AIFactPane::from_view(self.ai_fact_view.clone(), ctx);
self.ai_fact_view.update(ctx, |view, ctx| {
view.update_page(page.unwrap_or_default(), ctx);
});
let direction = direction.unwrap_or(Direction::Left);
self.active_tab_pane_group().update(ctx, |pane_group, ctx| {
pane_group
.add_pane_with_direction(direction, pane, true /* focus_new_pane */, ctx);
});
// Focus WD index item
self.set_selected_object(Some(WarpDriveItemId::AIFactCollection), ctx);
}
/// Open the Execution Profile Editor pane
pub fn open_execution_profile_editor_pane(
&mut self,
direction: Option<Direction>,
profile_id: ClientProfileId,
ctx: &mut ViewContext<Self>,
) {
let manager = ExecutionProfileEditorManager::handle(ctx);
if let Some(locator) = manager.as_ref(ctx).find_pane(ctx.window_id(), profile_id) {
self.focus_pane(locator, ctx);
return;
}
let pane = ExecutionProfileEditorPane::new(profile_id, ctx);
let direction = direction.unwrap_or(Direction::Right);
self.active_tab_pane_group().update(ctx, |pane_group, ctx| {
pane_group
.add_pane_with_direction(direction, pane, true /* focus_new_pane */, ctx);
});
}
/// Open the Environment Management pane in a split pane (default direction is right).
pub fn open_environment_management_pane(
&mut self,
direction: Option<Direction>,
mode: EnvironmentsPage,
ctx: &mut ViewContext<Self>,
) {
let direction = direction.unwrap_or(Direction::Right);
let environments_page_view = self.active_tab_pane_group().update(ctx, |pane_group, ctx| {
let pane = EnvironmentManagementPane::new(ctx);
let view = pane.environments_page_view(ctx);
pane_group
.add_pane_with_direction(direction, pane, true /* focus_new_pane */, ctx);
view
});
// Update page after the pane is added so focus works correctly
environments_page_view.update(ctx, |view, ctx| {
view.update_page(mode, ctx);
});
}
pub(super) fn active_session_view(
&self,
ctx: &mut ViewContext<Self>,
) -> Option<ViewHandle<TerminalView>> {
self.active_tab_pane_group()
.read(ctx, |pane_group, ctx| pane_group.active_session_view(ctx))
}
pub fn toggle_welcome_tips_visiblity(&mut self, ctx: &mut ViewContext<Self>) {
self.welcome_tips_view_state.toggle_popup();
if self.welcome_tips_view_state.is_popup_open() {
let input_id = self.active_input_id(ctx);
self.welcome_tips_view.update(ctx, |tips_view, ctx| {
tips_view.set_action_target(ctx.window_id(), input_id, ctx)
});
send_telemetry_from_ctx!(TelemetryEvent::OpenWelcomeTips, ctx);
}
ctx.focus(&self.welcome_tips_view);
ctx.notify();
}
pub fn close_tab_bar_overflow_menu(&mut self, ctx: &mut ViewContext<Self>) {
self.show_tab_bar_overflow_menu = false;
ctx.notify();
}
/// Find an active session and pre-fill the input editor the Warp executable with the
/// [`galaxy_cli::Command::DumpDebugInfo`] subcommand.
fn dump_debug_info(&mut self, ctx: &mut ViewContext<Self>) {
if let Some(exec) = std::env::current_exe()
.ok()
.map(|path| path.to_string_lossy().into_owned())
{
let command = format!("{exec} {}", galaxy_cli::dump_debug_info_flag());
// Get the active session for this tab if it exists.
let mut active_session_handle = self
.active_tab_pane_group()
.read(ctx, |pane_group_view, ctx| {
pane_group_view.active_session_view(ctx)
});
// A tab may not have any active session, say if it only contains notebook(s). If
// that's the case, create a new tab.
if active_session_handle.is_none() {
self.add_new_session_tab_with_default_mode(
NewSessionSource::Tab,
None,
None,
None,
false,
ctx,
);
}
active_session_handle = self
.active_tab_pane_group()
.read(ctx, |pane_group_view, ctx| {
pane_group_view.active_session_view(ctx)
});
if let Some(terminal_view_handle) = active_session_handle {
terminal_view_handle.update(ctx, |terminal_view, ctx| {
terminal_view.set_pending_command(&command, ctx);
});
}
}
}
/// Install the Warp CLI by creating a symlink in /usr/local/bin
#[cfg(target_os = "macos")]
fn install_cli(&mut self, ctx: &mut ViewContext<Self>) {
ctx.spawn(async { cli_install::install_cli() }, |view, result, ctx| {
match result {
Ok(_) => {
let command_name = ChannelState::channel().cli_command_name();
let message = format!("Successfully installed the Oz CLI! You can now run '{command_name}' from the command line.");
view.toast_stack.update(ctx, |toast_stack, ctx| {
let toast = DismissibleToast::success(message.to_string())
.with_link(
ToastLink::new("Learn more".to_string()).with_href(
"https://docs.warp.dev/reference/cli".to_string(),
),
);
toast_stack.add_ephemeral_toast(toast, ctx);
});
}
Err(error) => {
let error_message = format!("Failed to install Oz command: {error}");
log::error!("{error_message}");
view.toast_stack.update(ctx, |toast_stack, ctx| {
let toast = DismissibleToast::error(error_message);
toast_stack.add_persistent_toast(toast, ctx);
});
}
}
});
}
/// Uninstall the Warp CLI by removing the symlink from /usr/local/bin
#[cfg(target_os = "macos")]
fn uninstall_cli(&mut self, ctx: &mut ViewContext<Self>) {
ctx.spawn(
async { cli_install::uninstall_cli() },
|view, result, ctx| match result {
Ok(_) => {
let message = "Successfully uninstalled the Oz command.";
view.toast_stack.update(ctx, |toast_stack, ctx| {
let toast = DismissibleToast::success(message.to_string());
toast_stack.add_ephemeral_toast(toast, ctx);
});
}
Err(error) => {
let error_message = format!("Failed to uninstall Oz command: {error}");
log::error!("{error_message}");
view.toast_stack.update(ctx, |toast_stack, ctx| {
let toast = DismissibleToast::error(error_message);
toast_stack.add_persistent_toast(toast, ctx);
});
}
},
);
}
fn undo_revert_in_code_review_pane(
&mut self,
window_id: WindowId,
view_id: EntityId,
ctx: &mut ViewContext<Self>,
) {
GlobalCodeReviewModel::handle(ctx).update(ctx, |global_code_review_model, ctx| {
global_code_review_model.undo_revert_in_code_review_pane(window_id, view_id, ctx);
});
}
fn toggle_recording_mode(&self, ctx: &mut ViewContext<Self>) {
DebugSettings::handle(ctx).update(ctx, |debug_settings, settings_ctx| {
report_if_error!(debug_settings
.recording_mode
.toggle_and_save_value(settings_ctx));
});
}
fn toggle_in_band_generators(&self, ctx: &mut ViewContext<Self>) {
DebugSettings::handle(ctx).update(ctx, |debug_settings, settings_ctx| {
report_if_error!(debug_settings
.are_in_band_generators_for_all_sessions_enabled
.toggle_and_save_value(settings_ctx));
});
}
fn toggle_debug_network_status(&self, ctx: &mut ViewContext<Self>) {
NetworkStatus::handle(ctx).update(ctx, |network_status, network_ctx| {
let is_reachable = network_status.is_online();
let new_is_reachable = !is_reachable;
if new_is_reachable {
log::info!("Manually toggled network status to be reachable");
} else {
log::info!("Manually toggled network status to be not reachable");
}
network_status.reachability_changed(new_is_reachable, network_ctx);
});
}
fn toggle_show_memory_stats(&self, ctx: &mut ViewContext<Self>) {
DebugSettings::handle(ctx).update(ctx, |debug_settings, ctx| {
report_if_error!(debug_settings.show_memory_stats.toggle_and_save_value(ctx));
})
}
fn open_import_modal(
&mut self,
owner: Owner,
initial_folder_id: &Option<SyncId>,
ctx: &mut ViewContext<Self>,
) {
// TODO: This should take either an owner OR a folder.
self.current_workspace_state.is_import_modal_open = true;
self.import_modal.update(ctx, |import_modal, ctx| {
import_modal.open_with_target(owner, *initial_folder_id, ctx);
});
ctx.notify();
}
pub fn open_or_toggle_warp_drive(
&mut self,
toggle: bool,
explicit_user_action: bool,
ctx: &mut ViewContext<Self>,
) {
// Closing all left panels will also close warp drive so we need to retrieve
// whether warp drive was open first, and toggle based on the initial value.
let was_warp_drive_open = self.current_workspace_state.is_warp_drive_open;
self.current_workspace_state.close_all_left_panels();
self.current_workspace_state.is_warp_drive_open =
if toggle { !was_warp_drive_open } else { true };
// Set selected object to None upon toggle close of Warp Drive
if !self.current_workspace_state.is_warp_drive_open {
self.set_selected_object(None, ctx);
self.focus_active_tab(ctx);
}
// Reset focused index when opening/toggling Warp Drive open
if self.current_workspace_state.is_warp_drive_open {
self.reset_focused_index_in_warp_drive(true, ctx);
}
ctx.notify();
// Telemetry and welcome tip logic is only for when the user explicitly opens Warp Drive
// AND warp drive wasn't open before. There are other scenarios where we open Warp Drive like:
// new user onboarding, user joins a team, etc so we want to avoid counting those.
if explicit_user_action
&& !was_warp_drive_open
&& self.current_workspace_state.is_warp_drive_open
{
send_telemetry_from_ctx!(
TelemetryEvent::WarpDriveOpened {
source: WarpDriveSource::Legacy,
is_code_mode_v2: false
},
ctx
);
self.tips_completed.update(ctx, |tips_completed, ctx| {
mark_feature_used_and_write_to_user_defaults(
Tip::Action(TipAction::OpenWarpDrive),
tips_completed,
ctx,
);
ctx.notify();
});
}
}
fn open_resource_center_main_page(&mut self, ctx: &mut ViewContext<Self>) {
// Set current page to Main
self.resource_center_view
.update(ctx, |resource_center_view, ctx| {
resource_center_view.set_current_page(ResourceCenterPage::Main, ctx)
});
// Open side panel
self.current_workspace_state.is_resource_center_open = true;
}
pub fn toggle_resource_center(&mut self, ctx: &mut ViewContext<Self>) {
// Close AI Assistant panel when resource center is opened
if !self.current_workspace_state.is_resource_center_open {
self.current_workspace_state.is_ai_assistant_panel_open = false;
self.focus_active_tab(ctx);
}
if !self.current_workspace_state.is_resource_center_open {
self.open_resource_center_main_page(ctx);
send_telemetry_from_ctx!(TelemetryEvent::ResourceCenterOpened, ctx);
} else {
// Close side panel
self.current_workspace_state.is_resource_center_open = false;
}
self.update_resource_center_action_target(ctx);
ctx.notify();
}
fn open_left_panel(&mut self, ctx: &mut ViewContext<Self>) {
self.left_panel_open = true;
let active_pane_group = self.active_tab_pane_group().clone();
active_pane_group.update(ctx, |pane_group, ctx| {
pane_group.set_left_panel_open(true, ctx);
});
ctx.notify();
}
/// Auto-opens the conversation list on first app start.
/// Once we've done this once, we persist a preference so subsequent restarts
/// will respect the user's visibility preference (restored from workspace state).
fn maybe_auto_open_conversation_list(&mut self, ctx: &mut ViewContext<Self>) {
if !FeatureFlag::AgentViewConversationListView.is_enabled()
|| !AISettings::as_ref(ctx).is_any_ai_enabled(ctx)
{
return;
}
let has_auto_opened = *AISettings::as_ref(ctx).has_auto_opened_conversation_list;
if has_auto_opened {
return;
}
// Only auto-open on terminal tabs, not on settings or other non-terminal tabs
let has_terminal = self
.active_tab_pane_group()
.as_ref(ctx)
.has_terminal_panes();
if !has_terminal {
return;
}
// For first-time-users, auto-open the conversation list for discoverability
if !self.active_tab_pane_group().as_ref(ctx).left_panel_open {
self.open_left_panel(ctx);
}
self.left_panel_view.update(ctx, |lp, ctx| {
lp.restore_active_view_from_snapshot(ToolPanelView::ConversationListView, ctx);
});
// Mark that we've done the one-time auto-open
AISettings::handle(ctx).update(ctx, |settings, ctx| {
report_if_error!(settings
.has_auto_opened_conversation_list
.set_value(true, ctx));
});
}
fn close_left_panel(&mut self, ctx: &mut ViewContext<Self>) {
self.left_panel_open = false;
let active_pane_group = self.active_tab_pane_group().clone();
active_pane_group.update(ctx, |pane_group, ctx| {
pane_group.set_left_panel_open(false, ctx);
});
ctx.notify();
}
fn toggle_vertical_tabs_panel(&mut self, ctx: &mut ViewContext<Self>) {
self.vertical_tabs_panel_open = !self.vertical_tabs_panel_open;
if !self.vertical_tabs_panel_open {
self.close_vertical_tabs_settings_popup();
self.vertical_tabs_panel.clear_detail_sidecar();
}
self.sync_window_button_visibility(ctx);
ctx.notify();
}
fn close_vertical_tabs_settings_popup(&mut self) {
self.vertical_tabs_panel.show_settings_popup = false;
}
/// Sets the visibility state of the agent management view
/// and updates the AgentConversationsModel to reflect the new state.
fn set_is_agent_management_view_open(&mut self, is_open: bool, ctx: &mut ViewContext<Self>) {
let was_open = self.current_workspace_state.is_agent_management_view_open;
if was_open == is_open {
return;
}
self.current_workspace_state.is_agent_management_view_open = is_open;
let window_id = self.window_id;
let view_id = self.agent_management_view.id();
AgentConversationsModel::handle(ctx).update(ctx, |model, ctx| {
if is_open {
model.register_view_open(window_id, view_id, ctx);
} else {
model.register_view_closed(window_id, view_id, ctx);
}
});
// Notify panels about the agent management view state change so they can
// update their top border visibility accordingly.
self.left_panel_view.update(ctx, |panel, ctx| {
panel.set_agent_management_view_open(is_open, ctx);
});
self.right_panel_view.update(ctx, |panel, ctx| {
panel.set_agent_management_view_open(is_open, ctx);
});
}
fn toggle_left_panel(&mut self, ctx: &mut ViewContext<Self>) {
let active_pane_group = self.active_tab_pane_group().clone();
let was_open = active_pane_group.read(ctx, |pane_group, _| pane_group.left_panel_open);
let new_state = !was_open;
if new_state {
self.open_left_panel(ctx);
} else {
self.close_left_panel(ctx);
}
// If we are opening the panel, set width based on the most recent tab's width if available,
// otherwise compute default width from current window size. Also auto-expand the project
// explorer if it's the active left panel view.
if new_state {
let window_id = ctx.window_id();
let resizable_data = ResizableData::handle(ctx);
if let Some(handle) = resizable_data
.as_ref(ctx)
.get_handle(window_id, ModalType::LeftPanelWidth)
{
if let Ok(mut state) = handle.lock() {
// Get the current width from ResizableData - this reflects the most recent tab's width
let current_width = state.size();
// Only recompute default if the current width is at the default value
// This preserves the width from the most recent tab
if current_width == DEFAULT_LEFT_PANEL_WIDTH {
let has_horizontal_split = active_pane_group
.read(ctx, |pane_group, _| pane_group.has_horizontal_split());
let (left_width, _right_width) =
compute_default_panel_widths(ctx, window_id, has_horizontal_split);
state.set_size(left_width);
}
// If current_width is not the default, it means we have a width from a previous tab,
// so we don't need to do anything - the width is already preserved
}
}
// Auto-expand the file tree when the left panel is opened and the project explorer is
// the active view.
let file_tree_active = self
.left_panel_view
.read(ctx, |lp, _| lp.is_file_tree_active());
if file_tree_active {
self.left_panel_view.update(ctx, |left_panel, ctx| {
left_panel.auto_expand_active_file_tree_to_most_recent_directory(ctx);
});
}
}
if !new_state {
self.focus_active_tab(ctx);
}
ctx.notify();
}
#[cfg(feature = "local_fs")]
fn setup_code_review_panel(
&mut self,
context: Option<&CodeReviewPaneContext>,
ctx: &mut ViewContext<Self>,
) {
if !*TabSettings::as_ref(ctx).show_code_review_button {
return;
}
// If context is provided, use it directly. Otherwise, derive from active pane group.
let context_data: Option<(
Option<PathBuf>,
ModelHandle<DiffStateModel>,
WeakViewHandle<TerminalView>,
)> = if let Some(context) = context {
Some((
context.repo_path.clone(),
context.diff_state_model.clone(),
context.terminal_view.clone(),
))
} else {
let active_pane_group = self.active_tab_pane_group().clone();
// Read repo_path and terminal_view from the pane group (immutable context).
let read_result = active_pane_group.read(ctx, |pane_group, ctx| {
pane_group.active_session_view(ctx).map(|terminal_view| {
let repo_path = terminal_view.as_ref(ctx).current_repo_path().cloned();
(repo_path, terminal_view.downgrade())
})
});
// Resolve DiffStateModel outside the read closure (needs mutable context).
read_result.and_then(
|(repo_path, terminal_view): (Option<PathBuf>, WeakViewHandle<TerminalView>)| {
let diff_state_model = repo_path.as_ref().and_then(|rp: &PathBuf| {
self.working_directories_model.update(ctx, |model, ctx| {
model.get_or_create_diff_state_model(rp.clone(), ctx)
})
})?;
Some((repo_path, diff_state_model, terminal_view))
},
)
};
if let Some((repo, diff_state_model, terminal_view)) = context_data {
self.right_panel_view.update(ctx, |right_pane_view, ctx| {
right_pane_view.open_code_review(
repo.clone(),
diff_state_model,
terminal_view,
ctx,
);
});
} else {
self.right_panel_view.update(ctx, |right_panel_view, ctx| {
right_panel_view.close_code_review(ctx);
})
}
}
fn open_code_review_panel_from_arg(
&mut self,
panel_context: &CodeReviewPanelArg,
pane_group: ViewHandle<PaneGroup>,
ctx: &mut ViewContext<Self>,
) {
// Skip the full panel setup when the panel is already open for the target repo.
let panel_already_showing_repo = pane_group.as_ref(ctx).right_panel_open
&& panel_context
.repo_path
.as_ref()
.is_some_and(|target_repo_path| {
self.right_panel_view.as_ref(ctx).selected_repo_path() == Some(target_repo_path)
});
if panel_already_showing_repo {
return;
}
let repo_path = panel_context.repo_path.clone();
let diff_state_model = repo_path.as_ref().and_then(|rp| {
self.working_directories_model.update(ctx, |model, ctx| {
model.get_or_create_diff_state_model(rp.clone(), ctx)
})
});
let Some(diff_state_model) = diff_state_model else {
return;
};
let context = CodeReviewPaneContext {
repo_path,
diff_state_model,
terminal_view: panel_context.terminal_view.clone(),
};
self.open_right_panel(
&context,
&pane_group,
panel_context.entrypoint,
panel_context.cli_agent,
ctx,
);
let active_conversation_id = panel_context
.terminal_view
.upgrade(ctx)
.and_then(|tv| BlocklistAIHistoryModel::as_ref(ctx).active_conversation_id(tv.id()));
if let Some(conversation_id) = active_conversation_id {
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, _| {
history_model.set_has_code_review_opened_to_true(conversation_id);
});
}
}
fn update_right_panel_open_state(
&mut self,
#[cfg_attr(target_family = "wasm", allow(unused_variables))]
panel_update_params: RightPanelUpdateParams,
ctx: &mut ViewContext<Self>,
) {
let should_open = panel_update_params.target_open_state;
let should_close = !panel_update_params.target_open_state;
let new_is_maximized = panel_update_params.pane_group.update(ctx, |pane_group, _| {
pane_group.right_panel_open = should_open;
pane_group.is_right_panel_maximized
});
self.right_panel_view.update(ctx, |view, ctx| {
view.set_maximized(new_is_maximized, ctx);
if should_close {
view.close_code_review(ctx);
}
});
if should_open {
#[cfg(feature = "local_fs")]
{
let window_id = ctx.window_id();
let resizable_data = ResizableData::handle(ctx);
if let Some(handle) = resizable_data
.as_ref(ctx)
.get_handle(window_id, ModalType::RightPanelWidth)
{
if let Ok(mut state) = handle.lock() {
// Get the current width from ResizableData - this reflects the most recent tab's width
let current_width = state.size();
// Only recompute default if the current width is at the default value
// This preserves the width from the most recent tab
if current_width == DEFAULT_RIGHT_PANEL_WIDTH {
let has_horizontal_split = panel_update_params
.pane_group
.read(ctx, |pane_group, _| pane_group.has_horizontal_split());
let (_left_width, right_width) =
compute_default_panel_widths(ctx, window_id, has_horizontal_split);
state.set_size(right_width);
}
// If current_width is not the default, it means we have a width from a previous tab,
// so we don't need to do anything - the width is already preserved
}
}
send_telemetry_from_ctx!(
CodeReviewTelemetryEvent::PaneOpened {
entrypoint: panel_update_params.entrypoint.unwrap_or_default(),
is_code_mode_v2: true,
cli_agent: panel_update_params.cli_agent.map(Into::into),
},
ctx
);
self.setup_code_review_panel(panel_update_params.review_pane_context, ctx);
}
} else {
self.focus_active_tab(ctx);
}
ctx.notify();
}
fn toggle_right_panel(
&mut self,
pane_group_handle: &ViewHandle<PaneGroup>,
ctx: &mut ViewContext<Self>,
) {
let target_open_state =
pane_group_handle.read(ctx, |pane_group, _| !pane_group.right_panel_open);
// Read repo_path and terminal_view from pane group (immutable context).
let read_result = pane_group_handle.read(ctx, |pane_group, ctx| {
pane_group.active_session_view(ctx).map(|terminal_view| {
let repo_path = terminal_view.as_ref(ctx).current_repo_path().cloned();
(repo_path, terminal_view.downgrade())
})
});
// Resolve DiffStateModel outside the read closure (needs mutable context).
let context = read_result.and_then(
|(repo_path, terminal_view): (Option<PathBuf>, WeakViewHandle<TerminalView>)| {
let diff_state_model = repo_path.as_ref().and_then(|rp: &PathBuf| {
self.working_directories_model.update(ctx, |model, ctx| {
model.get_or_create_diff_state_model(rp.clone(), ctx)
})
})?;
Some(CodeReviewPaneContext {
repo_path,
diff_state_model,
terminal_view,
})
},
);
self.update_right_panel_open_state(
RightPanelUpdateParams {
pane_group: pane_group_handle,
target_open_state,
entrypoint: Some(CodeReviewPaneEntrypoint::RightPanel),
cli_agent: None,
review_pane_context: context.as_ref(),
},
ctx,
);
}
#[cfg(feature = "local_fs")]
fn open_right_panel(
&mut self,
context: &CodeReviewPaneContext,
pane_group_handle: &ViewHandle<PaneGroup>,
entrypoint: CodeReviewPaneEntrypoint,
cli_agent: Option<crate::terminal::CLIAgent>,
ctx: &mut ViewContext<Self>,
) {
if pane_group_handle.as_ref(ctx).right_panel_open {
if let Some(repo_path) = &context.repo_path {
self.right_panel_view.update(ctx, |right_panel, ctx| {
right_panel.update_selected_repo(repo_path.clone(), ctx);
});
}
return;
}
self.update_right_panel_open_state(
RightPanelUpdateParams {
pane_group: pane_group_handle,
target_open_state: true,
entrypoint: Some(entrypoint),
cli_agent,
review_pane_context: Some(context),
},
ctx,
);
if let Some(repo_path) = &context.repo_path {
self.right_panel_view.update(ctx, |right_panel, ctx| {
right_panel.update_selected_repo(repo_path.clone(), ctx);
});
}
}
#[cfg(not(feature = "local_fs"))]
fn open_right_panel(
&mut self,
_context: &CodeReviewPaneContext,
_pane_group_handle: &ViewHandle<PaneGroup>,
_entrypoint: CodeReviewPaneEntrypoint,
_cli_agent: Option<crate::terminal::CLIAgent>,
_ctx: &mut ViewContext<Self>,
) {
}
pub fn close_right_panel(
&mut self,
pane_group_handle: &ViewHandle<PaneGroup>,
ctx: &mut ViewContext<Self>,
) {
self.update_right_panel_open_state(
RightPanelUpdateParams {
pane_group: pane_group_handle,
target_open_state: false,
entrypoint: None,
cli_agent: None,
review_pane_context: None,
},
ctx,
);
}
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
fn toggle_right_panel_maximized(&mut self, ctx: &mut ViewContext<Self>) {
let pane_group = self.active_tab_pane_group().clone();
let is_maximized = pane_group.update(ctx, |pane_group, _| {
pane_group.is_right_panel_maximized = !pane_group.is_right_panel_maximized;
pane_group.is_right_panel_maximized
});
self.right_panel_view.update(ctx, |view, ctx| {
view.set_maximized(is_maximized, ctx);
if is_maximized {
view.focus_active_code_review_view(ctx);
}
});
if !is_maximized {
self.focus_active_tab(ctx);
}
ctx.notify();
}
fn user_menu_items(&self, app: &AppContext) -> Vec<MenuItem<WorkspaceAction>> {
let mut items = Vec::new();
if !self.auth_state.is_anonymous_or_logged_out() {
let name = self.auth_state.username_for_display().unwrap_or_default();
items.push(MenuItemFields::new(name).with_disabled(true).into_item())
}
let appearance = Appearance::as_ref(app);
// Render the subtle autoupdate UI if autoupdate is ready and there is no incoming prominent update version.
if FeatureFlag::Autoupdate.is_enabled()
&& FeatureFlag::AutoupdateUIRevamp.is_enabled()
&& ChannelState::show_autoupdate_menu_items()
{
match autoupdate::get_update_state(app) {
AutoupdateStage::UpdateReady { new_version, .. }
| AutoupdateStage::UpdatedPendingRestart { new_version }
if !is_incoming_version_past_current(
new_version.last_prominent_update.as_deref(),
) =>
{
items.push(
MenuItemFields::new("Update and relaunch Galaxy")
.with_on_select_action(WorkspaceAction::ApplyUpdate)
.with_override_text_color(appearance.theme().ansi_fg_red())
.into_item(),
)
}
AutoupdateStage::Updating { new_version, .. }
if !is_incoming_version_past_current(
new_version.last_prominent_update.as_deref(),
) =>
{
items.push(
MenuItemFields::new(format!("Updating to ({})", new_version.version))
.with_disabled(true)
.into_item(),
)
}
AutoupdateStage::UnableToUpdateToNewVersion { new_version }
if !is_incoming_version_past_current(
new_version.last_prominent_update.as_deref(),
) =>
{
items.push(
MenuItemFields::new("Update Galaxy manually")
.with_on_select_action(WorkspaceAction::DownloadNewVersion)
.with_override_text_color(appearance.theme().ansi_fg_red())
.into_item(),
)
}
_ => {}
}
}
items.extend([
MenuItemFields::new("What's new")
.with_on_select_action(WorkspaceAction::ViewLatestChangelog)
.into_item(),
MenuItemFields::new("Settings")
.with_on_select_action(WorkspaceAction::ShowSettings)
.into_item(),
MenuItemFields::new("Keyboard shortcuts")
.with_on_select_action(WorkspaceAction::ToggleKeybindingsPage)
.into_item(),
MenuItem::Separator,
MenuItemFields::new("Documentation")
.with_on_select_action(WorkspaceAction::ViewUserDocs)
.into_item(),
MenuItemFields::new("Feedback")
.with_on_select_action(WorkspaceAction::SendFeedback)
.into_item(),
]);
#[cfg(not(target_family = "wasm"))]
items.push(
MenuItemFields::new("View Galaxy logs")
.with_on_select_action(WorkspaceAction::ViewLogs)
.into_item(),
);
items.extend([
MenuItemFields::new("Slack")
.with_on_select_action(WorkspaceAction::JoinSlack)
.into_item(),
MenuItem::Separator,
]);
if self.auth_state.is_anonymous_or_logged_out() {
items.push(
MenuItemFields::new("Sign up")
.with_on_select_action(WorkspaceAction::SignupAnonymousUser)
.into_item(),
);
}
// Check if the user is on any paid plan to determine whether to show "Billing and Usage" or "Upgrade"
let is_on_paid_plan = UserWorkspaces::as_ref(app)
.current_workspace()
.map(|workspace| workspace.billing_metadata.is_user_on_paid_plan())
.unwrap_or(false);
if is_on_paid_plan {
items.push(
MenuItemFields::new("Billing and usage")
.with_on_select_action(WorkspaceAction::ShowSettingsPage(
SettingsSection::BillingAndUsage,
))
.into_item(),
);
} else {
items.push(
MenuItemFields::new("Upgrade")
.with_on_select_action(WorkspaceAction::ShowUpgrade)
.into_item(),
);
}
items.push(
MenuItemFields::new("Invite a friend")
.with_on_select_action(WorkspaceAction::ShowReferralSettingsPage)
.into_item(),
);
if !self.auth_state.is_anonymous_or_logged_out() {
items.push(
MenuItemFields::new("Log out")
.with_on_select_action(WorkspaceAction::LogOut)
.into_item(),
);
}
items
}
fn selected_new_session_sidecar_selection(
&self,
ctx: &AppContext,
) -> Option<NewSessionSidecarSelection> {
self.new_session_sidecar_menu.read(ctx, |menu, _| {
menu.selected_item().and_then(|item| match item {
MenuItem::Item(fields) => fields.on_select_action().cloned(),
_ => None,
})
})
}
fn execute_new_session_sidecar_selection(
&mut self,
selection: NewSessionSidecarSelection,
ctx: &mut ViewContext<Self>,
) {
match selection {
NewSessionSidecarSelection::OpenWorktreeRepo { repo_path } => {
self.open_worktree_in_repo(repo_path, ctx);
}
}
}
fn toggle_user_menu(&mut self, ctx: &mut ViewContext<Self>) {
self.is_user_menu_open = !self.is_user_menu_open;
if self.is_user_menu_open {
let items = self.user_menu_items(ctx);
self.user_menu.update(ctx, |menu, ctx| {
menu.set_items(items, ctx);
});
}
ctx.focus(&self.user_menu);
ctx.notify();
}
pub fn toggle_keybindings_page(&mut self, ctx: &mut ViewContext<Self>) {
let current_page = self
.resource_center_view
.read(ctx, |resource_center_view, _ctx| {
resource_center_view.get_current_page()
});
if !self.current_workspace_state.is_resource_center_open {
// Set current page to Keybindings
self.resource_center_view
.update(ctx, |resource_center_view, ctx| {
resource_center_view.set_current_page(ResourceCenterPage::Keybindings, ctx)
});
// Ensure other right panels are closed
self.current_workspace_state.is_ai_assistant_panel_open = false;
// Open side panel
self.current_workspace_state.is_resource_center_open = true;
send_telemetry_from_ctx!(TelemetryEvent::KeybindingsPageOpened, ctx);
} else if current_page != ResourceCenterPage::Keybindings
&& self.current_workspace_state.is_resource_center_open
{
// Navigate to keybindings page
self.resource_center_view
.update(ctx, |resource_center_view, ctx| {
resource_center_view.set_current_page(ResourceCenterPage::Keybindings, ctx)
});
send_telemetry_from_ctx!(TelemetryEvent::KeybindingsPageOpened, ctx);
} else {
// Close side panel
self.current_workspace_state.is_resource_center_open = false;
self.focus_active_tab(ctx);
}
ctx.notify();
}
fn update_resource_center_action_target(&mut self, ctx: &mut ViewContext<Self>) {
if self.current_workspace_state.is_resource_center_open {
let input_id = self.active_input_id(ctx);
self.resource_center_view
.update(ctx, |resource_center_view, ctx| {
resource_center_view.set_action_target(ctx.window_id(), input_id, ctx)
});
}
}
fn handle_tab_right_click_menu_event(
&mut self,
event: &MenuEvent,
ctx: &mut ViewContext<Self>,
) {
if let MenuEvent::Close { via_select_item: _ } = event {
self.show_tab_right_click_menu = None;
ctx.notify();
}
}
fn handle_new_session_menu_event(&mut self, event: &MenuEvent, ctx: &mut ViewContext<Self>) {
match event {
MenuEvent::Close { .. } => {
self.close_new_session_dropdown_menu(ctx);
}
MenuEvent::ItemHovered => {
self.update_new_session_sidecar(ctx);
}
MenuEvent::ItemSelected => {
self.update_new_session_sidecar(ctx);
}
}
}
fn handle_new_session_sidecar_event(&mut self, event: &MenuEvent, ctx: &mut ViewContext<Self>) {
match event {
MenuEvent::Close { via_select_item } => {
let selection = if *via_select_item {
self.selected_new_session_sidecar_selection(ctx)
} else {
None
};
log::info!(
"New-session sidecar closed: worktree_active={}, via_select_item={via_select_item}",
self.worktree_sidecar_active
);
if let Some(selection) = selection {
self.execute_new_session_sidecar_selection(selection, ctx);
}
if *via_select_item {
// Item clicked in sidecar — also close the main menu.
self.show_new_session_dropdown_menu = None;
}
self.clear_worktree_sidecar_state(ctx);
self.new_session_dropdown_menu.update(ctx, |menu, _| {
menu.set_safe_zone_target(None);
menu.set_submenu_being_shown_for_item_index(None);
});
ctx.notify();
}
MenuEvent::ItemSelected => {}
MenuEvent::ItemHovered => {
self.sync_new_session_sidecar_selection_to_hover(ctx);
}
}
}
fn should_include_worktree_sidecar_repo(repo_path: &Path, ctx: &AppContext) -> bool {
// This performs one repo-metadata lookup per persisted workspace while the
// sidecar items are rebuilt. That's acceptable for now given the expected
// repo counts here, and it keeps linked-worktree filtering scoped to the
// only UI that currently needs it.
let Some(repository) =
DetectedRepositories::as_ref(ctx).get_watched_repo_for_path(repo_path, ctx)
else {
return true;
};
// Linked worktrees (and submodules) have an external gitdir; exclude
// them so only primary repository checkouts appear in the list.
repository.as_ref(ctx).external_git_directory().is_none()
}
fn build_worktree_sidecar_items(
&self,
ctx: &AppContext,
) -> Vec<MenuItem<NewSessionSidecarSelection>> {
let search_editor = self.worktree_sidecar_search_editor.clone();
let search_item = MenuItemFields::new_with_custom_label(
Arc::new(move |_, _, appearance, _| {
let theme = appearance.theme();
let search_icon = ConstrainedBox::new(
icons::Icon::SearchSmall
.to_galaxyui_icon(theme.sub_text_color(theme.surface_2()))
.finish(),
)
.with_width(16.)
.with_height(16.)
.finish();
let search_row = Flex::row()
.with_child(Container::new(search_icon).with_margin_right(8.).finish())
.with_child(
Shrinkable::new(1., ChildView::new(&search_editor).finish()).finish(),
)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_size(MainAxisSize::Max)
.finish();
ConstrainedBox::new(
Container::new(search_row)
.with_padding_left(NEW_SESSION_SIDECAR_SEARCH_BOX_HORIZONTAL_PADDING)
.with_padding_right(NEW_SESSION_SIDECAR_SEARCH_BOX_HORIZONTAL_PADDING)
.with_padding_top(NEW_SESSION_SIDECAR_SEARCH_BOX_VERTICAL_PADDING)
.with_padding_bottom(NEW_SESSION_SIDECAR_SEARCH_BOX_VERTICAL_PADDING)
.with_border(Border::all(1.).with_border_fill(theme.surface_3()))
.with_corner_radius(CornerRadius::with_top(Radius::Pixels(4.)))
.finish(),
)
.with_height(NEW_SESSION_SIDECAR_SEARCH_BOX_HEIGHT)
.finish()
}),
Some("Search repos".to_string()),
)
.with_no_interaction_on_hover()
.no_highlight_on_hover()
.with_padding_override(0., 0.)
.into_item();
let query = self.worktree_sidecar_search_query.trim().to_lowercase();
let mut items = vec![search_item];
items.extend(
PersistedWorkspace::as_ref(ctx)
.workspaces()
.filter(|ws| ws.path.exists())
.filter(|ws| Self::should_include_worktree_sidecar_repo(&ws.path, ctx))
.filter(|ws| {
if query.is_empty() {
true
} else {
ws.path
.to_string_lossy()
.to_lowercase()
.contains(query.as_str())
}
})
.map(|ws| {
let path_str = ws.path.to_string_lossy().into_owned();
MenuItemFields::new(path_str.clone())
.with_on_select_action(NewSessionSidecarSelection::OpenWorktreeRepo {
repo_path: path_str,
})
.with_icon(icons::Icon::Folder)
.into_item()
})
.collect::<Vec<_>>(),
);
items
}
fn configure_worktree_new_session_sidecar(
&mut self,
hovered_index: usize,
auto_select_first_repo: bool,
ctx: &mut ViewContext<Self>,
) {
let items = self.build_worktree_sidecar_items(ctx);
let repo_count = items.len().saturating_sub(1);
log::info!(
"Configuring worktree sidecar: hovered_index={hovered_index}, query={:?}, repo_count={repo_count}",
self.worktree_sidecar_search_query
);
let add_repo_mouse_state = self.new_session_sidecar_add_repo_mouse_state.clone();
self.new_session_sidecar_menu.update(ctx, |menu, view_ctx| {
menu.set_items(items, view_ctx);
menu.clear_pinned_header_builder();
menu.set_content_padding_overrides(Some(0.), None);
menu.set_pinned_footer_builder(move |app| {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let font_family = appearance.ui_font_family();
let font_size = appearance.ui_font_size();
let border_fill = theme.outline();
let mouse_state = add_repo_mouse_state.clone();
Hoverable::new(mouse_state, move |state| {
let bg = if state.is_hovered() {
theme.accent_button_color()
} else {
theme.surface_2()
};
let text_color = theme.main_text_color(bg);
ConstrainedBox::new(
Container::new(
Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(
Text::new_inline(" + Add new repo", font_family, font_size)
.with_color(text_color.into())
.finish(),
)
.finish(),
)
.with_padding_left(NEW_SESSION_SIDECAR_FOOTER_HORIZONTAL_PADDING)
.with_padding_right(NEW_SESSION_SIDECAR_FOOTER_HORIZONTAL_PADDING)
.with_padding_top(NEW_SESSION_SIDECAR_FOOTER_VERTICAL_PADDING)
.with_padding_bottom(NEW_SESSION_SIDECAR_FOOTER_VERTICAL_PADDING)
.with_background(bg)
.with_border(Border::top(1.).with_border_fill(border_fill))
.with_corner_radius(CornerRadius::with_bottom(Radius::Pixels(5.)))
.finish(),
)
.with_width(NEW_SESSION_SIDECAR_WIDTH)
.finish()
})
.with_cursor(Cursor::PointingHand)
.on_click(|ctx: &mut galaxyui::elements::EventContext, _, _| {
ctx.dispatch_typed_action(WorkspaceAction::OpenWorktreeAddRepoPicker);
ctx.dispatch_typed_action(crate::menu::MenuAction::Close(true));
})
.finish()
});
});
if auto_select_first_repo {
self.select_first_worktree_sidecar_repo(ctx);
} else {
self.reset_worktree_sidecar_repo_selection(ctx);
}
self.worktree_sidecar_active = true;
self.show_new_session_sidecar = true;
let sidecar_rect = ctx
.element_position_by_id_at_last_frame(self.window_id, NEW_SESSION_SIDECAR_POSITION_ID);
log::info!(
"Worktree sidecar safe-zone target from previous frame available: {}",
sidecar_rect.is_some()
);
self.new_session_dropdown_menu.update(ctx, |menu, _| {
menu.set_safe_zone_target(sidecar_rect);
menu.set_submenu_being_shown_for_item_index(Some(hovered_index));
});
ctx.focus(&self.worktree_sidecar_search_editor);
}
fn configure_action_sidecar_for_hovered_item(
&mut self,
label: &str,
hovered_index: usize,
ctx: &mut ViewContext<Self>,
) {
// Determine the SidecarItemKind from the hovered menu item's label and action.
let hovered_action = self.new_session_dropdown_menu.read(ctx, |menu, _| {
menu.items().get(hovered_index).and_then(|item| match item {
MenuItem::Item(fields) => fields.on_select_action().cloned(),
_ => None,
})
});
let item_kind = match &hovered_action {
Some(WorkspaceAction::SelectTabConfig(config)) => SidecarItemKind::UserTabConfig {
config: config.clone(),
},
Some(WorkspaceAction::AddAgentTab) => SidecarItemKind::BuiltIn {
name: label.to_string(),
default_mode: DefaultSessionMode::Agent,
shell: None,
},
Some(WorkspaceAction::AddAmbientAgentTab) => SidecarItemKind::BuiltIn {
name: label.to_string(),
default_mode: DefaultSessionMode::CloudAgent,
shell: None,
},
Some(WorkspaceAction::AddTerminalTab { .. }) => SidecarItemKind::BuiltIn {
name: label.to_string(),
default_mode: DefaultSessionMode::Terminal,
shell: None,
},
Some(WorkspaceAction::AddTabWithShell { shell, .. }) => SidecarItemKind::BuiltIn {
name: label.to_string(),
default_mode: DefaultSessionMode::Terminal,
shell: Some(shell.clone()),
},
Some(WorkspaceAction::AddDockerSandboxTab) => SidecarItemKind::BuiltIn {
name: label.to_string(),
default_mode: DefaultSessionMode::DockerSandbox,
shell: None,
},
_ => {
// Hovered item has no associated sidecar. Clear any stale
// sidecar state left over from a previously-hovered item so
// the menu doesn't keep rendering that item as the
// submenu-parent highlight.
self.tab_config_action_sidecar_item = None;
self.new_session_dropdown_menu.update(ctx, |menu, _| {
menu.set_safe_zone_target(None);
menu.set_submenu_being_shown_for_item_index(None);
});
return;
}
};
self.tab_config_action_sidecar_item = Some(item_kind);
let sidecar_rect = ctx
.element_position_by_id_at_last_frame(self.window_id, NEW_SESSION_SIDECAR_POSITION_ID);
self.new_session_dropdown_menu.update(ctx, |menu, _| {
menu.set_safe_zone_target(sidecar_rect);
menu.set_submenu_being_shown_for_item_index(Some(hovered_index));
});
}
/// Returns `true` when a sidecar of the given width should render on the left
/// of the menu (because it would overflow the window on the right).
fn should_render_sidecar_left(
&self,
anchor_label: &str,
sidecar_width: f32,
app: &AppContext,
) -> bool {
let Some(window) = app.windows().platform_window(self.window_id) else {
return false;
};
let Some(anchor_rect) =
app.element_position_by_id_at_last_frame(self.window_id, anchor_label)
else {
return false;
};
let gap = 4.0;
let would_overflow_right = anchor_rect.max_x() + gap + sidecar_width >= window.size().x();
let would_overflow_left = anchor_rect.min_x() - gap - sidecar_width < 0.0;
match (would_overflow_left, would_overflow_right) {
(true, false) => false, // Only right fits
(false, true) => true, // Only left fits
_ => false, // Default to right
}
}
fn refresh_worktree_sidecar_if_active(&mut self, ctx: &mut ViewContext<Self>) {
if !self.worktree_sidecar_active {
return;
}
let Some(hovered_index) = self
.new_session_dropdown_menu
.read(ctx, |menu, _| menu.hovered_index())
else {
return;
};
self.configure_worktree_new_session_sidecar(hovered_index, true, ctx);
}
/// Updates the sidecar menu based on which item is hovered in the main
/// new-session dropdown. If the hovered item is a submenu parent (Terminal
/// or New worktree config), populates the sidecar with the appropriate items.
fn update_new_session_sidecar(&mut self, ctx: &mut ViewContext<Self>) {
// Use hovered_index (not selected_index) as the source of truth.
// hovered_row_index accurately tracks the mouse position and survives
// reset_selection (which only clears selected_row/item indices).
// selected_index can get stuck on a submenu parent when
// UnhoverSubmenuParent resets the selection.
let hovered_index = self
.new_session_dropdown_menu
.read(ctx, |menu, _| menu.hovered_index());
// If hovered is None the mouse has left the menu (possibly onto the
// sidecar) or is on a non-hoverable element. Keep current state.
let Some(hovered_index) = hovered_index else {
return;
};
// Check what the hovered item is by reading its label.
let hovered_label = self.new_session_dropdown_menu.read(ctx, |menu, _| {
menu.items().get(hovered_index).and_then(|item| match item {
MenuItem::Item(fields) => Some(fields.label().to_string()),
_ => None,
})
});
// Separator or non-labeled item — hide sidecar.
let Some(label) = hovered_label else {
if self.show_new_session_sidecar {
self.show_new_session_sidecar = false;
self.new_session_dropdown_menu.update(ctx, |menu, _| {
menu.set_safe_zone_target(None);
menu.set_submenu_being_shown_for_item_index(None);
});
ctx.notify();
}
return;
};
match label.as_str() {
"New worktree config" => {
self.tab_config_action_sidecar_item = None;
let auto_select_first_repo = self.new_session_dropdown_menu.read(ctx, |menu, _| {
menu.last_selection_source() != Some(MenuSelectionSource::Pointer)
});
self.configure_worktree_new_session_sidecar(
hovered_index,
auto_select_first_repo,
ctx,
);
}
// Items that don't get any sidecar.
"New tab config" => {
self.tab_config_action_sidecar_item = None;
if self.show_new_session_sidecar {
self.show_new_session_sidecar = false;
self.worktree_sidecar_active = false;
self.new_session_dropdown_menu.update(ctx, |menu, _| {
menu.set_safe_zone_target(None);
menu.set_submenu_being_shown_for_item_index(None);
});
}
}
// All other actionable items get the action sidecar.
_ => {
self.show_new_session_sidecar = false;
self.worktree_sidecar_active = false;
self.configure_action_sidecar_for_hovered_item(&label, hovered_index, ctx);
}
}
ctx.notify();
}
fn handle_tab_bar_overflow_menu_event(
&mut self,
event: &MenuEvent,
ctx: &mut ViewContext<Self>,
) {
if let MenuEvent::Close { via_select_item: _ } = event {
self.close_tab_bar_overflow_menu(ctx)
}
}
fn handle_launch_config_save_modal_event(
&mut self,
event: &LaunchConfigModalEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
LaunchConfigModalEvent::Close => {
self.current_workspace_state
.is_launch_config_save_modal_open = false;
self.launch_config_save_modal.close();
ctx.notify();
}
LaunchConfigModalEvent::SuccessfullySavedConfig(launch_config) => {
ctx.update_model(&WarpConfig::handle(ctx), move |warp_config, ctx| {
warp_config.append_launch_config(launch_config, ctx);
});
ctx.notify();
}
#[cfg(feature = "local_fs")]
LaunchConfigModalEvent::OpenFileWithTarget {
path,
target,
line_col,
} => {
self.open_file_with_target(
path.clone(),
target.clone(),
*line_col,
CodeSource::Link {
path: path.clone(),
range_start: None,
range_end: None,
},
ctx,
);
}
}
}
fn handle_tab_config_params_modal_event(
&mut self,
event: &ModalEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
ModalEvent::Close => {
self.cancel_tab_config_params_modal(ctx);
}
}
}
/// Cleans up pending state and closes the tab-config params modal without
/// creating a tab config. Used when the modal is dismissed or cancelled.
fn cancel_tab_config_params_modal(&mut self, ctx: &mut ViewContext<Self>) {
let pending_intention = self.pending_onboarding_intention.take();
self.pending_session_config_replacement = None;
self.pending_session_config_tab_config_chip = false;
self.close_tab_config_params_modal(ctx);
if let Some(intention) = pending_intention {
self.dispatch_tutorial_when_bootstrapped(false, intention, ctx);
}
}
fn handle_tab_config_params_modal_body_event(
&mut self,
event: &TabConfigParamsModalEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
TabConfigParamsModalEvent::Submit { config, params } => {
let pending_intention = self.pending_onboarding_intention.take();
let should_track_existing_config_open =
self.pending_session_config_replacement.is_none();
let worktree_name = self.maybe_generate_worktree_name(config);
self.open_tab_config_with_params(
config.as_ref().clone(),
params.clone(),
worktree_name.as_deref(),
ctx,
);
if should_track_existing_config_open {
send_telemetry_from_ctx!(
TabConfigsTelemetryEvent::ExistingConfigOpened {
open_mode: ExistingTabConfigOpenMode::ParamsModal,
is_worktree_config: config.is_worktree(),
},
ctx
);
}
self.close_tab_config_params_modal(ctx);
self.complete_pending_session_config_replacement(ctx);
// The new tab has setup commands (worktree creation); wait for
// them to finish before starting the onboarding tutorial, but
// only after the tab-config chip is dismissed.
if let Some(intention) = pending_intention {
self.queue_onboarding_tutorial_after_session_config_tab_config_chip(
PendingSessionConfigTabConfigChipTutorial::AfterSetupCommands { intention },
ctx,
);
}
// Params modal is now closed; show the chip if it was pending.
self.promote_session_config_tab_config_chip(ctx);
}
TabConfigParamsModalEvent::Close => {
self.cancel_tab_config_params_modal(ctx);
}
TabConfigParamsModalEvent::PickNewRepo { param_index } => {
ctx.dispatch_typed_action_deferred(WorkspaceAction::OpenTabConfigRepoPicker {
param_index: *param_index,
});
}
}
}
/// Finishes the tab replacement that was deferred while the params modal
/// was open (worktree flow from the session config modal).
fn complete_pending_session_config_replacement(&mut self, ctx: &mut ViewContext<Self>) {
let Some(pending) = self.pending_session_config_replacement.take() else {
return;
};
self.remove_tab_by_pane_group_id(pending.old_pane_group_id, ctx);
}
/// Removes the tab whose pane group matches `pane_group_id`, if it exists
/// and there is more than one tab.
fn remove_tab_by_pane_group_id(
&mut self,
pane_group_id: EntityId,
ctx: &mut ViewContext<Self>,
) {
if self.tabs.len() <= 1 {
return;
}
if let Some(index) = self
.tabs
.iter()
.position(|tab| tab.pane_group.id() == pane_group_id)
{
self.remove_tab(index, false, true, ctx);
}
}
/// Opens a native folder picker and, when the user selects a folder, upserts it
/// into `PersistedWorkspace` and notifies the modal's repo picker at `param_index`.
fn open_repo_picker_for_tab_config_modal(
&mut self,
param_index: usize,
ctx: &mut ViewContext<Self>,
) {
let modal_view = self.tab_config_params_modal.view.clone();
ctx.open_file_picker(
move |result, ctx| {
let Ok(paths) = result else { return };
let Some(path) = paths.into_iter().next() else {
return;
};
// Register the chosen directory as a workspace so it appears in
// PersistedWorkspace (which is the data source for the repo picker
// and also triggers codebase indexing / project rules scanning).
let path_buf: PathBuf = path.clone().into();
PersistedWorkspace::handle(ctx).update(ctx, |persisted, ctx| {
persisted.user_added_workspace(path_buf.clone(), ctx);
});
// Refresh the repo picker and pre-select the new path.
modal_view.update(ctx, |modal, ctx| {
modal.body().update(ctx, |body, ctx| {
body.on_new_repo_selected(path_buf, param_index, ctx);
});
});
},
galaxyui::platform::FilePickerConfiguration::new().folders_only(),
);
}
fn close_tab_config_params_modal(&mut self, ctx: &mut ViewContext<Self>) {
self.current_workspace_state.is_tab_config_params_modal_open = false;
self.tab_config_params_modal.close();
self.tab_config_params_modal.view.update(ctx, |modal, ctx| {
modal.body().update(ctx, |body, ctx| {
body.on_close(ctx);
});
});
ctx.notify();
}
fn handle_new_worktree_modal_event(&mut self, event: &ModalEvent, ctx: &mut ViewContext<Self>) {
match event {
ModalEvent::Close => self.close_new_worktree_modal(ctx),
}
}
fn handle_new_worktree_modal_body_event(
&mut self,
event: &NewWorktreeModalEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
NewWorktreeModalEvent::Close => self.close_new_worktree_modal(ctx),
NewWorktreeModalEvent::Submit {
repo,
branch,
worktree_branch_name,
} => {
self.handle_new_worktree_submit(repo, branch, worktree_branch_name.as_deref(), ctx);
self.close_new_worktree_modal(ctx);
}
NewWorktreeModalEvent::PickNewRepo => {
ctx.dispatch_typed_action_deferred(WorkspaceAction::OpenNewWorktreeRepoPicker);
}
}
}
fn close_new_worktree_modal(&mut self, ctx: &mut ViewContext<Self>) {
self.current_workspace_state.is_new_worktree_modal_open = false;
self.new_worktree_modal.close();
self.new_worktree_modal.view.update(ctx, |modal, ctx| {
modal.body().update(ctx, |body, ctx| {
body.on_close(ctx);
});
});
ctx.notify();
}
/// Checks whether the tab config references the special-cased
/// `autogenerated_branch_name` template var. If so, fetches existing
/// branches and generates a unique themed name.
fn maybe_generate_worktree_name(
&self,
config: &crate::tab_configs::TabConfig,
) -> Option<String> {
if !config.uses_autogenerated_branch_name() {
return None;
}
let pane = config
.panes
.iter()
.find(|pane| pane.directory.is_some())
.or_else(|| config.panes.first())?;
let repo_path = pane.directory.as_deref().map(Path::new);
let branches = repo_path
.map(crate::util::git::list_local_branches_sync)
.unwrap_or_default();
let branch_refs: HashSet<&str> = branches.iter().map(|s| s.as_str()).collect();
Some(galaxy_util::worktree_names::generate_worktree_branch_name(
&branch_refs,
))
}
/// Generates a worktree tab config TOML, writes it to `~/.warp/tab_configs/`,
/// and opens the resulting config as a new tab.
///
/// When `worktree_branch_name` is `None` (autogenerate), the TOML stores
/// commands with `{autogenerated_branch_name}` template variables that get
/// substituted with a fresh name on every open.
/// When `Some(name)` (manual naming), the commands are baked in and a
/// `worktree_branch_name` param is added so re-opens show the params modal.
#[cfg(feature = "local_fs")]
fn handle_new_worktree_submit(
&mut self,
repo: &str,
base_branch: &str,
worktree_branch_name: Option<&str>,
ctx: &mut ViewContext<Self>,
) {
let repo_display_name = Path::new(repo)
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| repo.to_string());
let config_name = match worktree_branch_name {
Some(name) if !name.is_empty() => {
format!("New worktree: {repo_display_name}, {name}")
}
_ if !base_branch.is_empty() => {
format!("New worktree: {repo_display_name}, {base_branch}")
}
_ => format!("New worktree: {repo_display_name}"),
};
let filename_hint = if let Some(name) = worktree_branch_name {
name.to_string()
} else {
let branches = crate::util::git::list_local_branches_sync(Path::new(repo));
let branch_refs: HashSet<&str> = branches.iter().map(|s| s.as_str()).collect();
galaxy_util::worktree_names::generate_worktree_branch_name(&branch_refs)
};
let toml_content = crate::tab_configs::build_worktree_config_toml(
&config_name,
repo,
base_branch,
worktree_branch_name,
);
let dir = tab_configs_dir();
if let Err(e) = std::fs::create_dir_all(&dir) {
log::warn!("Failed to create tab_configs dir: {e:?}");
return;
}
let path = find_unused_worktree_config_path(&dir, &filename_hint);
if let Err(e) = std::fs::write(&path, &toml_content) {
log::warn!("Failed to write worktree tab config: {e:?}");
return;
}
match toml::from_str::<crate::tab_configs::TabConfig>(&toml_content) {
Ok(tab_config) => {
let naming_mode = if worktree_branch_name.is_some() {
WorktreeBranchNamingMode::Manual
} else {
WorktreeBranchNamingMode::Auto
};
if let Some(name) = worktree_branch_name {
// First open with manual name — bypass the params modal.
let mut param_values = HashMap::new();
param_values.insert("worktree_branch_name".to_string(), name.to_string());
self.open_tab_config_with_params(tab_config, param_values, None, ctx);
} else {
// Autogenerate — open with the name we just generated.
let param_values = tab_config.default_param_values();
self.open_tab_config_with_params(
tab_config,
param_values,
Some(&filename_hint),
ctx,
);
}
send_telemetry_from_ctx!(
TabConfigsTelemetryEvent::NewWorktreeConfigOpened {
source: NewWorktreeConfigOpenSource::NewWorktreeModal,
naming_mode,
},
ctx
);
}
Err(e) => {
log::warn!("Failed to parse generated worktree config: {e:?}");
}
}
}
#[cfg(not(feature = "local_fs"))]
fn handle_new_worktree_submit(
&mut self,
_repo: &str,
_base_branch: &str,
_worktree_branch_name: Option<&str>,
_ctx: &mut ViewContext<Self>,
) {
}
fn open_repo_picker_for_new_worktree_modal(&mut self, ctx: &mut ViewContext<Self>) {
let modal_view = self.new_worktree_modal.view.clone();
ctx.open_file_picker(
move |result, ctx| {
let Ok(paths) = result else { return };
let Some(path) = paths.into_iter().next() else {
return;
};
let path_buf: PathBuf = path.clone().into();
PersistedWorkspace::handle(ctx).update(ctx, |persisted, ctx| {
persisted.user_added_workspace(path_buf.clone(), ctx);
});
modal_view.update(ctx, |modal, ctx| {
modal.body().update(ctx, |body, ctx| {
body.on_new_repo_selected(path_buf, ctx);
});
});
},
galaxyui::platform::FilePickerConfiguration::new().folders_only(),
);
}
/// Opens a worktree in the given repo using the default worktree tab config,
/// saving the materialized config to `~/.warp-core/tab_configs/` first.
/// The branch name is auto-generated.
#[cfg(feature = "local_fs")]
fn open_worktree_in_repo(&mut self, repo_path: String, ctx: &mut ViewContext<Self>) {
log::info!("open_worktree_in_repo requested: repo_path={repo_path:?}");
let config_path = ensure_default_worktree_config();
log::info!("Reading default worktree config from {config_path:?}");
let template_toml = match std::fs::read_to_string(&config_path) {
Ok(s) => s,
Err(e) => {
log::warn!("Failed to read default worktree config from {config_path:?}: {e:?}");
return;
}
};
let branches = crate::util::git::list_local_branches_sync(Path::new(&repo_path));
let branch_refs: HashSet<&str> = branches.iter().map(|s| s.as_str()).collect();
let branch_name = galaxy_util::worktree_names::generate_worktree_branch_name(&branch_refs);
let repo_display_name = Path::new(&repo_path)
.file_name()
.map(|name| name.to_string_lossy().to_string())
.unwrap_or_else(|| repo_path.clone());
let config_name = format!("Worktree: {repo_display_name}");
// Use the user's default session mode to decide pane type.
let pane_type = if AISettings::as_ref(ctx).is_any_ai_enabled(ctx)
&& AISettings::as_ref(ctx).default_session_mode(ctx) == DefaultSessionMode::Agent
{
"agent"
} else {
"terminal"
};
log::info!(
"Materializing default worktree config: repo_path={repo_path:?}, branch_name={branch_name:?}, pane_type={pane_type}"
);
let (toml_content, tab_config) = match materialize_default_worktree_config(
&template_toml,
&config_name,
&repo_path,
pane_type,
) {
Ok(materialized) => materialized,
Err(e) => {
log::warn!(
"Failed to materialize default worktree config from {config_path:?}: {e}"
);
return;
}
};
let dir = tab_configs_dir();
if let Err(e) = std::fs::create_dir_all(&dir) {
log::warn!("Failed to create tab_configs dir: {e:?}");
return;
}
let saved_config_path =
find_unused_toml_path(&dir, &sanitize_toml_base_name(&repo_display_name));
if let Err(e) = std::fs::write(&saved_config_path, &toml_content) {
log::warn!("Failed to write worktree tab config to {saved_config_path:?}: {e:?}");
return;
}
log::info!(
"Saved default worktree config to {saved_config_path:?}: config_name={:?}",
tab_config.name
);
let param_values = tab_config.default_param_values();
log::info!("Opening tab from saved worktree config");
self.open_tab_config_with_params(tab_config, param_values, Some(&branch_name), ctx);
send_telemetry_from_ctx!(
TabConfigsTelemetryEvent::NewWorktreeConfigOpened {
source: NewWorktreeConfigOpenSource::Submenu,
naming_mode: WorktreeBranchNamingMode::Auto,
},
ctx
);
}
#[cfg(not(feature = "local_fs"))]
fn open_worktree_in_repo(&mut self, _repo_path: String, _ctx: &mut ViewContext<Self>) {}
/// Opens a native folder picker to add a new repo to PersistedWorkspace,
/// triggered from the "+ Add new repo..." item in the New worktree config submenu.
fn open_folder_picker_for_worktree_submenu(&mut self, ctx: &mut ViewContext<Self>) {
ctx.open_file_picker(
move |result, ctx| {
let Ok(paths) = result else { return };
let Some(path) = paths.into_iter().next() else {
return;
};
let path_buf: PathBuf = path.into();
PersistedWorkspace::handle(ctx).update(ctx, |persisted, ctx| {
persisted.user_added_workspace(path_buf, ctx);
});
},
galaxyui::platform::FilePickerConfiguration::new().folders_only(),
);
}
fn handle_enable_auto_reload_modal_event(
&mut self,
event: &EnableAutoReloadModalEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
EnableAutoReloadModalEvent::Close => {
self.current_workspace_state
.is_enable_auto_reload_modal_open = false;
ctx.notify();
}
EnableAutoReloadModalEvent::ShowToast { message, flavor } => {
self.toast_stack.update(ctx, |toast_stack, ctx| {
toast_stack
.add_ephemeral_toast(DismissibleToast::new(message.clone(), *flavor), ctx);
});
}
}
}
fn handle_welcome_tips_event(&mut self, event: &TipsEvent, ctx: &mut ViewContext<Self>) {
match event {
TipsEvent::Close => {
self.welcome_tips_view_state.close_popup();
ctx.notify();
}
TipsEvent::TipsDismissed => {
self.tips_completed.update(ctx, |tips_completed, ctx| {
skip_tips_and_write_to_user_defaults(tips_completed, ctx);
ctx.notify();
});
self.welcome_tips_view_state = WelcomeTipsViewState::Unavailable;
ctx.notify();
}
}
}
fn handle_workflow_modal_event(
&mut self,
event: &WorkflowModalEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
WorkflowModalEvent::Close => {
self.current_workspace_state.is_workflow_modal_open = false;
ctx.notify();
}
WorkflowModalEvent::AiAssistError(message) => {
self.toast_stack.update(ctx, |view, ctx| {
let new_toast = DismissibleToast::error(message.clone());
view.add_ephemeral_toast(new_toast, ctx);
});
}
WorkflowModalEvent::UpdatedWorkflow(workflow_id) => {
// If saved workflow id matches the one that is currently displayed, then refresh workflow info box + input
self.maybe_refresh_workflow_info_box_and_input(workflow_id, ctx);
}
WorkflowModalEvent::ViewInWarpDrive(id) => {
self.view_in_and_focus_warp_drive(*id, ctx);
}
WorkflowModalEvent::AiAssistUpgradeError(team_uid, user_id) => {
let upgrade_link = team_uid
.map(UserWorkspaces::upgrade_link_for_team)
.unwrap_or_else(|| UserWorkspaces::upgrade_link(*user_id));
self.toast_stack.update(ctx, |view, ctx| {
let new_toast =
DismissibleToast::error("Looks like you're out of AI credits.".into())
.with_link(
ToastLink::new("Upgrade for more credits.".into())
.with_href(upgrade_link),
);
view.add_ephemeral_toast(new_toast, ctx);
});
}
}
}
fn is_input_box_visible(&self, app: &AppContext) -> bool {
if let (Some(terminal_model), Some(terminal_view)) = (
self.get_active_session_terminal_model(app),
self.active_tab_pane_group()
.as_ref(app)
.active_session_view(app),
) {
terminal_view.read(app, |view, ctx| {
view.is_input_box_visible(&terminal_model.lock(), ctx)
})
} else {
false
}
}
fn maybe_refresh_workflow_info_box_and_input(
&mut self,
workflow_id: &SyncId,
ctx: &mut ViewContext<Self>,
) {
let Some(terminal_view_handle) = self
.active_tab_pane_group()
.as_ref(ctx)
.active_session_view(ctx)
else {
return;
};
let terminal_input =
terminal_view_handle.read(ctx, |terminal_view, _| terminal_view.input().clone());
if self.is_input_box_visible(ctx) {
let open_workflow_id = terminal_input.update(ctx, |input, _| {
// We only care to refresh if workflow info box is visible
if input.is_workflows_info_box_open() {
input.workflows_info_box_open_workflow_cloud_id()
} else {
None
}
});
// Check if workflow displayed in info box matches the one that was just updated.
if open_workflow_id == Some(*workflow_id) {
// Fetch latest version of workflow and update info box with fresh contents
let cloud_model = CloudModel::as_ref(ctx);
if let Some(workflow) = cloud_model.get_workflow(workflow_id) {
// Proc same behavior as DrivePanelEvent::RunWorkflow
self.run_cloud_workflow_in_active_input(
workflow.clone(),
WorkflowSelectionSource::WarpDrive,
TerminalSessionFallbackBehavior::default(),
ctx,
);
}
}
}
}
fn handle_require_login_modal_event(
&mut self,
event: &AuthViewEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
AuthViewEvent::Close => {
self.current_workspace_state.is_require_login_modal_open = false;
ctx.notify();
}
}
}
fn handle_theme_creator_modal_event(
&mut self,
event: &ThemeCreatorModalEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
ThemeCreatorModalEvent::Close => {
self.current_workspace_state.is_theme_creator_modal_open = false;
ctx.notify();
}
ThemeCreatorModalEvent::SetCustomTheme { theme } => {
self.theme_chooser_view
.update(ctx, |theme_chooser_view, ctx| {
theme_chooser_view.reload_and_set_custom_theme(theme.clone(), ctx);
});
}
ThemeCreatorModalEvent::ShowErrorToast { message } => {
self.toast_stack.update(ctx, |view, ctx| {
let new_toast = DismissibleToast::error(message.clone());
view.add_ephemeral_toast(new_toast, ctx);
});
}
}
}
fn handle_theme_deletion_modal_event(
&mut self,
event: &ThemeDeletionModalEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
ThemeDeletionModalEvent::Close => {
self.current_workspace_state.is_theme_deletion_modal_open = false;
ctx.notify();
}
ThemeDeletionModalEvent::ShowErrorToast { message } => {
self.toast_stack.update(ctx, |view, ctx| {
let new_toast = DismissibleToast::error(message.clone());
view.add_ephemeral_toast(new_toast, ctx);
});
}
ThemeDeletionModalEvent::DeleteCurrentTheme => {
self.theme_chooser_view
.update(ctx, |theme_chooser_view, ctx| {
// Reset theme to Dark if we are deleting the current theme
theme_chooser_view.select_and_save_theme(&ThemeKind::Dark, ctx);
});
}
}
}
/// Returns the pane group with the matching EntityId, or None if it doesn't exist.
fn get_pane_group_view_with_id(&self, id: EntityId) -> Option<&ViewHandle<PaneGroup>> {
self.tab_views().find(|view| view.id() == id)
}
// The workspace manages the close confirmation dialog, so it may need to close a pane after the user confirms in the dialog.
// The flow is:
// - User closes pane in pane group, which emits event to workspace
// - Workspace shows confirmation dialog, and calls back into pane group to close pane here if user confirms
fn close_pane(
&mut self,
pane_group_id: EntityId,
pane_id: PaneId,
ctx: &mut ViewContext<Self>,
) {
let Some(pane_group_view) = self.get_pane_group_view_with_id(pane_group_id) else {
log::error!("Could not close pane because pane group doesn't exist");
return;
};
pane_group_view.update(ctx, |pane_group, ctx| {
pane_group.close_pane(pane_id, ctx);
});
}
fn handle_close_session_confirmation_dialog_event(
&mut self,
event: &CloseSessionConfirmationEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
CloseSessionConfirmationEvent::Cancel => {
self.current_workspace_state
.is_close_session_confirmation_dialog_open = false;
ctx.notify();
}
CloseSessionConfirmationEvent::CloseSession {
dont_show_again,
open_confirmation_source,
} => {
if *dont_show_again {
if let Err(e) = SessionSettings::handle(ctx).update(ctx, |settings, ctx| {
settings.should_confirm_close_session.set_value(false, ctx)
}) {
log::error!(
"Failed to set should_confirm_close_session setting to false: {e}"
);
};
}
match *open_confirmation_source {
OpenDialogSource::CloseTab { tab_index } => {
self.remove_tab(tab_index, true, true, ctx);
}
OpenDialogSource::ClosePane {
pane_group_id,
pane_id,
} => {
self.close_pane(pane_group_id, pane_id, ctx);
}
OpenDialogSource::CloseTabsDirection {
tab_index,
direction,
} => {
self.close_tabs_direction(tab_index, direction, true, ctx);
}
OpenDialogSource::CloseOtherTabs { tab_index } => {
self.close_other_tabs(tab_index, true, ctx);
}
}
self.current_workspace_state
.is_close_session_confirmation_dialog_open = false;
ctx.notify();
}
}
}
fn handle_rewind_confirmation_dialog_event(
&mut self,
event: &RewindConfirmationEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
RewindConfirmationEvent::Cancel => {
self.current_workspace_state
.is_rewind_confirmation_dialog_open = false;
self.focus_active_tab(ctx);
ctx.notify();
}
RewindConfirmationEvent::Confirm { rewind_source } => {
self.current_workspace_state
.is_rewind_confirmation_dialog_open = false;
self.handle_action(
&WorkspaceAction::ExecuteRewindAIConversation {
ai_block_view_id: rewind_source.ai_block_view_id,
exchange_id: rewind_source.exchange_id,
conversation_id: rewind_source.conversation_id,
},
ctx,
);
self.focus_active_tab(ctx);
ctx.notify();
}
}
}
fn handle_delete_conversation_confirmation_dialog_event(
&mut self,
event: &DeleteConversationConfirmationEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
DeleteConversationConfirmationEvent::Cancel => {
self.current_workspace_state
.is_delete_conversation_confirmation_dialog_open = false;
ctx.focus(&self.left_panel_view);
ctx.notify();
}
DeleteConversationConfirmationEvent::Confirm { source } => {
self.current_workspace_state
.is_delete_conversation_confirmation_dialog_open = false;
self.handle_action(
&WorkspaceAction::ExecuteDeleteConversation {
conversation_id: source.conversation_id,
terminal_view_id: source.terminal_view_id,
},
ctx,
);
ctx.focus(&self.left_panel_view);
ctx.notify();
}
}
}
pub fn handle_network_status_event(
&mut self,
_handle: ModelHandle<NetworkStatus>,
_event: &NetworkStatusEvent,
ctx: &mut ViewContext<Self>,
) {
ctx.notify();
}
fn handle_auth_manager_event(
&mut self,
_handle: ModelHandle<AuthManager>,
event: &AuthManagerEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
AuthManagerEvent::AttemptedLoginGatedFeature { auth_view_variant } => {
self.open_require_login_modal(*auth_view_variant, ctx)
}
AuthManagerEvent::LoginOverrideDetected(interrupted_auth_payload) => {
self.open_auth_override_warning_modal(interrupted_auth_payload.clone(), ctx);
}
AuthManagerEvent::AuthComplete => {
// Only show the telemetry banner if the user is an existing user. The new user flow
// for this is handled in the onboarding flow.
if self.auth_state.is_onboarded().unwrap_or_default() {
// Need to check this AFTER we fetch any billing metadata associated with the team,
// to make sure we don't show the banner if the user is an enterprise user.
self.check_and_trigger_telemetry_banner_for_existing_users(ctx);
}
}
_ => {
ctx.notify();
}
}
}
pub fn toggle_block_snackbar(&mut self, ctx: &mut ViewContext<Self>) {
BlockListSettings::handle(ctx).update(ctx, |blocklist_settings, ctx| {
report_if_error!(blocklist_settings
.snackbar_enabled
.toggle_and_save_value(ctx));
});
}
pub fn toggle_error_underlining(&mut self, ctx: &mut ViewContext<Self>) {
InputSettings::handle(ctx).update(ctx, |input_settings, ctx| {
report_if_error!(input_settings.error_underlining.toggle_and_save_value(ctx));
});
}
pub fn toggle_syntax_highlighting(&mut self, ctx: &mut ViewContext<Self>) {
InputSettings::handle(ctx).update(ctx, |input_settings, ctx| {
report_if_error!(input_settings
.syntax_highlighting
.toggle_and_save_value(ctx));
});
}
pub fn change_cursor(&mut self, cursor_shape: Cursor, ctx: &mut ViewContext<Self>) {
ctx.set_cursor_shape(cursor_shape);
ctx.notify();
}
pub fn set_a11y_verbosity(
&mut self,
verbosity: AccessibilityVerbosity,
ctx: &mut ViewContext<Self>,
) {
AccessibilitySettings::handle(ctx).update(ctx, |accessibility_settings, ctx| {
report_if_error!(accessibility_settings
.a11y_verbosity
.set_value(verbosity, ctx));
});
}
pub fn snapshot(
&self,
window_id: WindowId,
quake_mode: bool,
app: &AppContext,
) -> WindowSnapshot {
let window_bounds = app.window_bounds(&window_id);
let window_fullscreen_state = app
.windows()
.platform_window(window_id)
.map(|window| window.fullscreen_state())
.unwrap_or_default();
let active_tab_index = self.active_tab_index();
let tabs = self
.tab_views()
.enumerate()
.map(|(tab_index, pane_group_view)| {
let resizable_data = ResizableData::handle(app);
let modal_sizes = resizable_data.as_ref(app).get_all_handles(window_id);
let left_panel_width = modal_sizes.map(|ms| {
ms.left_panel_width
.lock()
.expect("should be able to lock left panel handle")
.size()
});
let right_panel_width = modal_sizes.map(|ms| {
ms.right_panel_width
.lock()
.expect("should be able to lock right panel handle")
.size()
});
let pane_group = pane_group_view.as_ref(app);
let root = pane_group.snapshot(app);
let left_panel =
self.compute_left_panel_snapshot(pane_group_view, left_panel_width, app);
let right_panel =
self.compute_right_panel_snapshot(pane_group_view, right_panel_width, app);
TabSnapshot {
root,
custom_title: pane_group.custom_title(app),
default_directory_color: self
.tabs
.get(tab_index)
.and_then(|tab| tab.default_directory_color),
selected_color: self
.tabs
.get(tab_index)
.map_or(SelectedTabColor::Unset, |tab| tab.selected_color),
left_panel,
right_panel,
}
})
.filter(|tab| {
// Filter out any tab that contains a single, read-only session.
!matches!(
tab.root,
PaneNodeSnapshot::Leaf(LeafSnapshot {
contents: LeafContents::Terminal(TerminalPaneSnapshot {
is_read_only: true,
..
}),
..
})
)
})
.collect();
let resizable_data = ResizableData::handle(app);
let modal_sizes = resizable_data.as_ref(app).get_all_handles(window_id);
// Reads the current width of the universal search modal, to store with the window snapshot
let universal_search_width = modal_sizes.map(|ms| {
ms.universal_search_width
.lock()
.expect("should be able to lock universal search resizable state handle")
.size()
});
let warp_ai_width = modal_sizes.map(|ms| {
ms.warp_ai_width
.lock()
.expect("should be able to lock warp_ai resizable state handle")
.size()
});
let voltron_width = modal_sizes.map(|ms| {
ms.voltron_width
.lock()
.expect("should be able to lock voltron resizable state handle")
.size()
});
let warp_drive_index_width = modal_sizes.map(|ms| {
ms.warp_drive_index_width
.lock()
.expect("should be able to lock warp drive resizable state handle")
.size()
});
let left_panel_width = modal_sizes.map(|ms| {
ms.left_panel_width
.lock()
.map(|guard| guard.size())
.unwrap_or(DEFAULT_LEFT_PANEL_WIDTH)
});
let right_panel_width = modal_sizes.map(|ms| {
ms.right_panel_width
.lock()
.map(|guard| guard.size())
.unwrap_or(DEFAULT_RIGHT_PANEL_WIDTH)
});
let agent_management_filters = Some(
self.agent_management_view
.read(app, |view, _| view.get_filters()),
);
WindowSnapshot {
tabs,
active_tab_index,
bounds: window_bounds,
fullscreen_state: window_fullscreen_state,
quake_mode,
universal_search_width,
warp_ai_width,
voltron_width,
warp_drive_index_width,
left_panel_open: self.left_panel_open,
vertical_tabs_panel_open: self.vertical_tabs_panel_open,
left_panel_width,
right_panel_width,
agent_management_filters,
}
}
fn compute_left_panel_snapshot(
&self,
pane_group: &ViewHandle<PaneGroup>,
left_panel_width: Option<f32>,
app: &AppContext,
) -> Option<LeftPanelSnapshot> {
let pane_group_ref = pane_group.as_ref(app);
if !pane_group_ref.left_panel_open {
return None;
}
let pane_group_id = pane_group.id();
self.left_panel_view.read(app, |lp, _| {
Some(LeftPanelSnapshot {
left_panel_displayed_tab: lp.active_view().into(),
pane_group_id: pane_group_id.to_string(),
width: left_panel_width.unwrap_or(DEFAULT_LEFT_PANEL_WIDTH) as usize,
})
})
}
fn compute_right_panel_snapshot(
&self,
pane_group: &ViewHandle<PaneGroup>,
right_panel_width: Option<f32>,
app: &AppContext,
) -> Option<RightPanelSnapshot> {
let pane_group_ref = pane_group.as_ref(app);
if !pane_group_ref.right_panel_open {
return None;
}
let pane_group_id = pane_group.id();
let is_maximized = pane_group_ref.is_right_panel_maximized;
Some(RightPanelSnapshot {
pane_group_id: pane_group_id.to_string(),
width: right_panel_width.unwrap_or(DEFAULT_RIGHT_PANEL_WIDTH) as usize,
is_maximized,
})
}
pub fn open_launch_config_save_modal(&mut self, ctx: &mut ViewContext<Self>) {
self.close_palette(true, None, ctx); // close palettes if any are open
self.launch_config_save_modal.open();
self.current_workspace_state
.is_launch_config_save_modal_open = true;
self.launch_config_save_modal.view.update(ctx, |view, ctx| {
view.set_snapshot_source(ctx);
view.reset_editor(ctx); // placeholder and clear editor
ctx.notify();
});
self.tips_completed.update(ctx, |tips_completed, ctx| {
mark_feature_used_and_write_to_user_defaults(
Tip::Action(TipAction::SaveNewLaunchConfig),
tips_completed,
ctx,
);
ctx.notify();
});
ctx.focus(&self.launch_config_save_modal.view);
ctx.notify();
}
pub fn cycle_prev_session(&mut self, ctx: &mut ViewContext<Self>) {
self.cycle_session(SessionCycleDirection::Previous, ctx);
}
pub fn cycle_next_session(&mut self, ctx: &mut ViewContext<Self>) {
self.cycle_session(SessionCycleDirection::Next, ctx);
}
fn cycle_session(&mut self, direction: SessionCycleDirection, ctx: &mut ViewContext<Self>) {
let keys_settings = KeysSettings::as_ref(ctx);
match *keys_settings.ctrl_tab_behavior {
CtrlTabBehavior::ActivatePrevNextTab => match direction {
SessionCycleDirection::Next => {
self.activate_next_tab(ctx);
}
SessionCycleDirection::Previous => {
self.activate_prev_tab(ctx);
}
},
CtrlTabBehavior::CycleMostRecentSession => {
self.current_workspace_state.is_palette_open = false;
if !self.current_workspace_state.is_ctrl_tab_palette_open {
self.open_palette_action(
PaletteMode::Navigation,
PaletteSource::CtrlTab {
shift_pressed_initially: matches!(
direction,
SessionCycleDirection::Previous
),
},
None,
ctx,
);
}
self.ctrl_tab_palette
.update(ctx, |palette, ctx| match direction {
SessionCycleDirection::Next => {
palette.select_next_item(ctx);
}
SessionCycleDirection::Previous => {
palette.select_prev_item(ctx);
}
});
ctx.notify();
}
}
}
pub fn activate_prev_tab(&mut self, ctx: &mut ViewContext<Self>) {
let index = if self.vertical_tabs_panel.search_query.is_empty() {
if self.active_tab_index > 0 {
self.active_tab_index - 1
} else {
self.tabs.len() - 1
}
} else {
let matching = self.vertical_tabs_panel.matching_tab_indices(
&self.tabs,
self.active_tab_index,
ctx,
);
matching
.iter()
.rev()
.find(|&&i| i < self.active_tab_index)
.or_else(|| matching.last())
.copied()
.unwrap_or(self.active_tab_index)
};
self.activate_tab(index, ctx);
}
pub fn activate_next_tab(&mut self, ctx: &mut ViewContext<Self>) {
let index = if self.vertical_tabs_panel.search_query.is_empty() {
if self.active_tab_index + 1 < self.tabs.len() {
self.active_tab_index + 1
} else {
0
}
} else {
let matching = self.vertical_tabs_panel.matching_tab_indices(
&self.tabs,
self.active_tab_index,
ctx,
);
matching
.iter()
.find(|&&i| i > self.active_tab_index)
.or_else(|| matching.first())
.copied()
.unwrap_or(self.active_tab_index)
};
self.activate_tab(index, ctx);
}
pub fn activate_last_tab(&mut self, ctx: &mut ViewContext<Self>) {
if self.tabs.len() > 1 {
let target_index = self.tabs.len() - 1;
self.activate_tab(target_index, ctx);
}
}
fn remove_tab(
&mut self,
index: usize,
add_to_undo_stack: bool,
detach_panes_for_close: bool,
ctx: &mut ViewContext<Self>,
) {
let Some(tab_data) = self.tabs.get(index) else {
debug_assert!(false, "Tried to remove a tab with an invalid index");
return;
};
// If the vertical-tabs detail sidecar is anchored to this tab's pane group, clear it.
// Otherwise it will try to position itself against a pane row that is about to disappear
// (either because the tab is being removed from `self.tabs`, or because we're about to
// close the window for the last tab).
self.vertical_tabs_panel
.clear_detail_sidecar_if_for_pane_group(tab_data.pane_group.id());
// If this is the last tab, close the window instead of actually removing
// the tab.
if self.tabs.len() == 1 {
if ContextFlag::CloseWindow.is_enabled() {
ctx.close_window();
}
return;
}
if detach_panes_for_close {
let working_directories_model = self.working_directories_model.clone();
tab_data.pane_group.update(ctx, |pane_group, ctx| {
pane_group.for_all_terminal_panes(
|terminal_view, ctx| {
if terminal_view
.model
.lock()
.block_list()
.active_block()
.is_active_and_long_running()
{
terminal_view.shutdown_pty(ctx);
}
},
ctx,
);
pane_group.detach_panes_for_close(&working_directories_model, ctx);
});
}
let tab_data = self.tabs.remove(index);
if add_to_undo_stack {
let handle = ctx.handle();
UndoCloseStack::handle(ctx).update(ctx, |stack, ctx| {
log::info!("storing data for closed tab");
stack.handle_tab_closed(handle, index, tab_data, ctx);
});
}
match index.cmp(&self.active_tab_index) {
Ordering::Equal => {
// If there's a previous tab, activate it. Otherwise, keep the active
// tab at index 0.
self.activate_tab_internal(index.saturating_sub(1), ctx);
}
Ordering::Less => {
// If we are closing a tab before the active tab we need to adjust
// the active tab index.
self.active_tab_index -= 1;
}
_ => {}
}
ctx.dispatch_global_action("workspace:save_app", ());
ctx.notify();
}
pub fn remove_tab_without_undo(&mut self, index: usize, ctx: &mut ViewContext<Self>) {
self.remove_tab(index, false, false, ctx);
}
/// Adopts a transferred PaneGroup into the placeholder tab created during window transfer.
/// This replaces the placeholder tab's PaneGroup with the actual transferred one.
pub fn adopt_transferred_pane_group(
&mut self,
new_pane_group: ViewHandle<PaneGroup>,
ctx: &mut ViewContext<Self>,
) {
if !self.pending_pane_group_transfer {
debug_assert!(
false,
"adopt_transferred_pane_group called without pending transfer"
);
return;
}
if self.tabs.is_empty() {
debug_assert!(false, "adopt_transferred_pane_group called with no tabs");
return;
}
let Some(placeholder_tab) = self.tabs.last_mut() else {
debug_assert!(
false,
"adopt_transferred_pane_group missing placeholder tab"
);
return;
};
let placeholder_pane_group =
std::mem::replace(&mut placeholder_tab.pane_group, new_pane_group);
let working_directories_model = self.working_directories_model.clone();
placeholder_pane_group.update(ctx, |pg, ctx| {
pg.detach_panes_for_close(&working_directories_model, ctx);
});
self.pending_pane_group_transfer = false;
ctx.dispatch_global_action("workspace:save_app", ());
ctx.notify();
}
fn should_confirm_close_session(&self, ctx: &mut ViewContext<Self>) -> bool {
// If we're closing the only remaining tab, we're actually going to close the window.
// We don't need a user confirmation here because there's already another one on window close.
if self.tab_count() == 1 {
return false;
}
// TODO: remove session sharing flag check when long-running commands are included
FeatureFlag::CreatingSharedSessions.is_enabled()
&& ContextFlag::CreateSharedSession.is_enabled()
&& *SessionSettings::as_ref(ctx).should_confirm_close_session
}
/// Checks if the provided tab indices need to be confirmed before closing, unless skip_confirmation is true.
/// If none of them need confirmation (or the confirm setting is turned off), we close all the provided tabs.
/// Returns true iff all of the tabs were closed.
fn close_tabs(
&mut self,
tab_indices: impl Iterator<Item = usize>,
dialog_source: OpenDialogSource,
skip_confirmation: bool,
add_to_undo_stack: bool,
ctx: &mut ViewContext<Self>,
) -> bool {
let tab_indices_vec = tab_indices.collect_vec();
// Check if there are any tabs that can't be closed without confirmation
if !skip_confirmation && self.should_confirm_close_session(ctx) {
for i in tab_indices_vec.iter() {
let is_tab_shared = self
.get_pane_group_view(*i)
.is_some_and(|view| view.as_ref(ctx).is_terminal_pane_being_shared(ctx));
if is_tab_shared {
self.show_close_session_confirmation_dialog(dialog_source, ctx);
return false;
}
}
}
if !skip_confirmation {
let tabs = tab_indices_vec
.iter()
.filter_map(|i| self.get_pane_group_view(*i))
.map(|tab| tab.downgrade())
.collect_vec();
let summary = UnsavedStateSummary::for_tabs(tabs, ctx);
if summary.should_display_warning(ctx) {
// The quit-warning dialog uses app-scoped callbacks (ironically, because that's
// what Self::show_native_modal expects). That means we need a handle to the
// current workspace here.
let confirm_self = ctx.handle();
let navigate_self = ctx.handle();
let confirm_tabs = tab_indices_vec.clone();
let dialog = summary
.dialog()
.on_confirm(move |ctx| {
if let Some(workspace) = confirm_self.upgrade(ctx) {
workspace.update(ctx, |workspace, ctx| {
workspace.close_tabs(
confirm_tabs.into_iter(),
dialog_source,
true,
add_to_undo_stack,
ctx,
);
});
}
})
.on_cancel(|_ctx| { /* No action needed besides dismissing the dialog. */ })
.on_show_processes(move |ctx| {
if let Some(workspace) = navigate_self.upgrade(ctx) {
workspace.update(ctx, |workspace, ctx| {
// TODO(ben): Ideally, this would filter to the relevant tabs.
workspace.open_palette_action(
PaletteMode::Navigation,
PaletteSource::QuitModal,
Some("running"),
ctx,
);
})
}
})
.build();
send_telemetry_from_ctx!(
TelemetryEvent::QuitModalShown {
running_processes: summary.total_long_running_commands as u32,
shared_sessions: summary.shared_sessions as u32,
modal_for: CloseTarget::Tab,
},
ctx
);
if cfg!(all(not(target_family = "wasm"), target_os = "macos")) {
AppContext::show_native_platform_modal(ctx, dialog);
return false;
} else if cfg!(all(
not(target_family = "wasm"),
any(target_os = "linux", windows)
)) {
self.show_native_modal(dialog, ctx);
return false;
}
}
}
// If we are renaming a tab, cancel that. Closing tabs causes the renamed tab index
// to fall out of sync. This can cause inconsistencies.
self.cancel_tab_rename(ctx);
// Remove the tabs in reverse order to avoid indexing OOB.
for i in tab_indices_vec.into_iter().sorted().rev() {
self.remove_tab(i, add_to_undo_stack, true, ctx);
}
true
}
/// Opens a confirmation dialog if necessary, or closes immediately if not.
/// Always closes immediately if skip_confirmation is true.
fn close_tab(
&mut self,
index: usize,
skip_confirmation: bool,
add_to_undo_stack: bool,
ctx: &mut ViewContext<Self>,
) {
let is_last_tab = self.tabs.len() == 1;
if !ContextFlag::CloseWindow.is_enabled() && is_last_tab {
return;
}
let tabs_closed = self.close_tabs(
vec![index].into_iter(),
OpenDialogSource::CloseTab { tab_index: index },
skip_confirmation || is_last_tab, // If this is the last tab, the confirmation dialog will be handled by the window close.
add_to_undo_stack,
ctx,
);
// Telemetry whenever tabs actually closed, not when confirmation dialog comes up.
if tabs_closed {
ctx.dispatch_global_action("workspace:save_app", ());
send_telemetry_from_ctx!(
TelemetryEvent::TabOperations {
action: TabTelemetryAction::CloseTab,
},
ctx
);
}
}
/// Opens a confirmation dialog if necessary, or closes immediately if not.
/// Always closes immediately if skip_confirmation is true.
pub fn close_other_tabs(
&mut self,
index: usize,
skip_confirmation: bool,
ctx: &mut ViewContext<Self>,
) {
// Figure out what indices we want to delete for the "other tabs" case.
let indices_to_remove = (0..self.tabs.len()).filter(|i| *i != index);
let tabs_closed = self.close_tabs(
indices_to_remove,
OpenDialogSource::CloseOtherTabs { tab_index: index },
skip_confirmation,
true,
ctx,
);
// Telemetry whenever tabs actually closed, not when confirmation dialog comes up.
if tabs_closed {
send_telemetry_from_ctx!(
TelemetryEvent::TabOperations {
action: TabTelemetryAction::CloseOtherTabs,
},
ctx
);
}
}
/// Opens a confirmation dialog if necessary, or closes immediately if not.
/// Always closes immediately if skip_confirmation is true.
pub fn close_tabs_direction(
&mut self,
index: usize,
direction: TabMovement,
skip_confirmation: bool,
ctx: &mut ViewContext<Self>,
) {
let indices_to_remove = match direction {
TabMovement::Left => 0..index,
TabMovement::Right => (index + 1)..self.tabs.len(),
};
let tabs_closed = self.close_tabs(
indices_to_remove,
OpenDialogSource::CloseTabsDirection {
tab_index: index,
direction,
},
skip_confirmation,
true,
ctx,
);
// Telemetry whenever tabs actually closed, not when confirmation dialog comes up.
if tabs_closed {
match direction {
TabMovement::Right if self.active_tab_index > index => {
send_telemetry_from_ctx!(
TelemetryEvent::TabOperations {
action: TabTelemetryAction::CloseTabsToRight,
},
ctx
);
}
_ => (),
}
}
}
/// Closes all tabs that have code panes with the specified file path open.
/// This is used when a file is renamed or deleted in the file tree
#[cfg(feature = "local_fs")]
fn close_tabs_with_file_path(&mut self, old_path: &Path, ctx: &mut ViewContext<Self>) {
// Find all code panes across all tabs that have this file open
for tab_data in &self.tabs {
// Check if this tab has any code panes with the old file path open
tab_data.pane_group.update(ctx, |pane_group, ctx| {
// Collect code panes first to avoid borrowing issues
let code_panes: Vec<_> = pane_group.code_panes(ctx).collect();
for (_, code_pane) in code_panes {
code_pane.update(ctx, |code_view, ctx| {
code_view.close_tabs_with_path(old_path, ctx);
});
}
});
}
ctx.notify();
}
/// Renames all open code tabs that point to `old_path` to now point to `new_path`,
/// updating their contents in-place rather than closing them.
#[cfg(feature = "local_fs")]
fn rename_tabs_with_file_path(
&mut self,
old_path: &Path,
new_path: &Path,
ctx: &mut ViewContext<Self>,
) {
for tab_data in &self.tabs {
tab_data.pane_group.update(ctx, |pane_group, ctx| {
// Collect code panes first to avoid borrowing issues
let code_panes: Vec<_> = pane_group.code_panes(ctx).collect();
for (_, code_pane) in code_panes {
code_pane.update(ctx, |code_view, ctx| {
code_view.rename_tabs_with_path(old_path, new_path, ctx);
});
}
});
}
ctx.notify();
}
/// Update this workspace when it is reopened after being closed.
pub fn handle_reopen(&mut self, ctx: &mut ViewContext<Self>) {
self.sync_window_button_visibility(ctx);
for pane_group in self.tab_views() {
pane_group.update(ctx, |pane_group, ctx| {
pane_group.reattach_panes(ctx);
})
}
self.update_active_session(ctx);
}
pub fn restore_closed_tab(
&mut self,
tab_index: usize,
tab_data: TabData,
ctx: &mut ViewContext<Self>,
) {
// When restoring a closed tab, we have to reattach its panes so that they know they're
// user-accessible again.
tab_data.pane_group.update(ctx, |pane_group, ctx| {
pane_group.reattach_panes(ctx);
});
self.tabs.insert(tab_index, tab_data);
self.activate_tab(tab_index, ctx);
ctx.notify();
}
pub fn open_autoupdate_failure_link(&mut self, ctx: &mut ViewContext<Self>) {
ctx.open_url(
"https://docs.warp.dev/support-and-community/troubleshooting-and-support/updating-warp",
);
}
pub fn add_terminal_tab(&mut self, hide_homepage: bool, ctx: &mut ViewContext<Self>) {
self.add_new_session_tab_with_default_mode(
NewSessionSource::Tab,
Some(ctx.window_id()),
None,
None,
hide_homepage,
ctx,
);
ctx.notify();
}
fn add_welcome_tab(&mut self, ctx: &mut ViewContext<Self>) {
let startup_directory = self.get_new_tab_startup_directory(
NewSessionSource::Tab,
Some(ctx.window_id()),
None,
ctx,
);
self.add_tab_with_pane_layout(
PanesLayout::Snapshot(Box::new(PaneNodeSnapshot::Leaf(LeafSnapshot {
is_focused: true,
custom_vertical_tabs_title: None,
contents: LeafContents::Welcome { startup_directory },
}))),
Arc::new(HashMap::new()),
None,
ctx,
);
ctx.notify();
}
fn add_get_started_tab(&mut self, ctx: &mut ViewContext<Self>) {
self.add_tab_with_pane_layout(
PanesLayout::Snapshot(Box::new(PaneNodeSnapshot::Leaf(LeafSnapshot {
is_focused: true,
custom_vertical_tabs_title: None,
contents: LeafContents::GetStarted,
}))),
Arc::new(HashMap::new()),
None,
ctx,
);
ctx.notify();
}
fn add_docker_sandbox_tab(&mut self, ctx: &mut ViewContext<Self>) {
if !FeatureFlag::LocalDockerSandbox.is_enabled() {
log::warn!("Local docker sandbox feature flag is disabled");
return;
}
// Docker sandboxes are inherently local — sbx resolution and the
// `AvailableShell::new_docker_sandbox_shell` constructor both require
// `local_tty`. Other builds (e.g. wasm/remote_tty) log and bail.
#[cfg(feature = "local_tty")]
{
// Resolve sbx via the user's interactive shell PATH (same mechanism
// MCP servers use) so we find it when installed via homebrew on Apple
// Silicon, `~/.local/bin`, `nvm`-style paths, etc. This is async
// because capturing the interactive PATH requires spawning the user's
// login shell.
let window_id = ctx.window_id();
let sbx_future = resolve_sbx_path_from_user_shell(ctx);
ctx.spawn(sbx_future, move |me, sbx_path, ctx| {
let Some(sbx_path) = sbx_path else {
log::error!("sbx binary not found; cannot create Docker sandbox");
return;
};
let shell = AvailableShell::new_docker_sandbox_shell(
sbx_path,
DEFAULT_DOCKER_SANDBOX_BASE_IMAGE.map(str::to_owned),
);
me.add_new_session_tab_internal_with_default_session_mode_behavior(
NewSessionSource::Tab,
Some(window_id),
Some(shell),
None,
true, /* hide_homepage */
DefaultSessionModeBehavior::Ignore,
ctx,
);
ctx.notify();
});
}
#[cfg(not(feature = "local_tty"))]
{
let _ = ctx;
log::warn!("Docker sandbox requires the `local_tty` feature; ignoring request");
}
}
fn add_ambient_agent_tab(&mut self, ctx: &mut ViewContext<Self>) {
if !FeatureFlag::AgentView.is_enabled() || !FeatureFlag::CloudMode.is_enabled() {
return;
}
send_telemetry_from_ctx!(
CloudAgentTelemetryEvent::EnteredCloudMode {
entry_point: CloudModeEntryPoint::NewTab,
},
ctx
);
self.add_tab_with_pane_layout(
PanesLayout::AmbientAgent,
Arc::new(HashMap::new()),
None,
ctx,
);
ctx.notify();
}
// Adds a tab with a specific shell, only meant to be dispatched directly by actions.
fn add_tab_with_shell(
&mut self,
shell: AvailableShell,
source: AddTabWithShellSource,
ctx: &mut ViewContext<Self>,
) {
send_telemetry_from_ctx!(
TelemetryEvent::AddTabWithShell {
source,
shell: shell.telemetry_value()
},
ctx
);
self.add_new_session_tab_with_default_mode(
NewSessionSource::Tab,
Some(ctx.window_id()),
Some(shell),
None,
false,
ctx,
);
ctx.notify();
}
fn add_new_session_tab_with_default_mode(
&mut self,
new_session_source: NewSessionSource,
previous_session_window_id: Option<WindowId>,
chosen_shell: Option<AvailableShell>,
conversation_restoration: Option<ConversationRestorationInNewPaneType>,
hide_homepage: bool,
ctx: &mut ViewContext<Self>,
) {
self.add_new_session_tab_internal_with_default_session_mode_behavior(
new_session_source,
previous_session_window_id,
chosen_shell,
conversation_restoration,
hide_homepage,
DefaultSessionModeBehavior::Apply,
ctx,
);
}
#[allow(clippy::too_many_arguments)]
fn add_new_session_tab_internal_with_default_session_mode_behavior(
&mut self,
new_session_source: NewSessionSource,
previous_session_window_id: Option<WindowId>,
chosen_shell: Option<AvailableShell>,
conversation_restoration: Option<ConversationRestorationInNewPaneType>,
hide_homepage: bool,
default_session_mode_behavior: DefaultSessionModeBehavior,
ctx: &mut ViewContext<Self>,
) {
// Check if we should default to agent mode (only for new sessions, not restorations)
let should_enter_agent_view = matches!(
default_session_mode_behavior,
DefaultSessionModeBehavior::Apply
) && conversation_restoration.is_none()
&& AISettings::as_ref(ctx).default_session_mode(ctx) == DefaultSessionMode::Agent;
#[cfg(feature = "local_tty")]
let is_docker_sandbox = chosen_shell
.as_ref()
.is_some_and(AvailableShell::is_docker_sandbox);
#[cfg(not(feature = "local_tty"))]
let is_docker_sandbox = {
let _ = chosen_shell.as_ref();
false
};
// 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(|| {
self.get_new_tab_startup_directory(
new_session_source,
previous_session_window_id,
chosen_shell.as_ref(),
ctx,
)
});
self.add_tab_with_pane_layout(
PanesLayout::SingleTerminal(Box::new(NewTerminalOptions {
shell: chosen_shell,
initial_directory: startup_directory,
conversation_restoration,
hide_homepage,
..Default::default()
})),
Arc::new(HashMap::new()),
None, /*custom_tab_title*/
ctx,
);
#[cfg(all(feature = "local_tty", not(target_family = "wasm")))]
if is_docker_sandbox {
if let Some(terminal_view) = self
.active_tab_pane_group()
.as_ref(ctx)
.active_session_view(ctx)
{
TerminalView::initialize_docker_sandbox_environment(&terminal_view, ctx);
} else {
log::warn!("Could not find docker sandbox terminal view after creating new tab");
}
}
#[cfg(not(all(feature = "local_tty", not(target_family = "wasm"))))]
let _ = is_docker_sandbox;
// If the default session mode is Agent and AI is enabled, enter agent view
if should_enter_agent_view {
self.enter_agent_view_on_active_tab(ctx);
}
}
/// Enters agent view with a new conversation on the active tab's terminal.
///
/// Used after adding a new tab when the session mode should default to agent view.
fn enter_agent_view_on_active_tab(&self, ctx: &mut ViewContext<Self>) {
self.active_tab_pane_group().update(ctx, |pane_group, ctx| {
if let Some(terminal_view) = pane_group.active_session_view(ctx) {
terminal_view.update(ctx, |view, ctx| {
view.enter_agent_view_for_new_conversation(
None,
AgentViewEntryOrigin::DefaultSessionMode,
ctx,
);
});
}
});
}
pub fn add_tab_with_pane_layout(
&mut self,
panes_layout: PanesLayout,
block_lists: Arc<HashMap<PaneUuid, Vec<SerializedBlockListItem>>>,
custom_tab_title: Option<String>,
ctx: &mut ViewContext<Self>,
) {
// Remember whether the left panel was open on the current active pane group
// before creating a new active pane group.
let left_panel_was_open = if self.tabs.is_empty() {
false
} else {
self.active_tab_pane_group().as_ref(ctx).left_panel_open
};
// Capture the active tab's colors before creating the new tab.
let active_tab = self.tabs.get(self.active_tab_index);
let active_tab_selected_color = active_tab.map(|tab| tab.selected_color);
let active_tab_default_color = active_tab.and_then(|tab| tab.default_directory_color);
let is_new_terminal = matches!(panes_layout, PanesLayout::SingleTerminal(_));
let is_restoration = matches!(panes_layout, PanesLayout::Snapshot(_));
let new_pane_group = ctx.add_typed_action_view(|ctx| {
let mut pane_group = PaneGroup::new_with_panes_layout(
self.tips_completed.clone(),
self.user_default_shell_unsupported_banner_model_handle
.clone(),
self.server_api.clone(),
panes_layout,
block_lists,
self.model_event_sender.clone(),
ctx,
);
if let Some(title) = custom_tab_title {
pane_group.set_title(&title, ctx);
}
pane_group
});
ctx.subscribe_to_view(&new_pane_group, move |me, pane_group, event, ctx| {
me.handle_file_tree_event(pane_group, event, ctx)
});
let new_tab_placement_setting = TabSettings::as_ref(ctx).new_tab_placement;
match new_tab_placement_setting {
NewTabPlacement::AfterAllTabs => {
self.tabs.push(TabData::new(new_pane_group));
self.activate_tab_internal(self.tab_count() - 1, ctx);
}
// Add tab after current tab
_ => {
if self.tab_count() == 0 {
self.tabs.push(TabData::new(new_pane_group));
self.activate_tab_internal(self.tab_count() - 1, ctx);
} else {
self.tabs
.insert(self.active_tab_index + 1, TabData::new(new_pane_group));
self.activate_tab_internal(self.active_tab_index + 1, ctx);
}
}
}
if !is_restoration {
if *TabSettings::as_ref(ctx).preserve_active_tab_color.value() {
if let Some(SelectedTabColor::Color(color)) = active_tab_selected_color {
self.tabs[self.active_tab_index].selected_color =
SelectedTabColor::Color(color);
}
}
// preserve the current tab's default directory color when the new tab inherits the working directory
// (otherwise the new tab's color flashes from no-color to default color during bootstrapping).
if FeatureFlag::DirectoryTabColors.is_enabled() && is_new_terminal {
let wd_config = &SessionSettings::as_ref(ctx).working_directory_config;
let inherits_cwd = wd_config.config_for_source(NewSessionSource::Tab).mode
== WorkingDirectoryMode::PreviousDir
|| wd_config.config_for_source(NewSessionSource::Window).mode
== WorkingDirectoryMode::PreviousDir;
if inherits_cwd {
if let Some(color) = active_tab_default_color {
self.tabs[self.active_tab_index].default_directory_color = Some(color);
}
}
}
}
// If the previous tab's left panel was open, maintain that state with the new tab
// (unless we're restoring the tab from a persisted snapshot).
if FeatureFlag::AgentViewConversationListView.is_enabled()
&& !is_restoration
&& left_panel_was_open
{
self.active_tab_pane_group().update(ctx, |pg, ctx| {
pg.set_left_panel_open(true, ctx);
});
}
}
pub fn add_tab_from_existing_pane(
&mut self,
pane: Box<dyn AnyPaneContent>,
new_idx: usize,
ctx: &mut ViewContext<Self>,
) {
let new_pane_group = ctx.add_typed_action_view(|ctx| {
PaneGroup::new_from_existing_pane(
pane,
self.tips_completed.clone(),
self.user_default_shell_unsupported_banner_model_handle
.clone(),
self.server_api.clone(),
self.model_event_sender.clone(),
ctx,
)
});
ctx.subscribe_to_view(&new_pane_group, move |me, pane_group, event, ctx| {
me.handle_file_tree_event(pane_group, event, ctx)
});
if self.tab_count() == 0 {
self.tabs.push(TabData::new(new_pane_group));
self.activate_tab_internal(self.tab_count() - 1, ctx);
} else {
self.tabs.insert(new_idx, TabData::new(new_pane_group));
self.activate_tab_internal(new_idx, ctx);
}
}
pub fn add_tab_for_cloud_notebook(
&mut self,
notebook_id: SyncId,
settings: &OpenWarpDriveObjectSettings,
ctx: &mut ViewContext<Self>,
) {
// TODO: We should validate that this notebook exists and fallback if it doesn't
let panes_layout = PanesLayout::Snapshot(Box::new(PaneNodeSnapshot::Leaf(LeafSnapshot {
is_focused: true,
custom_vertical_tabs_title: None,
contents: LeafContents::Notebook(NotebookPaneSnapshot::CloudNotebook {
notebook_id: Some(notebook_id),
settings: settings.clone(),
}),
})));
self.add_tab_with_pane_layout(panes_layout, Arc::new(HashMap::new()), None, ctx);
}
fn add_tab_for_cloud_workflow(
&mut self,
workflow_id: SyncId,
settings: &OpenWarpDriveObjectSettings,
ctx: &mut ViewContext<Self>,
) {
let panes_layout = PanesLayout::Snapshot(Box::new(PaneNodeSnapshot::Leaf(LeafSnapshot {
is_focused: true,
custom_vertical_tabs_title: None,
contents: LeafContents::Workflow(WorkflowPaneSnapshot::CloudWorkflow {
workflow_id: Some(workflow_id),
settings: settings.clone(),
}),
})));
self.add_tab_with_pane_layout(panes_layout, Arc::new(HashMap::new()), None, ctx);
}
/// Add a tab with a file notebook pane open.
pub fn add_tab_for_file_notebook(
&mut self,
file_path: Option<PathBuf>,
ctx: &mut ViewContext<Self>,
) {
let panes_layout = PanesLayout::Snapshot(Box::new(PaneNodeSnapshot::Leaf(LeafSnapshot {
is_focused: true,
custom_vertical_tabs_title: None,
contents: LeafContents::Notebook(NotebookPaneSnapshot::LocalFileNotebook {
path: file_path,
}),
})));
self.add_tab_with_pane_layout(panes_layout, Arc::new(HashMap::new()), None, ctx);
}
pub fn add_tab_for_assisted_autoupdate<V: View>(
&mut self,
update_command_fn: impl 'static + Fn(ShellType) -> String,
context_block: ViewHandle<V>,
ctx: &mut ViewContext<Self>,
) {
self.add_tab_with_pane_layout(
Default::default(),
Arc::new(HashMap::new()),
Some("Install Update".to_owned()),
ctx,
);
let Some(terminal_view) = self
.active_tab_pane_group()
.as_ref(ctx)
.active_session_view(ctx)
else {
log::error!("Could not access terminal view after creating a new tab!");
return;
};
terminal_view.update(ctx, |terminal_view, ctx| {
terminal_view.insert_rich_content(
None,
context_block,
None,
terminal::view::rich_content::RichContentInsertionPosition::Append {
insert_below_long_running_block: false,
},
ctx,
);
});
// Once we know what shell is being used, update the input with the
// appropriate assisted auto-update command.
ctx.subscribe_to_view(&terminal_view, move |me, _, event, ctx| {
if let terminal::Event::ShellSpawned(shell_type) = event {
me.set_active_terminal_input_contents_and_focus_app(
&(update_command_fn)(*shell_type),
ctx,
);
}
});
}
/// Add a tab with a code pane open for the specified file.
pub fn add_tab_for_code_file(
&mut self,
file_path: PathBuf,
line_and_column: Option<LineAndColumnArg>,
ctx: &mut ViewContext<Self>,
) {
let source = CodeSource::Link {
path: file_path,
range_start: None,
range_end: None,
};
let pane = CodePane::new(source, line_and_column, ctx);
let new_tab_placement_setting = TabSettings::as_ref(ctx).new_tab_placement;
let new_idx = match new_tab_placement_setting {
NewTabPlacement::AfterAllTabs => self.tab_count(),
NewTabPlacement::AfterCurrentTab => self.active_tab_index + 1,
};
self.add_tab_from_existing_pane(Box::new(pane), new_idx, ctx);
}
pub fn add_tab_for_new_code_file(&mut self, ctx: &mut ViewContext<Self>) {
#[cfg(feature = "local_fs")]
{
let default_directory = self
.active_session_view(ctx)
.and_then(|view| view.as_ref(ctx).pwd())
.map(PathBuf::from)
.or_else(dirs::home_dir);
let source = CodeSource::New { default_directory };
let layout = *EditorSettings::as_ref(ctx).open_file_layout.value();
// Check if we can add the new file to an existing code pane (when using split pane
// layout).
if layout == EditorLayout::SplitPane
&& FeatureFlag::TabbedEditorView.is_enabled()
&& *EditorSettings::as_ref(ctx)
.prefer_tabbed_editor_view
.value()
{
let code_view = self
.active_tab_pane_group()
.as_ref(ctx)
.code_panes(ctx)
.find(|(pane_id, _)| {
!self
.active_tab_pane_group()
.as_ref(ctx)
.is_pane_hidden_for_close(*pane_id)
});
if let Some((pane_id, code_view)) = code_view {
code_view.update(ctx, |code_view, ctx| {
code_view.open_or_focus_existing(None, None, ctx);
});
self.active_tab_pane_group().update(ctx, |pane_group, ctx| {
pane_group.focus_pane(pane_id, true, ctx);
});
return;
}
}
let pane = CodePane::new(source, None, ctx);
match layout {
EditorLayout::NewTab => {
let new_tab_placement_setting = TabSettings::as_ref(ctx).new_tab_placement;
let new_idx = match new_tab_placement_setting {
NewTabPlacement::AfterAllTabs => self.tab_count(),
NewTabPlacement::AfterCurrentTab => self.active_tab_index + 1,
};
self.add_tab_from_existing_pane(Box::new(pane), new_idx, ctx);
}
EditorLayout::SplitPane => {
self.active_tab_pane_group().update(ctx, |pane_group, ctx| {
pane_group.add_pane_with_direction(
Direction::Right,
pane,
true, /* focus_new_pane */
ctx,
);
});
}
}
}
#[cfg(not(feature = "local_fs"))]
{
let _ = ctx;
// Code file functionality is not available without local_fs feature
log::warn!("NewCodeFile action called but local_fs feature is not enabled");
}
}
fn open_repository(&mut self, path: Option<&str>, ctx: &mut ViewContext<Self>) {
match path {
Some(path) => self.handle_open_repository(path, ctx),
None => ctx.open_file_picker(
|result, ctx| match result {
Ok(paths) => {
let Some(path) = paths.into_iter().next() else {
return;
};
if let Some(handle) = ctx.handle().upgrade(ctx) {
handle.update(ctx, |workspace, ctx| {
workspace.handle_open_repository(&path, ctx);
});
}
}
Err(err) => {
let window_id = ctx.window_id();
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
toast_stack.add_ephemeral_toast(
DismissibleToast::error(format!("{err}")),
window_id,
ctx,
);
});
}
},
FilePickerConfiguration::new().folders_only(),
),
}
}
fn handle_open_repository(&mut self, path: &str, ctx: &mut ViewContext<Self>) {
let path_buf = PathBuf::from(path);
ProjectManagementModel::handle(ctx).update(ctx, |projects, ctx| {
projects.upsert_project(path_buf.clone(), ctx);
});
self.add_tab_with_pane_layout(
PanesLayout::SingleTerminal(Box::new(NewTerminalOptions {
initial_directory: Some(path_buf.clone()),
hide_homepage: true,
..Default::default()
})),
Arc::new(HashMap::new()),
None,
ctx,
);
self.active_tab_pane_group().update(ctx, |tab, ctx| {
if let Some(active_terminal) = tab.active_session_view(ctx) {
active_terminal.update(ctx, |terminal, _| {
terminal.maybe_set_pending_repo_init_path(path_buf);
});
}
});
}
/// Navigate to an existing AI conversation, focusing on its terminal view, if it's open anywhere.
/// If the conversation is not in an open pane, restore it based on the provided layout override
/// or the user's setting.
fn restore_or_navigate_to_conversation(
&mut self,
conversation_id: AIConversationId,
window_id: Option<WindowId>,
pane_view_locator: Option<PaneViewLocator>,
terminal_view_id: Option<EntityId>,
mut restore_layout: Option<RestoreConversationLayout>,
ctx: &mut ViewContext<Self>,
) {
// If we have all required navigation data, try to navigate to the existing pane
if let (Some(pane_view_locator), Some(window_id), Some(terminal_view_id)) =
(pane_view_locator, window_id, terminal_view_id)
{
// The pane group will be in the undo stack if its parent view (either the tab or the split pane)
// has been recently closed, and this closure can still be undone.
let is_pane_in_undo_stack = UndoCloseStack::as_ref(ctx)
.is_pane_group_tab_in_stack(pane_view_locator.pane_group_id)
|| ctx
.view_with_id::<PaneGroup>(window_id, pane_view_locator.pane_group_id)
.is_some_and(|pg| {
pg.as_ref(ctx)
.is_pane_hidden_for_close(pane_view_locator.pane_id)
});
// Check if there's an active long-running command in the target terminal and target conversation.
let has_blocking_long_running_command = ctx
.view_with_id::<TerminalView>(window_id, terminal_view_id)
.map(|view| {
let model = view.as_ref(ctx).model.lock();
let is_long_running = model
.block_list()
.active_block()
.is_active_and_long_running();
let selected_conversation_id = view
.as_ref(ctx)
.ai_context_model()
.as_ref(ctx)
.selected_conversation_id(ctx);
is_long_running && selected_conversation_id != Some(conversation_id)
})
.unwrap_or(false);
// If the pane group is in the undo stack, we want to make sure its tab/pane parent
// is not re-openable (as this would cause a duplicate of this conversation to be created).
if is_pane_in_undo_stack {
// Don't open in this pane, fall back to user setting
restore_layout = None;
// TODO(harry): this does not detect correctly when the conversation is part of a window that has been closed.
UndoCloseStack::handle(ctx).update(ctx, |stack, ctx| {
stack.discard_pane_group_parent(pane_view_locator.pane_group_id, ctx);
});
} else if has_blocking_long_running_command {
// Don't open in this pane and use the existing restore layout.
} else {
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, ctx| {
history_model.set_active_conversation_id(
conversation_id,
terminal_view_id,
ctx,
);
});
Self::set_pending_query_state_for_terminal_view(
terminal_view_id,
PendingQueryState::Existing { conversation_id },
ctx,
);
// If the conversation is in the current window, focus the pane directly.
// Otherwise, dispatch an action to the appropriate window.
if window_id == ctx.window_id() {
ctx.windows().show_window_and_focus_app(window_id);
self.focus_pane(pane_view_locator, ctx);
} else if let Some(root_view_id) = ctx.root_view_id(window_id) {
ctx.dispatch_action_for_view(
window_id,
root_view_id,
"root_view:handle_pane_navigation_event",
&pane_view_locator,
);
}
return;
}
}
// Determine effective layout: use provided layout or fall back to setting
#[cfg(feature = "local_fs")]
let layout_from_setting =
match *EditorSettings::as_ref(ctx).open_conversation_layout_preference {
OpenConversationPreference::NewTab => RestoreConversationLayout::NewTab,
OpenConversationPreference::SplitPane => RestoreConversationLayout::SplitPane,
};
#[cfg(not(feature = "local_fs"))]
let layout_from_setting = RestoreConversationLayout::NewTab;
let effective_layout = restore_layout.unwrap_or(layout_from_setting);
// Handle based on effective layout
match effective_layout {
RestoreConversationLayout::ActivePane => {
self.restore_conversation_in_active_pane(conversation_id, ctx);
}
RestoreConversationLayout::SplitPane => {
self.restore_conversation_in_split_pane(conversation_id, ctx);
}
RestoreConversationLayout::NewTab => {
self.restore_conversation_in_new_tab(conversation_id, ctx);
}
}
}
/// Restores a conversation into the active terminal pane.
/// Shows a full-screen loading state while fetching, then restores the conversation into the existing terminal.
/// Falls back to new tab if we cannot restore into the active pane (has a long-running command or is invalid).
fn restore_conversation_in_active_pane(
&mut self,
conversation_id: AIConversationId,
ctx: &mut ViewContext<Self>,
) {
let terminal_view_for_active_pane = self.active_session_view(ctx).filter(|_| {
self.get_active_session_terminal_model(ctx)
.is_some_and(|model| !model.lock().shared_session_status().is_viewer())
&& FeatureFlag::AgentView.is_enabled()
});
// If we can't restore in the active pane, fall back to restoring in new tab.
let Some(terminal_view) = &terminal_view_for_active_pane else {
self.restore_conversation_in_new_tab(conversation_id, ctx);
return;
};
// Check if there's an active long-running command in the active terminal.
// If not, set the active terminal view to loading since we're going to restore in the active pane.
// We do these operations together to hold the model lock across them.
let active_pane_has_long_running_command =
terminal_view.update(ctx, |terminal_view, ctx| {
let mut model_lock = terminal_view.model.lock();
if model_lock
.block_list()
.active_block()
.is_active_and_long_running()
{
return true;
}
// Active pane does not have a long running command. We're going to restore in the active pane
// so set the loading status atomically.
model_lock.set_conversation_transcript_viewer_status(Some(
ConversationTranscriptViewerStatus::Loading,
));
ctx.notify();
false
});
// If there's an active long-running command in the active terminal, fall back to restoring in new tab.
if active_pane_has_long_running_command {
self.restore_conversation_in_new_tab(conversation_id, ctx);
return;
}
let history_model = BlocklistAIHistoryModel::handle(ctx);
let future = history_model
.as_ref(ctx)
.load_conversation_data(conversation_id, ctx);
let terminal_view_for_closure = terminal_view.clone();
let window_id = ctx.window_id();
ctx.spawn(future, move |_workspace, conversation, ctx| {
let Some(conversation) = conversation else {
log::warn!("Failed to load conversation {conversation_id}");
// Unset the loading status
terminal_view_for_closure.update(ctx, |terminal_view, ctx| {
terminal_view
.model
.lock()
.set_conversation_transcript_viewer_status(None);
ctx.notify();
});
WorkspaceToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
let toast = DismissibleToast::error("Failed to load conversation.".to_owned());
toast_stack.add_ephemeral_toast(toast, window_id, ctx);
});
return;
};
terminal_view_for_closure.update(ctx, |terminal_view, ctx| {
// Unset the loading status
terminal_view
.model
.lock()
.set_conversation_transcript_viewer_status(None);
terminal_view.restore_conversation_and_directory_context(
conversation,
FeatureFlag::AgentView.is_enabled(),
|terminal_view, ctx| {
terminal_view.redetermine_global_focus(ctx);
},
ctx,
);
});
});
}
/// Restores a conversation in a new split pane.
/// Creates a loading pane immediately, then replaces it with the real terminal with the conversation once data loads.
/// We have to do this instead of loading the data into the same terminal pane to avoid problems with
/// restoring conversations while the shell is bootstrapping.
fn restore_conversation_in_split_pane(
&mut self,
conversation_id: AIConversationId,
ctx: &mut ViewContext<Self>,
) {
let window_id = ctx.window_id();
let tab_pane_group = self.active_tab_pane_group().clone();
let pane_group_id = tab_pane_group.id();
let loading_pane_id = tab_pane_group.update(ctx, |pane_group, ctx| {
let base_pane_id = pane_group.focused_pane_id(ctx);
pane_group.add_loading_conversation_pane(
PaneGroupDirection::Right,
Some(base_pane_id),
ctx,
)
});
let history_model = BlocklistAIHistoryModel::handle(ctx);
let future = history_model
.as_ref(ctx)
.load_conversation_data(conversation_id, ctx);
ctx.spawn(future, move |_workspace, conversation, ctx| {
let Some(conversation) = conversation else {
log::warn!("Failed to load conversation {conversation_id}");
WorkspaceToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
let toast = DismissibleToast::error("Failed to load conversation.".to_owned());
toast_stack.add_ephemeral_toast(toast, window_id, ctx);
});
// Close the loading pane
if let Some(pane_group) = ctx.view_with_id::<PaneGroup>(window_id, pane_group_id) {
pane_group.update(ctx, |pane_group, ctx| {
pane_group.close_pane(loading_pane_id, ctx);
});
}
return;
};
// Replace the loading pane with real terminal
if let Some(pane_group) = ctx.view_with_id::<PaneGroup>(window_id, pane_group_id) {
pane_group.update(ctx, |pane_group, ctx| {
pane_group.replace_loading_pane_with_terminal(
loading_pane_id,
conversation,
ctx,
);
});
}
});
}
/// Restores a conversation in a new tab.
/// Creates a new tab with a loading pane immediately, then replaces it with the real terminal with the conversation once data loads.
/// We have to do this instead of loading the data into the same terminal pane to avoid problems with
/// restoring conversations while the shell is bootstrapping.
fn restore_conversation_in_new_tab(
&mut self,
conversation_id: AIConversationId,
ctx: &mut ViewContext<Self>,
) {
let window_id = ctx.window_id();
// Create a new tab with loading pane
let new_pane_group = ctx.add_typed_action_view(|ctx| {
PaneGroup::new_for_conversation_transcript_viewer_loading(
self.tips_completed.clone(),
self.user_default_shell_unsupported_banner_model_handle
.clone(),
self.server_api.clone(),
self.model_event_sender.clone(),
ctx,
)
});
ctx.subscribe_to_view(&new_pane_group, move |me, pane_group, event, ctx| {
me.handle_file_tree_event(pane_group, event, ctx)
});
self.tabs.push(TabData::new(new_pane_group.clone()));
let new_tab_index = self.tab_count() - 1;
self.activate_tab_internal(new_tab_index, ctx);
// Get both IDs from the NEW tab's pane group
let pane_group_id = new_pane_group.id();
let loading_pane_id = new_pane_group.as_ref(ctx).focused_pane_id(ctx);
let history_model = BlocklistAIHistoryModel::handle(ctx);
let future = history_model
.as_ref(ctx)
.load_conversation_data(conversation_id, ctx);
ctx.spawn(future, move |workspace, conversation, ctx| {
let Some(conversation) = conversation else {
log::warn!("Failed to load conversation {conversation_id}");
WorkspaceToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
let toast = DismissibleToast::error("Failed to load conversation.".to_owned());
toast_stack.add_ephemeral_toast(toast, window_id, ctx);
});
// Close the loading tab
if let Some(tab_index) = workspace
.tabs
.iter()
.position(|tab| tab.pane_group.id() == pane_group_id)
{
workspace.close_tab(tab_index, true, false, ctx);
}
return;
};
// Find the tab with this pane_group_id and replace its loading pane
if let Some(tab_pane_group) = workspace
.tabs
.iter()
.find(|tab| tab.pane_group.id() == pane_group_id)
.map(|tab| tab.pane_group.clone())
{
tab_pane_group.update(ctx, |pane_group, ctx| {
pane_group.replace_loading_pane_with_terminal(
loading_pane_id,
conversation,
ctx,
);
});
}
});
}
fn set_pending_query_state_for_terminal_view(
terminal_view_id: EntityId,
pending_query_state: PendingQueryState,
ctx: &mut AppContext,
) {
let terminal_view = ctx.window_ids().find_map(|window_id| {
ctx.views_of_type::<TerminalView>(window_id)
.and_then(|terminal_views| {
terminal_views.iter().find_map(|terminal_view| {
if terminal_view.as_ref(ctx).view_id() == terminal_view_id {
Some(terminal_view.clone())
} else {
None
}
})
})
});
if let Some(terminal_view) = terminal_view {
terminal_view.update(ctx, |terminal_view, ctx| {
terminal_view.set_pending_query_state(pending_query_state, ctx);
ctx.notify();
});
} else {
log::warn!(
"Failed to find terminal view with id {terminal_view_id} to set pending query state"
);
}
}
/// Fork an existing AI conversation.
/// Optionally summarizes the conversation after forking and/or sends an initial prompt.
#[allow(clippy::too_many_arguments)]
fn fork_ai_conversation(
&mut self,
conversation_id: AIConversationId,
fork_from_exchange: Option<ForkFromExchange>,
summarize_after_fork: bool,
summarization_prompt: Option<String>,
initial_prompt: Option<String>,
destination: ForkedConversationDestination,
ctx: &mut ViewContext<Self>,
) {
let history_model = BlocklistAIHistoryModel::handle(ctx);
let window_id = ctx.window_id();
let source_terminal_view_id = history_model
.as_ref(ctx)
.all_live_conversations()
.into_iter()
.find(|(_, convo)| convo.id() == conversation_id)
.map(|(terminal_view_id, _)| terminal_view_id);
// An empty prompt should not be provided as a query for the new forked conversation.
let initial_prompt = initial_prompt.and_then(|prompt| {
if prompt.trim().is_empty() {
None
} else {
Some(prompt)
}
});
// Load the conversation data asynchronously
let future = history_model
.as_ref(ctx)
.load_conversation_data(conversation_id, ctx);
ctx.spawn(future, move |workspace, source_conversation, ctx| {
let Some(CloudConversationData::Oz(source_conversation)) = source_conversation else {
log::error!("Failed to load Oz conversation {conversation_id} for forking.");
WorkspaceToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
let toast = DismissibleToast::error(
"Failed to load conversation for forking.".to_owned(),
);
toast_stack.add_ephemeral_toast(toast, window_id, ctx);
});
return;
};
let history_model = BlocklistAIHistoryModel::handle(ctx);
let fork_result = history_model.update(ctx, |history_model, ctx| {
if let Some(fork_from) = fork_from_exchange {
history_model.fork_conversation_at_exchange(
&source_conversation,
fork_from.exchange_id,
fork_from.fork_from_exact_exchange,
FORK_PREFIX,
ctx,
)
} else {
history_model.fork_conversation(&source_conversation, FORK_PREFIX, ctx)
}
});
let forked_conversation = match fork_result {
Ok(forked_conversation) => forked_conversation,
Err(e) => {
log::error!("Conversation forking failed. {e}.");
WorkspaceToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
let toast =
DismissibleToast::error("Conversation forking failed.".to_owned());
toast_stack.add_ephemeral_toast(toast, window_id, ctx);
});
return;
}
};
// Handle forking into the current pane
if destination.is_current_pane() {
if let Some(terminal_view) = workspace.active_session_view(ctx) {
let forked_conversation_id = forked_conversation.id();
terminal_view.update(ctx, move |terminal_view, ctx| {
terminal_view.restore_conversation_after_view_creation(
RestoredAIConversation::new(forked_conversation.clone()),
true,
ctx,
);
terminal_view
.maybe_show_restore_context_hint(RestorationDirState::Unchanged, ctx);
terminal_view.redetermine_global_focus(ctx);
});
Self::handle_forked_conversation_prompts(
terminal_view,
summarize_after_fork,
summarization_prompt,
initial_prompt,
forked_conversation_id,
ctx,
);
Self::show_fork_toast(conversation_id, window_id, ctx);
return;
}
// If no active session view, fall through to create a new pane
log::warn!("CurrentPane fork requested with no active session view");
}
// Respect the explicit destination: SplitPane opens a split pane, NewTab opens a
// new tab. `open_conversation_layout_preference` is only consulted as a fallback by
// `restore_or_navigate_to_conversation`; fork callers always pass an explicit
// destination, so overriding SplitPane with NewTab here would silently defeat the
// user's choice (e.g. `/fork` with Enter explicitly picks SplitPane).
let should_open_in_new_tab = destination.is_new_tab();
if should_open_in_new_tab {
let forked_conversation_id = forked_conversation.id();
workspace.add_new_session_tab_with_default_mode(
NewSessionSource::Tab,
Some(window_id),
None,
Some(ConversationRestorationInNewPaneType::Forked {
conversation: forked_conversation,
}),
false,
ctx,
);
// Handle sending summarize and/or initial prompt to the forked conversation
if let Some(terminal_view) = workspace
.active_tab_pane_group()
.as_ref(ctx)
.active_session_view(ctx)
{
// Copy model selection and execution profile from source to new terminal view
if let Some(source_id) = source_terminal_view_id {
Self::copy_model_and_profile_to_terminal_view(
source_id,
terminal_view.id(),
ctx,
);
}
Self::handle_forked_conversation_prompts(
terminal_view,
summarize_after_fork,
summarization_prompt,
initial_prompt,
forked_conversation_id,
ctx,
);
}
Self::show_fork_toast(conversation_id, window_id, ctx);
return;
}
let active_pane_group = workspace.active_tab_pane_group();
let active_pane_group_id = active_pane_group.id();
let created_pane_id: PaneId = active_pane_group.update(ctx, |pane_group, ctx| {
let active_pane_id = pane_group.focused_pane_id(ctx);
let new_pane_id = pane_group.add_session(
PaneGroupDirection::Right,
Some(active_pane_id),
active_pane_id.as_terminal_pane_id(),
None, /* chosen_shell */
Some(ConversationRestorationInNewPaneType::Forked {
conversation: forked_conversation.clone(),
}),
ctx,
);
new_pane_id.into()
});
// Handle sending summarize and/or initial prompt to the forked conversation
let forked_conversation_id = forked_conversation.id();
let tab_pane_group_handle = active_pane_group.clone();
if let Some(terminal_view) = tab_pane_group_handle.as_ref(ctx).focused_session_view(ctx)
{
// Copy model selection and execution profile from source to new terminal view
if let Some(source_id) = source_terminal_view_id {
Self::copy_model_and_profile_to_terminal_view(
source_id,
terminal_view.id(),
ctx,
);
}
Self::handle_forked_conversation_prompts(
terminal_view,
summarize_after_fork,
summarization_prompt,
initial_prompt,
forked_conversation_id,
ctx,
);
}
// After splitting, focus the newly created pane
let locator = PaneViewLocator {
pane_group_id: active_pane_group_id,
pane_id: created_pane_id,
};
workspace.focus_pane(locator, ctx);
Self::show_fork_toast(conversation_id, window_id, ctx);
});
}
/// Handle sending summarize and/or initial prompt to a forked conversation.
fn handle_forked_conversation_prompts(
terminal_view: ViewHandle<TerminalView>,
summarize_after_fork: bool,
summarization_prompt: Option<String>,
initial_prompt: Option<String>,
forked_conversation_id: AIConversationId,
ctx: &mut ViewContext<Self>,
) {
if !summarize_after_fork && initial_prompt.is_none() {
return;
}
terminal_view.update(ctx, |terminal_view, terminal_view_ctx| {
if summarize_after_fork {
terminal_view
.ai_controller()
.update(terminal_view_ctx, |controller, ctx| {
controller.send_slash_command_request(
SlashCommandRequest::Summarize {
prompt: summarization_prompt,
},
ctx,
);
});
if let Some(prompt) = initial_prompt {
terminal_view.send_user_query_after_next_conversation_finished(
prompt,
/* show_close_button */ true,
/* show_send_now_button */ false,
terminal_view_ctx,
);
}
} else if let Some(prompt) = initial_prompt {
terminal_view
.ai_controller()
.update(terminal_view_ctx, |controller, ctx| {
controller.send_user_query_in_conversation_no_lrc_subagent(
prompt,
forked_conversation_id,
None,
ctx,
);
});
}
});
}
/// Copy the model selection and execution profile from the source terminal view to a new terminal view.
fn copy_model_and_profile_to_terminal_view(
source_terminal_view_id: EntityId,
new_terminal_view_id: EntityId,
ctx: &mut ViewContext<Self>,
) {
// Copy the LLM preference from source to new terminal view
let source_llm_id = LLMPreferences::as_ref(ctx)
.get_active_base_model(ctx, Some(source_terminal_view_id))
.id
.clone();
LLMPreferences::handle(ctx).update(ctx, |prefs, ctx| {
prefs.update_preferred_agent_mode_llm(&source_llm_id, new_terminal_view_id, ctx);
});
// Copy the execution profile from source to new terminal view
let source_profile_id = *AIExecutionProfilesModel::as_ref(ctx)
.active_profile(Some(source_terminal_view_id), ctx)
.id();
AIExecutionProfilesModel::handle(ctx).update(ctx, |profiles, ctx| {
profiles.set_active_profile(new_terminal_view_id, source_profile_id, ctx);
});
}
/// Show a toast notification for a forked conversation.
fn show_fork_toast(
conversation_id: AIConversationId,
window_id: WindowId,
ctx: &mut ViewContext<Self>,
) {
let history_model = BlocklistAIHistoryModel::handle(ctx);
let source_title = history_model
.as_ref(ctx)
.conversation(&conversation_id)
.and_then(|c| c.title())
.map(|s| s.to_string())
.unwrap_or_else(|| "Conversation".to_string());
let title = if source_title.chars().count() > MAX_FORK_TOAST_TITLE_LENGTH {
let truncated: String = source_title
.chars()
.take(MAX_FORK_TOAST_TITLE_LENGTH)
.collect();
format!("{truncated}...")
} else {
source_title
};
WorkspaceToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
let toast = DismissibleToast::default(format!("Forked \"{title}\""));
toast_stack.add_ephemeral_toast(toast, window_id, ctx);
});
}
fn summarize_active_ai_conversation(
&mut self,
prompt: Option<String>,
initial_prompt: Option<String>,
ctx: &mut ViewContext<Self>,
) {
let Some(terminal_view) = self
.active_tab_pane_group()
.as_ref(ctx)
.active_session_view(ctx)
else {
return;
};
terminal_view.update(ctx, |terminal, ctx| {
terminal.ai_controller().update(ctx, |controller, ctx| {
controller
.send_slash_command_request(SlashCommandRequest::Summarize { prompt }, ctx);
});
if let Some(prompt) = initial_prompt {
terminal.send_user_query_after_next_conversation_finished(
prompt, /* show_close_button */ true,
/* show_send_now_button */ false, ctx,
);
}
});
}
/// Handle a tab being dragged
///
/// Will determine if the dragged tab needs to be swapped with another tab in the list and
/// perform the swap, making sure to maintain the active tab if necessary
fn on_tab_drag(&mut self, current_index: usize, position: RectF, ctx: &mut ViewContext<Self>) {
let new_index = if FeatureFlag::VerticalTabs.is_enabled()
&& *TabSettings::as_ref(ctx).use_vertical_tabs
{
self.calculate_updated_tab_index_vertical(current_index, position, ctx)
} else {
self.calculate_updated_tab_index(current_index, position, ctx)
};
if new_index != current_index {
self.tabs.swap(new_index, current_index);
// Update the active tab index if it was impacted by the swap
if current_index == self.active_tab_index {
self.set_active_tab_index(new_index, ctx);
} else if new_index == self.active_tab_index {
self.set_active_tab_index(current_index, ctx);
}
ctx.notify();
}
}
/// Determines the appropriate index for a tab that is being dragged, based on its current
/// index and drag position
///
/// We check if the midpoint of the dragged tab has crossed into the boundary of either
/// surrounding tab. For the tab immediately to the left, this means checking against the
/// rightmost boundary, while for the tab immediately to the right, we check against the
/// leftmost boundary.
///
/// If the midpoint is not in either location, then we return the current index, as the tab has
/// not moved out of its position
fn calculate_updated_tab_index(
&self,
current_index: usize,
drag_position: RectF,
ctx: &mut ViewContext<Self>,
) -> usize {
let midpoint_drag_x = (drag_position.min_x() + drag_position.max_x()) / 2.;
let maybe_left_tab = if current_index > 0 {
ctx.element_position_by_id(tab_position_id(current_index - 1))
} else {
None
};
if let Some(tab_position) = maybe_left_tab {
if midpoint_drag_x < tab_position.max_x() {
return current_index - 1;
}
}
let maybe_right_tab = if current_index < self.tabs.len() - 1 {
ctx.element_position_by_id(tab_position_id(current_index + 1))
} else {
None
};
if let Some(tab_position) = maybe_right_tab {
if midpoint_drag_x > tab_position.min_x() {
return current_index + 1;
}
}
current_index
}
/// Y-axis variant of `calculate_updated_tab_index` for vertical tab layout.
///
/// Uses midpoint-of-neighbor thresholds rather than edge thresholds to prevent
/// oscillation when groups have different heights.
fn calculate_updated_tab_index_vertical(
&self,
current_index: usize,
drag_position: RectF,
ctx: &mut ViewContext<Self>,
) -> usize {
let midpoint_drag_y = (drag_position.min_y() + drag_position.max_y()) / 2.;
let maybe_above_tab = if current_index > 0 {
ctx.element_position_by_id(tab_position_id(current_index - 1))
} else {
None
};
if let Some(tab_position) = maybe_above_tab {
let neighbor_midpoint_y = (tab_position.min_y() + tab_position.max_y()) / 2.;
if midpoint_drag_y < neighbor_midpoint_y {
return current_index - 1;
}
}
let maybe_below_tab = if current_index < self.tabs.len() - 1 {
ctx.element_position_by_id(tab_position_id(current_index + 1))
} else {
None
};
if let Some(tab_position) = maybe_below_tab {
let neighbor_midpoint_y = (tab_position.min_y() + tab_position.max_y()) / 2.;
if midpoint_drag_y > neighbor_midpoint_y {
return current_index + 1;
}
}
current_index
}
// Move tab, given tab index, left or right
fn move_tab(&mut self, index: usize, direction: TabMovement, ctx: &mut ViewContext<Self>) {
let tabs_len = self.tabs.len();
let new_index = match direction {
TabMovement::Left if index > 0 => index - 1,
TabMovement::Right if index < tabs_len - 1 => index + 1,
_ => return,
};
// Don't need to worry about negative numbers because that case is covered above
self.tabs.swap(index, new_index);
if index == self.active_tab_index {
self.set_active_tab_index(new_index, ctx);
send_telemetry_from_ctx!(TelemetryEvent::MoveActiveTab { direction }, ctx);
} else {
// Don't want to change the active tab for the user due to an adjacent
// tab being moved left/right.
if new_index == self.active_tab_index {
self.set_active_tab_index(index, ctx);
}
send_telemetry_from_ctx!(TelemetryEvent::MoveTab { direction }, ctx);
}
ctx.notify();
}
/// How to render the tab bar.
fn tab_bar_mode(&self, app: &AppContext) -> ShowTabBar {
// Always show the tab bar during HoA onboarding so that callouts
// pointing at tabs/inbox render correctly even when the user has
// "show tab bar on hover" enabled.
if self.hoa_onboarding_flow.is_some() || self.should_show_session_config_tab_config_chip() {
return ShowTabBar::Stacked;
}
if !FeatureFlag::FullScreenZenMode.is_enabled() {
return ShowTabBar::default();
}
let is_fullscreen = app
.windows()
.platform_window(self.window_id)
.is_some_and(|window| window.fullscreen_state() == FullscreenState::Fullscreen);
let is_hovered = self
.tab_bar_hover_state
.lock()
.is_ok_and(|state| state.is_hovered())
|| self.traffic_light_mouse_states.are_traffic_lights_hovered();
// Check if any of the menus/popups rendered relative to the tab bar are open.
let is_vertical_tabs_active = FeatureFlag::VerticalTabs.is_enabled()
&& *TabSettings::as_ref(app).use_vertical_tabs
&& self.vertical_tabs_panel_open;
let is_tab_menu_open = self.show_tab_bar_overflow_menu
|| (self.show_tab_right_click_menu.is_some() && !is_vertical_tabs_active)
|| (self.show_new_session_dropdown_menu.is_some() && !is_vertical_tabs_active)
|| (!FeatureFlag::AgentMode.is_enabled() && self.should_show_ai_assistant_warm_welcome)
|| self.is_user_menu_open
|| self.tab_bar_pinned_by_popup;
// Check if any panes are being dragged (potentially into a new tab).
let is_pane_being_dragged = self
.active_tab_pane_group()
.as_ref(app)
.any_pane_being_dragged(app);
let workspace_decoration_visibility = TabSettings::as_ref(app)
.workspace_decoration_visibility
.value();
let hovered_visibility = if is_pane_being_dragged || is_hovered || is_tab_menu_open {
ShowTabBar::Stacked
} else {
ShowTabBar::Hidden
};
match workspace_decoration_visibility {
WorkspaceDecorationVisibility::OnHover => hovered_visibility,
// If the tab bar is hidden when fullscreen, show/hide on hover.
WorkspaceDecorationVisibility::HideFullscreen if is_fullscreen => hovered_visibility,
// If the user always wants a tab bar OR the window isn't fullscreen, make it
// persistently stacked above the content area.
_ => ShowTabBar::Stacked,
}
}
#[cfg(target_os = "macos")]
pub fn sync_window_button_visibility(&self, ctx: &mut ViewContext<Self>) {
use galaxyui::platform::mac::WindowExt;
let show = if FeatureFlag::FullScreenZenMode.is_enabled()
&& TabSettings::as_ref(ctx)
.workspace_decoration_visibility
.value()
== &WorkspaceDecorationVisibility::OnHover
{
self.tab_bar_mode(ctx).has_tab_bar()
} else {
TabSettings::as_ref(ctx)
.workspace_decoration_visibility
.show_window_decorations()
};
if let Some(platform_window) = ctx.windows().platform_window(ctx.window_id()) {
platform_window.as_ref().set_window_buttons(show);
}
}
#[cfg(not(target_os = "macos"))]
pub fn sync_window_button_visibility(&self, _: &mut ViewContext<Self>) {
// Only macOS uses native window buttons.
}
/// Updates the titlebar height to match the scaled tab bar height.
pub fn update_titlebar_height(&self, ctx: &mut ViewContext<Self>) {
let zoom_factor = WindowSettings::as_ref(ctx).zoom_level.as_zoom_factor();
let scaled_tab_bar_height = (TOTAL_TAB_BAR_HEIGHT * zoom_factor) as f64;
if let Some(platform_window) = ctx.windows().platform_window(ctx.window_id()) {
platform_window
.as_ref()
.set_titlebar_height(scaled_tab_bar_height);
}
}
fn request_notification_permissions_if_needed(&mut self, ctx: &mut ViewContext<Self>) {
// Request permissions any time notifications are currently enabled.
let current_mode = SessionSettings::as_ref(ctx).notifications.value().mode;
if current_mode == NotificationsMode::Enabled {
ctx.request_desktop_notification_permissions(move |view, outcome, ctx| {
match &outcome {
RequestPermissionsOutcome::Accepted => (),
RequestPermissionsOutcome::PermissionsDenied => {
// Show a helpful toast if the user denied permissions.
let url = NOTIFICATIONS_TROUBLESHOOT_URL.to_string();
view.toast_stack.update(ctx, |toast_stack, ctx| {
let toast = DismissibleToast::error(
"Galaxy doesn't have permission to send desktop notifications.".to_string(),
)
.with_link(ToastLink::new("Troubleshoot notifications".to_string()).with_href(url));
toast_stack.add_persistent_toast(toast, ctx);
});
}
RequestPermissionsOutcome::OtherError { error_message } => {
log::error!(
"Unknown error when requesting notification permissions. error_msg: {error_message}"
);
}
}
send_telemetry_from_ctx!(
TelemetryEvent::NotificationsRequestPermissionsOutcome { outcome },
ctx
);
});
send_telemetry_from_ctx!(
TelemetryEvent::NotificationPermissionsRequested {
source: NotificationsTurnedOnSource::Settings,
trigger: None
},
ctx
);
}
}
fn toggle_notifications(&mut self, ctx: &mut ViewContext<Self>) {
let current_settings = SessionSettings::as_ref(ctx).notifications.value().clone();
let previous_mode = current_settings.mode;
let new_mode = match previous_mode {
NotificationsMode::Unset | NotificationsMode::Dismissed => NotificationsMode::Enabled,
NotificationsMode::Enabled => NotificationsMode::Disabled,
NotificationsMode::Disabled => NotificationsMode::Enabled,
};
SessionSettings::handle(ctx).update(ctx, |settings, ctx| {
let new_notifications = NotificationsSettings {
mode: new_mode,
..current_settings
};
if let Err(e) = settings.notifications.set_value(new_notifications, ctx) {
log::error!("Error persisting notifications setting: {e}");
}
});
}
fn open_command_palette(&mut self, ctx: &mut ViewContext<Self>) {
self.tips_completed.update(ctx, |tips_completed, ctx| {
mark_feature_used_and_write_to_user_defaults(
Tip::Action(TipAction::CommandPalette),
tips_completed,
ctx,
);
ctx.notify();
});
self.palette.update(ctx, |view, ctx| {
view.reset(ctx);
});
}
fn open_files_palette(&mut self, ctx: &mut ViewContext<Self>) {
self.tips_completed.update(ctx, |tips_completed, ctx| {
mark_feature_used_and_write_to_user_defaults(
Tip::Action(TipAction::CommandPalette),
tips_completed,
ctx,
);
ctx.notify();
});
self.palette.update(ctx, |view, ctx| {
view.reset(ctx);
// Reset mixer with correct file data source before setting filter
let mixer = view.search_bar.as_ref(ctx).mixer().clone();
view.data_source_store.update(ctx, |store, ctx| {
store.reset_search_mixer(mixer, self.is_shared_session_viewer_focused(ctx), ctx);
});
view.set_active_query_filter(QueryFilter::Files, ctx);
});
}
fn set_command_palette_binding_source(
&mut self,
source: PaletteSource,
ctx: &mut ViewContext<Self>,
) {
let window_id = ctx.window_id();
// Safety: Unwrap is okay here because we just retrieved the window_id from the context
// so we know it exists
let view_id = ctx
.focused_view_id(window_id)
.expect("Just retrieved the window_id from the context.");
let active_palette_handle = if matches!(source, PaletteSource::CtrlTab { .. }) {
&self.ctrl_tab_palette
} else {
&self.palette
};
active_palette_handle.update(ctx, |view, ctx| {
view.set_binding_source(window_id, view_id, ctx);
ctx.notify();
});
}
fn open_navigation_palette(&mut self, ctx: &mut ViewContext<Self>) {
self.palette.update(ctx, |view, ctx| {
view.reset(ctx);
view.set_active_query_filter(QueryFilter::Sessions, ctx);
view.set_initial_selection_offset(0, ctx);
});
ctx.notify();
}
fn open_recent_repos_and_convos_palette(&mut self, ctx: &mut ViewContext<Self>) {
self.palette.update(ctx, |view, ctx| {
view.reset(ctx);
view.set_fixed_query_filters(
"Search recent repos and conversations".to_string(),
vec![QueryFilter::HistoricalConversations, QueryFilter::Repos],
ctx,
);
});
}
fn open_conversations_palette(&mut self, ctx: &mut ViewContext<Self>) {
self.palette.update(ctx, |view, ctx| {
view.reset(ctx);
view.set_active_query_filter(QueryFilter::Conversations, ctx);
view.set_initial_selection_offset(0, ctx);
});
ctx.notify();
}
fn open_ctrl_tab_palette(
&mut self,
shift_pressed_initially: bool,
ctx: &mut ViewContext<Self>,
) {
let offset = if shift_pressed_initially { -1 } else { 1 };
self.ctrl_tab_palette.update(ctx, |view, ctx| {
view.reset(ctx);
view.set_active_query_filter(QueryFilter::Sessions, ctx);
view.set_initial_selection_offset(offset, ctx);
});
ctx.notify();
}
fn open_warp_drive_palette(&mut self, ctx: &mut ViewContext<Self>) {
self.palette.update(ctx, |view, ctx| {
view.reset(ctx);
view.set_active_query_filter(QueryFilter::Drive, ctx);
ctx.focus(&self.palette);
});
ctx.notify();
}
fn set_navigation_palette_session_source(
&mut self,
source: PaletteSource,
ctx: &mut ViewContext<Self>,
) {
let active_pane_id = self
.active_tab_pane_group()
.as_ref(ctx)
.focused_pane_id(ctx);
let active_tab_id = self
.tabs
.get(self.active_tab_index)
.map(|tab| tab.pane_group.id());
let active_window_id = ctx.window_id();
let active_palette_handle = if matches!(source, PaletteSource::CtrlTab { .. }) {
&self.ctrl_tab_palette
} else {
&self.palette
};
active_palette_handle.update(ctx, |view, ctx| {
// Set the session source when the active_tab_id is Some.
if let Some(active_tab_id) = active_tab_id {
view.set_session_source(
SessionSource::Set {
active_pane_id,
active_tab_id,
active_window_id,
},
ctx,
);
ctx.notify();
}
ctx.notify();
});
}
fn set_palette_sources(&mut self, source: PaletteSource, ctx: &mut ViewContext<Self>) {
self.set_command_palette_binding_source(source, ctx);
self.set_navigation_palette_session_source(source, ctx);
}
fn open_launch_config_palette(&mut self, ctx: &mut ViewContext<Self>) {
self.palette.update(ctx, |view, ctx| {
view.reset(ctx);
view.set_active_query_filter(QueryFilter::LaunchConfigurations, ctx);
});
}
fn close_palette(
&mut self,
focus_active_tab: bool,
accepted_action_type: Option<&'static str>,
ctx: &mut ViewContext<Self>,
) {
self.current_workspace_state.is_palette_open = false;
self.current_workspace_state.is_ctrl_tab_palette_open = false;
self.tab_bar_pinned_by_popup = false;
self.sync_window_button_visibility(ctx);
if focus_active_tab
// If the user did not do any action on the command palette (eg. closed via shortcut or clicking away)
// we always force the focus back onto the terminal input
// Otherwise we check if any other views are open before moving focus back to terminal input
&& (accepted_action_type.is_none()
|| !self
.current_workspace_state
.is_any_non_terminal_view_open(ctx))
{
self.focus_active_tab(ctx);
}
ctx.notify();
}
/// Close all overlays in this workspace and the active pane group.
fn close_all_overlays(&mut self, ctx: &mut ViewContext<Self>) {
self.current_workspace_state.close_all_modals();
self.close_tab_bar_overflow_menu(ctx);
self.close_all_chip_menus(ctx);
self.active_tab_pane_group()
.update(ctx, |pane_group, ctx| pane_group.close_overlays(ctx));
}
/// Close all chip menus across all inputs to prevent overlapping with modals.
/// This is a defensive measure to ensure chip menus don't stay open when focus-stealing modals appear.
fn close_all_chip_menus(&mut self, ctx: &mut ViewContext<Self>) {
if let Some(active_input_handle) = self.get_active_input_view_handle(ctx) {
active_input_handle.update(ctx, |input, ctx| {
input.prompt_render_helper.prompt_view().update(
ctx,
|prompt_display, prompt_ctx| {
prompt_display.close_all_chip_menus(prompt_ctx);
},
);
});
}
}
fn open_require_login_modal(&mut self, variant: AuthViewVariant, ctx: &mut ViewContext<Self>) {
self.require_login_modal.update(ctx, |modal, ctx| {
modal.set_variant(ctx, variant);
});
self.close_all_overlays(ctx);
self.current_workspace_state.is_require_login_modal_open = true;
ctx.focus(&self.require_login_modal);
ctx.notify();
}
fn open_auth_override_warning_modal(
&mut self,
auth_payload: AuthRedirectPayload,
ctx: &mut ViewContext<Self>,
) {
self.close_all_overlays(ctx);
self.auth_override_warning_modal.update(ctx, |modal, _| {
modal.set_interrupted_auth_payload(auth_payload);
});
self.current_workspace_state.is_auth_override_modal_open = true;
ctx.focus(&self.auth_override_warning_modal);
ctx.notify();
}
fn open_palette(
&mut self,
mode: PaletteMode,
source: PaletteSource,
ctx: &mut ViewContext<Self>,
) {
self.close_all_overlays(ctx);
// Set the shared session viewer state before opening the palette
// so it can determine which data sources to include (e.g., exclude Files in shared sessions)
let is_shared_session_viewer = self.is_shared_session_viewer_focused(ctx);
let active_palette = if matches!(source, PaletteSource::CtrlTab { .. }) {
&self.ctrl_tab_palette
} else {
&self.palette
};
active_palette.update(ctx, |palette, ctx| {
palette.set_is_shared_session_viewer(is_shared_session_viewer, ctx);
});
if matches!(source, PaletteSource::TitleBarSearchBar) {
self.tab_bar_pinned_by_popup = true;
}
if matches!(source, PaletteSource::CtrlTab { .. }) {
self.current_workspace_state.is_ctrl_tab_palette_open = true;
} else {
self.current_workspace_state.is_palette_open = true;
}
match mode {
PaletteMode::Command => self.open_command_palette(ctx),
PaletteMode::Navigation => match source {
PaletteSource::CtrlTab {
shift_pressed_initially,
} => self.open_ctrl_tab_palette(shift_pressed_initially, ctx),
_ => self.open_navigation_palette(ctx),
},
PaletteMode::LaunchConfig => self.open_launch_config_palette(ctx),
PaletteMode::WarpDrive => self.open_warp_drive_palette(ctx),
PaletteMode::Files => self.open_files_palette(ctx),
PaletteMode::Conversations => self.open_conversations_palette(ctx),
PaletteMode::ConversationsAndRepos => self.open_recent_repos_and_convos_palette(ctx),
}
ctx.focus(&self.palette);
send_telemetry_from_ctx!(TelemetryEvent::PaletteSearchOpened { mode, source }, ctx);
ctx.notify();
}
/// Implements the WorkspaceAction::OpenPalette. This method makes sure the palette is open and
/// has up-to-date sources. Use this if you don't want toggle semantics.
fn open_palette_action(
&mut self,
palette_mode: PaletteMode,
source: PaletteSource,
with_content: Option<&str>,
ctx: &mut ViewContext<Self>,
) {
// ensure the palette sources are up-to-date, e.g. maybe there is already a navigation
// palette open and then new sessions were opened after that
self.set_palette_sources(source, ctx);
self.open_palette(palette_mode, source, ctx);
if let Some(text) = with_content {
self.palette.update(ctx, |palette, ctx| {
palette.insert_query_text(text, ctx);
});
}
}
pub fn is_palette_mode_enabled(&self, palette_mode: PaletteMode, app: &AppContext) -> bool {
self.palette.as_ref(app).is_mode_enabled(palette_mode, app)
}
/// Toggle the open / closed state of the palette (so that hitting shortcut a second time
/// will close the palette)
fn toggle_palette(
&mut self,
palette_mode: PaletteMode,
source: PaletteSource,
ctx: &mut ViewContext<Self>,
) {
// If the invite modal is open, don't show the palette since it won't be visible anyway
if !self
.current_workspace_state
.is_any_non_palette_modal_open(ctx)
{
let is_palette_mode_already_open =
self.palette.as_ref(ctx).is_mode_enabled(palette_mode, ctx)
&& ((matches!(source, PaletteSource::CtrlTab { .. })
&& self.current_workspace_state.is_ctrl_tab_palette_open)
|| self.current_workspace_state.is_palette_open);
if is_palette_mode_already_open {
self.close_palette(true, None, ctx);
} else {
self.set_palette_sources(source, ctx);
self.open_palette(palette_mode, source, ctx);
}
}
}
fn handle_palette_event(&mut self, event: &CommandPaletteEvent, ctx: &mut ViewContext<Self>) {
match event {
CommandPaletteEvent::Close {
accepted_action_type,
} => self.close_palette(true, *accepted_action_type, ctx),
CommandPaletteEvent::ExecuteWorkflow { id } => {
let Some(workflow) = CloudModel::as_ref(ctx).get_workflow(id) else {
log::warn!("Tried to execute workflow for id {id:?} but it does not exist");
return;
};
self.run_cloud_workflow_in_active_input(
workflow.clone(),
WorkflowSelectionSource::CommandPalette,
TerminalSessionFallbackBehavior::default(),
ctx,
);
}
CommandPaletteEvent::InvokeEnvironmentVariables { id } => {
let Some(env_var_collection) = CloudModel::as_ref(ctx).get_env_var_collection(id)
else {
log::warn!("Tried to execute EVC for id {id:?} but it does not exist");
return;
};
self.invoke_environment_variables(env_var_collection.clone(), false, ctx);
}
CommandPaletteEvent::OpenNotebook { id } => self.open_notebook(
&NotebookSource::Existing(*id),
&OpenWarpDriveObjectSettings::default(),
ctx,
true,
),
CommandPaletteEvent::ViewInWarpDrive { id } => {
self.view_in_and_focus_warp_drive(WarpDriveItemId::Object(*id), ctx);
}
#[allow(unused_variables)]
CommandPaletteEvent::OpenFile {
path,
line_and_column_arg,
} => {
#[cfg(feature = "local_fs")]
self.open_code(
CodeSource::Link {
path: path.clone().into(),
range_start: None,
range_end: None,
},
*EditorSettings::as_ref(ctx).open_file_layout.value(),
*line_and_column_arg,
false, // preview
&[],
ctx,
);
}
CommandPaletteEvent::OpenDirectory { path } => {
let active_terminal_view = self
.active_tab_pane_group()
.as_ref(ctx)
.active_session_view(ctx);
if let Some(terminal_view) = active_terminal_view {
terminal_view.update(ctx, |terminal_view, ctx| {
terminal_view.open_repo_folder(path.to_string(), false, ctx);
});
}
}
}
}
/// This function is used when we set a selected object, which is an object open in an active pane.
/// We do not want to focus Warp Drive, instead we want to focus the editor of the open object.
fn view_in_warp_drive(&mut self, item_id: WarpDriveItemId, ctx: &mut ViewContext<Self>) {
self.open_left_panel(ctx);
self.left_panel_view.update(ctx, |left_panel, ctx| {
left_panel.handle_action(&LeftPanelAction::WarpDrive, ctx);
});
if let WarpDriveItemId::Object(object_id) = item_id {
CloudModel::handle(ctx).update(ctx, |model, ctx| {
model.force_expand_object_and_ancestors_cloud_id(object_id, ctx);
});
}
self.update_warp_drive_view(ctx, |warp_drive, ctx| {
warp_drive.scroll_item_into_view(item_id, ctx);
warp_drive.expand_section_for_drive_item_id(item_id, ctx);
warp_drive.initialize_drive_section_states(ctx);
});
}
/// This function is used when we want to view an item in Warp Drive AND focus Warp Drive.
pub fn view_in_and_focus_warp_drive(
&mut self,
item_id: WarpDriveItemId,
ctx: &mut ViewContext<Self>,
) {
self.view_in_warp_drive(item_id, ctx);
self.update_warp_drive_view(ctx, |warp_drive, ctx| {
warp_drive.reset_and_open_to_main_index(ctx);
warp_drive.set_focused_item(item_id, ctx);
});
ctx.notify();
}
/// Updates the left panel's warp drive view.
fn update_warp_drive_view<F>(&mut self, ctx: &mut ViewContext<Self>, update_fn: F)
where
F: FnOnce(&mut DrivePanel, &mut ViewContext<DrivePanel>),
{
self.left_panel_view.update(ctx, |left_panel, ctx| {
left_panel.warp_drive_view().update(ctx, |warp_drive, ctx| {
update_fn(warp_drive, ctx);
});
});
}
/// View an object in Warp Drive and open its sharing settings.
fn open_object_sharing_settings(
&mut self,
object_id: CloudObjectTypeAndId,
invitee_email: Option<String>,
source: SharingDialogSource,
ctx: &mut ViewContext<Self>,
) {
self.view_in_warp_drive(WarpDriveItemId::Object(object_id), ctx);
self.update_warp_drive_view(ctx, |warp_drive, ctx| {
warp_drive.reset_and_open_to_main_index(ctx);
warp_drive.open_object_sharing_settings(object_id, invitee_email, source, ctx);
});
ctx.notify();
}
fn move_to_drive_space(
&mut self,
cloud_object_type_and_id: CloudObjectTypeAndId,
space: Space,
ctx: &mut ViewContext<Self>,
) {
self.update_warp_drive_view(ctx, |warp_drive, ctx| {
warp_drive.move_object_to_team_owner(cloud_object_type_and_id, space, ctx);
});
}
fn set_focused_index(&mut self, index: Option<usize>, ctx: &mut ViewContext<Self>) {
self.update_warp_drive_view(ctx, |warp_drive, ctx| {
warp_drive.set_focused_index(index, ctx);
});
ctx.notify();
}
fn handle_changelog_event(&mut self, event: &ChangelogEvent, ctx: &mut ViewContext<Self>) {
// For certain contexts, like shared sessions, we do not want to force open the side panel
// or display the reward modal.
if !ContextFlag::ForceSidePanelOpen.is_enabled() {
return;
}
// Don't show changelog if user has disabled it in settings.
let show_changelog_setting = *ChangelogSettings::as_ref(ctx).show_changelog_after_update;
let mut request_type = None;
let should_show_changelog = match event {
ChangelogEvent::ChangelogRequestFailed {
request_type: ChangelogRequestType::UserAction,
}
| ChangelogEvent::ChangelogRequestComplete {
request_type: ChangelogRequestType::UserAction,
..
} => {
request_type = Some(ChangelogRequestType::UserAction);
true
}
ChangelogEvent::ChangelogRequestComplete {
request_type: ChangelogRequestType::WindowLaunch,
..
} => match ChannelState::app_version() {
Some(version) => {
let opening_warp_drive_on_start_up = OPENING_WARP_DRIVE_ON_START_UP
.lock()
.expect("Should be able to access OPENING_WARP_DRIVE_ON_START_UP");
request_type = Some(ChangelogRequestType::WindowLaunch);
// Do not show changelog on quake mode window or if it has already been shown
// or if we are opening Warp Drive on start up
quake_mode_window_id() != Some(ctx.window_id())
&& !Settings::has_changelog_been_shown(version, ctx)
&& !*opening_warp_drive_on_start_up
}
None => false,
},
ChangelogEvent::ChangelogRequestFailed {
request_type: ChangelogRequestType::WindowLaunch,
} => false,
ChangelogEvent::ImageRequestComplete => false,
} && show_changelog_setting;
match (
should_show_changelog,
request_type,
self.reward_modal_pending,
) {
(true, Some(ChangelogRequestType::WindowLaunch), _) => {
if let Some(version) = ChannelState::app_version() {
Settings::mark_changelog_shown(version, ctx);
if FeatureFlag::AvatarInTabBar.is_enabled() {
self.update_toast_stack.update(ctx, |stack, ctx| {
// Get keybinding for view changelog action
let keystroke = ctx
.editable_bindings()
.find(|binding| binding.name == "workspace:view_changelog")
.and_then(|binding| trigger_to_keystroke(binding.trigger));
let mut link = ToastLink::new("View changelog".to_owned())
.with_onclick_action(WorkspaceAction::ViewLatestChangelog);
if let Some(keystroke) = keystroke {
link = link.with_keystroke(keystroke);
}
let toast = DismissibleToast::default(String::from("Galaxy updated!"))
.with_link(link);
stack.add_ephemeral_toast(toast, ctx);
});
} else {
// If resource center isn't already open and Warp AI isn't open, then open resource center
if !self.current_workspace_state.is_resource_center_open
&& !self.current_workspace_state.is_ai_assistant_panel_open
{
self.open_resource_center_main_page(ctx);
self.update_resource_center_action_target(ctx);
ctx.notify();
}
}
}
}
(_, Some(ChangelogRequestType::UserAction), _) => {
if !self.current_workspace_state.is_resource_center_open
&& !self.current_workspace_state.is_ai_assistant_panel_open
{
self.open_resource_center_main_page(ctx);
self.update_resource_center_action_target(ctx);
ctx.notify();
}
}
(false, _, Some(kind)) => {
// We shouldn't show the changelog modal, but we have a pending reward modal, so we
// should show that now that we know the changelog won't be shown
self.show_reward_modal(kind, ctx);
}
_ => {}
}
}
fn manual_check_for_update(&self, ctx: &mut ViewContext<Self>) {
AutoupdateState::handle(ctx).update(ctx, |autoupdate_state, ctx| {
autoupdate_state.manually_check_for_update(ctx);
});
}
pub fn is_theme_creator_modal_open(&self) -> bool {
self.current_workspace_state.is_theme_creator_modal_open
}
pub fn is_theme_deletion_modal_open(&self) -> bool {
self.current_workspace_state.is_theme_deletion_modal_open
}
pub fn is_palette_open(&self) -> bool {
self.current_workspace_state.is_palette_open
|| self.current_workspace_state.is_ctrl_tab_palette_open
}
pub fn is_workflow_modal_open(&self) -> bool {
self.current_workspace_state.is_workflow_modal_open
}
pub fn is_warp_drive_open(&self) -> bool {
self.current_workspace_state.is_warp_drive_open
}
pub fn is_left_panel_open(&self, ctx: &AppContext) -> bool {
self.active_tab_pane_group().as_ref(ctx).left_panel_open
}
fn is_readonly_shared_session_active(&self, ctx: &mut ViewContext<Self>) -> bool {
let active_terminal_view = self
.active_tab_pane_group()
.as_ref(ctx)
.active_session_view(ctx);
active_terminal_view.is_some_and(|view| {
view.as_ref(ctx)
.model
.lock()
.shared_session_status()
.is_reader()
})
}
fn handle_settings_pane_event(
&mut self,
event: &SettingsViewEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
SettingsViewEvent::CheckForUpdate => {
self.manual_check_for_update(ctx);
}
SettingsViewEvent::LaunchNetworkLogging => {
self.open_network_log_pane(ctx);
}
SettingsViewEvent::OpenWarpDrive => {
self.close_all_overlays(ctx);
self.open_or_toggle_warp_drive(
false, /* toggle */
false, /* explicit_user_action */
ctx,
);
ctx.notify();
}
SettingsViewEvent::SignupAnonymousUser => {
self.initiate_user_signup(AnonymousUserSignupEntrypoint::SignUpButton, ctx);
}
SettingsViewEvent::Pane(_) | SettingsViewEvent::StartResize => {}
SettingsViewEvent::ShowToast { message, flavor } => {
self.toast_stack.update(ctx, |toast_stack, ctx| {
toast_stack
.add_ephemeral_toast(DismissibleToast::new(message.clone(), *flavor), ctx);
});
}
SettingsViewEvent::OpenAIFactCollection => {
self.open_ai_fact_collection_pane(Some(Direction::Right), None, ctx);
send_telemetry_from_ctx!(
TelemetryEvent::KnowledgePaneOpened {
entrypoint: KnowledgePaneEntrypoint::Settings,
},
ctx
);
}
SettingsViewEvent::OpenMCPServerCollection => {
self.show_settings_with_section(Some(SettingsSection::MCPServers), ctx);
send_telemetry_from_ctx!(
TelemetryEvent::MCPServerCollectionPaneOpened {
entrypoint: MCPServerCollectionPaneEntrypoint::Settings,
},
ctx
);
}
SettingsViewEvent::OpenExecutionProfileEditor(profile_id) => {
self.open_execution_profile_editor_pane(None, *profile_id, ctx);
}
SettingsViewEvent::OpenLspLogs { log_path } => {
self.open_lsp_logs(log_path, ctx);
}
SettingsViewEvent::OpenProjectRulesPane { rule_paths } => {
#[cfg(feature = "local_fs")]
if let Some((first, rest)) = rule_paths.split_first() {
self.open_code(
CodeSource::ProjectRules {
path: first.clone(),
},
EditorLayout::SplitPane,
None,
false,
rest,
ctx,
);
}
#[cfg(not(feature = "local_fs"))]
let _ = rule_paths;
}
}
}
fn refresh_working_directories_for_pane_group(
&mut self,
pane_group: &ViewHandle<PaneGroup>,
ctx: &mut ViewContext<Self>,
) {
let pane_group_id = pane_group.id();
let terminal_cwds: Vec<(EntityId, String)> = pane_group
.as_ref(ctx)
.terminal_view_working_directories(ctx)
.filter_map(|(id, cwd)| cwd.map(|c| (id, c)))
.collect();
let code_local_paths: Vec<(EntityId, String)> = pane_group
.as_ref(ctx)
.code_view_local_paths(ctx)
.filter_map(|(id, cwd)| cwd.map(|c| (id, c)))
.collect();
let code_diff_local_paths: Vec<(EntityId, String)> = pane_group
.as_ref(ctx)
.code_diff_view_local_paths(ctx)
.filter_map(|(id, cwd)| cwd.map(|c| (id, c)))
.collect();
let notebook_local_paths: Vec<(EntityId, String)> = pane_group
.as_ref(ctx)
.file_notebook_local_paths(ctx)
.filter_map(|(id, cwd)| cwd.map(|c| (id, c)))
.collect();
let local_paths: Vec<(EntityId, String)> = code_local_paths
.into_iter()
.chain(notebook_local_paths)
.chain(code_diff_local_paths)
.collect();
// Get the focused terminal ID to prioritize it in the repo_to_terminal map
let focused_terminal_id = pane_group
.as_ref(ctx)
.active_session_view(ctx)
.map(|terminal_view| terminal_view.id());
self.working_directories_model.update(ctx, |model, ctx| {
model.refresh_working_directories_for_pane_group(
pane_group_id,
terminal_cwds,
local_paths,
focused_terminal_id,
ctx,
);
});
}
/// Opens the in-app network log pane as a right-split of the active pane
/// group. If a pane already exists for the current window, refreshes its
/// snapshot from the in-memory model and focuses it instead of opening
/// another one.
pub(crate) fn open_network_log_pane(&mut self, ctx: &mut ViewContext<Self>) {
let manager = NetworkLogPaneManager::handle(ctx);
if let Some(locator) = manager.as_ref(ctx).find_pane(ctx.window_id()) {
// Pane is already open: refresh its snapshot so any items
// captured since the last open are reflected, then focus it.
if let Some(tab) = self
.tabs
.iter()
.find(|tab| tab.pane_group.id() == locator.pane_group_id)
{
let pane_group = tab.pane_group.clone();
let network_log_view = pane_group.read(ctx, |pane_group, ctx| {
pane_group
.downcast_pane_by_id::<NetworkLogPane>(locator.pane_id)
.map(|pane| pane.network_log_view(ctx))
});
if let Some(network_log_view) = network_log_view {
network_log_view.update(ctx, |view, ctx| view.reload_snapshot(ctx));
}
}
self.focus_pane(locator, ctx);
return;
}
let pane = NetworkLogPane::new(ctx);
self.active_tab_pane_group().update(ctx, |pane_group, ctx| {
pane_group.add_pane_with_direction(
Direction::Right,
pane,
true, /* focus_new_pane */
ctx,
);
});
}
fn handle_file_tree_event(
&mut self,
pane_group: ViewHandle<PaneGroup>,
event: &pane_group::Event,
ctx: &mut ViewContext<Self>,
) {
match event {
pane_group::Event::AppStateChanged => {
ctx.dispatch_global_action("workspace:save_app", ());
self.refresh_working_directories_for_pane_group(&pane_group, ctx);
self.update_resource_center_action_target(ctx);
self.update_active_session(ctx);
if FeatureFlag::DirectoryTabColors.is_enabled() {
if let Some(tab) = self
.tabs
.iter_mut()
.find(|t| t.pane_group.id() == pane_group.id())
{
Self::sync_codebase_tab_color(tab, ctx);
}
}
}
pane_group::Event::ActiveSessionChanged => {
self.update_active_session(ctx);
// ctx.notify();
}
pane_group::Event::Escape => {
if self.current_workspace_state.is_resource_center_open {
self.current_workspace_state.is_resource_center_open = false;
ctx.notify()
}
}
pane_group::Event::Exited { add_to_undo_stack } => {
let tab = self.tabs.iter().position(|t| {
t.pane_group.id() == pane_group.id()
&& t.pane_group.window_id(ctx) == pane_group.window_id(ctx)
});
if let Some(tab_index) = tab {
self.close_tab(tab_index, true, *add_to_undo_stack, ctx);
}
}
pane_group::Event::PaneTitleUpdated => {
self.update_window_title(ctx);
ctx.notify();
}
pane_group::Event::ShowCommandSearch(options) => {
self.show_command_search(options.filter, &options.init_content, ctx);
}
pane_group::Event::SendNotification {
notification,
pane_id,
} => {
// Right now, all notifications are block-specific, but in the future,
// we might want to serialize a block-agnostic notification context.
let window_id = ctx.window_id();
let pane_group_id = pane_group.id();
let pane_id = *pane_id;
let notification_data = NotificationContext::BlockOrigin {
window_id,
pane_group_id,
pane_id,
};
if let Ok(notification_data_str) = serde_json::to_string(&notification_data) {
// Read the notification sound setting from SessionSettings
let play_sound = SessionSettings::as_ref(ctx)
.notifications
.play_notification_sound;
ctx.send_desktop_notification(
UserNotification::new_with_sound(
notification.title.to_string(),
notification.body.to_string(),
Some(notification_data_str),
play_sound,
),
move |workspace, notification_error, ctx| {
// Log to sentry if unknown error
if let NotificationSendError::Other { error_message } =
&notification_error
{
log::error!(
"Unknown error when sending notification. error_msg: {error_message}"
);
}
send_telemetry_from_ctx!(
TelemetryEvent::NotificationFailedToSend {
error: notification_error.clone()
},
ctx
);
// Surface error to user
workspace.show_notification_error(
notification_error,
pane_group_id,
pane_id,
ctx,
);
},
)
}
}
pane_group::Event::OpenSettings(section) => {
self.show_settings_with_section(Some(*section), ctx);
}
pane_group::Event::OpenAutoReloadModal { purchased_credits } => {
self.current_workspace_state
.is_enable_auto_reload_modal_open = true;
self.enable_auto_reload_modal.update(ctx, |modal, ctx| {
modal.set_selected_denomination_by_credits(*purchased_credits, ctx);
});
ctx.notify();
}
#[cfg(not(target_family = "wasm"))]
pane_group::Event::OpenPluginInstructionsPane(agent, kind) => {
self.open_plugin_instructions_pane(*agent, *kind, ctx);
}
pane_group::Event::AskAIAssistant(ask_type) => self.ask_ai_assistant(ask_type, ctx),
pane_group::Event::SyncInput(input_type) => {
self.process_sync_event_for_all_synced_pane_groups(input_type, ctx);
}
pane_group::Event::TerminalViewStateChanged => ctx.notify(),
pane_group::Event::OnboardingTutorialCompleted => {
self.pending_session_config_tab_config_chip = false;
self.show_session_config_tab_config_chip = false;
self.pending_session_config_tab_config_chip_tutorial = None;
ctx.notify();
}
pane_group::Event::InvalidatedActiveConversation => {
self.handle_task_status_reset(pane_group.id(), ctx);
}
pane_group::Event::ExecuteCommand(execute_event) => {
// Clear the task status indicator as soon as the user runs a command. If a command is
// run as part of the task, leave the task marked as in-progress.
if !execute_event.source.is_ai_command() {
self.handle_task_status_reset(pane_group.id(), ctx);
}
}
pane_group::Event::OpenWorkflowModalWithCommand(command) => {
self.open_workflow_with_command(command.clone(), ctx)
}
pane_group::Event::OpenCloudWorkflowForEdit(workflow_id) => self
.open_workflow_with_existing(
*workflow_id,
&OpenWarpDriveObjectSettings::default(),
ctx,
),
pane_group::Event::OpenWorkflowModalWithTemporary(workflow) => {
self.open_workflow_with_temporary(*workflow.clone(), ctx)
}
pane_group::Event::OpenAIFactCollection { sync_id } => {
// Entrypoint from AI blocklist
let page = if sync_id.is_some() {
AIFactPage::RuleEditor { sync_id: *sync_id }
} else {
AIFactPage::Rules
};
self.open_ai_fact_collection_pane(None, Some(page), ctx);
send_telemetry_from_ctx!(
TelemetryEvent::KnowledgePaneOpened {
entrypoint: KnowledgePaneEntrypoint::AIBlocklist,
},
ctx
);
}
pane_group::Event::OpenPromptEditor => {
self.open_prompt_editor(PromptEditorOpenSource::InputContextMenu, ctx);
}
pane_group::Event::OpenAgentToolbarEditor => {
self.open_agent_toolbar_editor(AgentToolbarEditorMode::AgentView, ctx);
}
pane_group::Event::OpenCLIAgentToolbarEditor => {
self.open_agent_toolbar_editor(AgentToolbarEditorMode::CLIAgent, ctx);
}
pane_group::Event::OpenMCPSettingsPage { page } => {
// Open the MCP servers settings page to the list page
self.open_mcp_servers_page(page.unwrap_or_default(), None, ctx);
}
pane_group::Event::OpenAddRulePane => {
// Open the AI Fact Collection pane directly with the Rule Editor page for adding a new rule
self.open_ai_fact_collection_pane(
None,
Some(AIFactPage::RuleEditor { sync_id: None }),
ctx,
);
send_telemetry_from_ctx!(
TelemetryEvent::KnowledgePaneOpened {
entrypoint: KnowledgePaneEntrypoint::SlashCommand,
},
ctx
);
}
#[cfg_attr(not(feature = "local_fs"), allow(unused_variables))]
pane_group::Event::OpenFileInWarp { path, session } => {
#[cfg(feature = "local_fs")]
{
let layout = *EditorSettings::as_ref(ctx).open_file_layout.value();
self.open_file_notebook(path.clone(), Some(session.clone()), layout, ctx);
}
}
pane_group::Event::MoveToSpace {
cloud_object_type_and_id,
space,
} => {
self.move_to_drive_space(*cloud_object_type_and_id, *space, ctx);
}
pane_group::Event::OpenWarpDriveLink {
open_warp_drive_args,
} => {
let object_found = CloudModel::as_ref(ctx)
.get_by_uid(&open_warp_drive_args.server_id.uid())
.is_some();
if !object_found {
self.toast_stack.update(ctx, |toast_stack, ctx| {
let toast = DismissibleToast::error(String::from(
"Resource not found or access denied",
));
toast_stack.add_ephemeral_toast(toast, ctx);
});
ctx.notify();
return;
}
let server_id = open_warp_drive_args.server_id;
match open_warp_drive_args.object_type {
ObjectType::Notebook => self.open_notebook(
&NotebookSource::Existing(SyncId::ServerId(server_id)),
&open_warp_drive_args.settings,
ctx,
true,
),
ObjectType::Workflow => self.view_in_and_focus_warp_drive(
WarpDriveItemId::Object(CloudObjectTypeAndId::Workflow(SyncId::ServerId(
server_id,
))),
ctx,
),
ObjectType::GenericStringObject(GenericStringObjectFormat::Json(
JsonObjectType::EnvVarCollection,
)) => self.view_in_and_focus_warp_drive(
WarpDriveItemId::Object(CloudObjectTypeAndId::from_generic_string_object(
GenericStringObjectFormat::Json(JsonObjectType::EnvVarCollection),
SyncId::ServerId(server_id),
)),
ctx,
),
ObjectType::Folder => self.view_in_and_focus_warp_drive(
WarpDriveItemId::Object(CloudObjectTypeAndId::Folder(SyncId::ServerId(
server_id,
))),
ctx,
),
_ => {
log::warn!("Attempted to open an unsupported Warp Drive link")
}
}
}
#[cfg(feature = "local_fs")]
pane_group::Event::OpenCodeInWarp {
source,
layout,
line_col,
} => {
self.open_code(source.clone(), *layout, *line_col, false, &[], ctx);
}
#[cfg(feature = "local_fs")]
pane_group::Event::PreviewCodeInWarp { source } => {
self.open_code(
source.clone(),
EditorLayout::SplitPane, // preview always uses split pane
None, // no line/column for preview
true, // preview
&[],
ctx,
);
}
pane_group::Event::OpenCodeDiff { view } => {
self.open_code_diff(view.clone(), ctx);
}
pane_group::Event::AttachPathAsContext { path } => {
self.attach_path_as_context(path.clone(), ctx);
}
pane_group::Event::AttachPlanAsContext { ai_document_id } => {
self.attach_plan_as_context(*ai_document_id, ctx);
}
pane_group::Event::CDToDirectory { path } => {
self.cd_to_directory(path.clone(), ctx);
}
pane_group::Event::OpenDirectoryInNewTab { path } => {
self.open_directory_in_new_tab(path.clone(), ctx);
}
pane_group::Event::RunTabConfigSkill { path } => {
self.run_tab_config_skill(path, ctx);
}
pane_group::Event::OpenCodeReviewPane(arg) => {
self.open_code_review_panel_from_arg(arg, pane_group.clone(), ctx);
}
pane_group::Event::ToggleCodeReviewPane(arg) => {
self.toggle_right_panel(&pane_group, ctx);
let active_conversation_id = arg.terminal_view.upgrade(ctx).and_then(|tv| {
BlocklistAIHistoryModel::as_ref(ctx).active_conversation_id(tv.id())
});
if let Some(conversation_id) = active_conversation_id {
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, _| {
history_model.set_has_code_review_opened_to_true(conversation_id);
});
}
}
pane_group::Event::RunWorkflow {
workflow,
workflow_source,
workflow_selection_source,
argument_override,
} => {
self.run_workflow_in_active_input(
workflow,
*workflow_source,
*workflow_selection_source,
argument_override.clone(),
TerminalSessionFallbackBehavior::default(),
ctx,
);
}
pane_group::Event::InvokeEnvVarCollection {
env_var_collection,
in_subshell,
} => self.invoke_environment_variables(
env_var_collection.as_cloud_env_var_collection().clone(),
*in_subshell,
ctx,
),
pane_group::Event::CloseSharedSessionPaneRequested { pane_id } => {
if *SessionSettings::as_ref(ctx).should_confirm_close_session {
self.show_close_session_confirmation_dialog(
OpenDialogSource::ClosePane {
pane_group_id: pane_group.id(),
pane_id: *pane_id,
},
ctx,
);
} else {
self.close_pane(pane_group.id(), *pane_id, ctx);
}
}
pane_group::Event::MaximizePaneToggled => {
ctx.notify();
}
pane_group::Event::FocusPaneGroup => {
for (index, tab) in self.tabs.iter().enumerate() {
if tab.pane_group.id() == pane_group.id() {
self.activate_tab(index, ctx);
break;
}
}
}
pane_group::Event::FocusPane { pane_to_focus } => {
let Some(tab_index_to_focus) = self
.tabs
.iter()
.position(|tab| tab.pane_group.as_ref(ctx).has_pane_id(*pane_to_focus))
else {
log::warn!("Could not find tab to focus pane");
return;
};
self.activate_tab(tab_index_to_focus, ctx);
// TODO(CODE-266): This should focus the correct pane in the tab,
// but for some reason application focus is not being moved to
// the correct pane.
if let Some(tab) = self.tabs.get_mut(tab_index_to_focus) {
tab.pane_group.update(ctx, |pane_group, ctx| {
if let Some(pane) = pane_group.pane_by_id(*pane_to_focus) {
pane.focus(ctx);
}
});
}
}
pane_group::Event::FocusPaneInWorkspace { locator } => {
// Focus an existing pane by its locator (used when avoiding duplicate file panes during undo close pane)
self.focus_pane(*locator, ctx);
}
pane_group::Event::ViewInWarpDrive(id) => {
self.view_in_and_focus_warp_drive(*id, ctx);
}
// If focused pane contains an object, then set selected state in WD to that object
pane_group::Event::PaneFocused => {
self.current_workspace_state.close_all_modals();
// Re-evaluate which region is focused and update pane dimming accordingly.
self.update_pane_dimming_for_current_focus_region(ctx);
let mut active_object_open_in_pane = false;
// Case 1: if workflow, get workflow ID via TerminalView input
if let Some(terminal_view) = self
.active_tab_pane_group()
.as_ref(ctx)
.active_session_view(ctx)
{
#[cfg(feature = "local_fs")]
if self.active_tab_pane_group().as_ref(ctx).right_panel_open {
self.setup_code_review_panel(None, ctx);
}
// Get the ID of the workflow that's active in the pane, if there is one.
let active_workflow_id = terminal_view
.read(ctx, |terminal_view, _| terminal_view.input().clone())
.read(ctx, |input, _| {
input.workflows_info_box_open_workflow_cloud_id()
});
if let Some(workflow_id) = active_workflow_id {
self.set_selected_object(
Some(WarpDriveItemId::Object(
CloudObjectTypeAndId::from_id_and_type(
workflow_id,
ObjectType::Workflow,
),
)),
ctx,
);
active_object_open_in_pane = true;
}
}
// Case 2: if notebook, get notebook ID via NotebookPane
let pane_group = self.active_tab_pane_group().as_ref(ctx);
let focused_pane_id = Some(pane_group.focused_pane_id(ctx));
if let Some(notebook_pane) = pane_group.notebook_pane_by_pane_id(focused_pane_id) {
let notebook_id = notebook_pane
.notebook_view(ctx)
.as_ref(ctx)
.notebook_id(ctx);
if let Some(notebook_id) = notebook_id {
self.set_selected_object(
Some(WarpDriveItemId::Object(
CloudObjectTypeAndId::from_id_and_type(
notebook_id,
ObjectType::Notebook,
),
)),
ctx,
);
active_object_open_in_pane = true;
}
}
// Case 3: if a env var, get the env var id
else if let Some(env_var_collection_pane) =
pane_group.env_var_collection_pane_by_pane_id(focused_pane_id)
{
let env_var_collection_id = env_var_collection_pane
.env_var_collection_view(ctx)
.as_ref(ctx)
.env_var_collection_id(ctx);
if let Some(env_var_collection_id) = env_var_collection_id {
self.set_selected_object(
Some(WarpDriveItemId::Object(
CloudObjectTypeAndId::from_generic_string_object(
GenericStringObjectFormat::Json(
crate::cloud_object::JsonObjectType::EnvVarCollection,
),
env_var_collection_id,
),
)),
ctx,
);
active_object_open_in_pane = true;
}
}
// Case 4: if the pane is a workflow pane
else if let Some(workflow_pane) =
pane_group.workflow_pane_by_pane_id(focused_pane_id)
{
let workflow_id = workflow_pane.get_view(ctx).as_ref(ctx).workflow_id();
self.set_selected_object(
Some(WarpDriveItemId::Object(
CloudObjectTypeAndId::from_id_and_type(
workflow_id,
ObjectType::Workflow,
),
)),
ctx,
);
active_object_open_in_pane = true;
}
// Case 5: ai fact pane
else if let Some(_ai_fact_pane) =
pane_group.ai_fact_pane_by_pane_id(focused_pane_id)
{
self.set_selected_object(Some(WarpDriveItemId::AIFactCollection), ctx);
active_object_open_in_pane = true;
}
if !active_object_open_in_pane {
self.set_selected_object(None, ctx);
self.set_focused_index(None, ctx);
}
let focused_terminal_view_id = {
let pane_group = self.active_tab_pane_group().as_ref(ctx);
pane_group
.terminal_view_from_pane_id(pane_group.focused_pane_id(ctx), ctx)
.map(|tv| tv.id())
};
let ambient_agent_task_id = self
.get_active_session_terminal_model(ctx)
.and_then(|model| model.lock().ambient_agent_task_id());
self.notify_terminal_focus_change(
focused_terminal_view_id,
ambient_agent_task_id,
ctx,
);
}
pane_group::Event::RepoChanged => {
self.refresh_working_directories_for_pane_group(&pane_group, ctx);
#[cfg(feature = "local_fs")]
if self.active_tab_pane_group().as_ref(ctx).right_panel_open {
self.setup_code_review_panel(None, ctx);
}
if FeatureFlag::DirectoryTabColors.is_enabled() {
if let Some(tab) = self
.tabs
.iter_mut()
.find(|t| t.pane_group.id() == pane_group.id())
{
Self::sync_codebase_tab_color(tab, ctx);
}
}
}
#[cfg(feature = "local_fs")]
pane_group::Event::RemoteRepoNavigated {
host_id,
indexed_path,
} => {
use galaxy_util::standardized_path::StandardizedPath;
if let Ok(std_path) = StandardizedPath::try_new(indexed_path) {
let remote_id = RemoteRepositoryIdentifier::new(host_id.clone(), std_path);
let pane_group_id = pane_group.id();
if let Some(file_tree_view) = self
.working_directories_model
.as_ref(ctx)
.get_file_tree_view(pane_group_id)
{
file_tree_view.update(ctx, |view, ctx| {
view.set_remote_root_directories(std::slice::from_ref(&remote_id), ctx);
});
}
}
}
#[cfg(not(feature = "local_fs"))]
pane_group::Event::RemoteRepoNavigated { .. } => {}
pane_group::Event::DroppedOnTabBar { origin, pane_id } => {
if let Some(hovered_tab_index) = self.hovered_tab_index {
match hovered_tab_index {
TabBarHoverIndex::BeforeTab(workspace_tab_index) => {
// If an editor tab is dropped into a new position in the workspace tab group,
// create a new pane and insert it into the group.
let pane = if let ActionOrigin::EditorTab(editor_tab_index) = origin {
pane_group.update(ctx, |pane_group, ctx| {
pane_group.remove_editor_tab_for_move(
*pane_id,
*editor_tab_index,
ctx,
)
})
} else {
// Otherwise, move the existing pane's contents into the workspace tab group.
pane_group.update(ctx, |pane_group, ctx| {
pane_group.remove_pane_for_move(pane_id, ctx)
})
};
if let Some(pane) = pane {
self.add_tab_from_existing_pane(pane, workspace_tab_index, ctx);
// If the setting is enabled, preserve the color of the original pane's
// tab for the newly created tab.
if *TabSettings::as_ref(ctx).preserve_active_tab_color.value() {
if let Some(source_tab) = self
.tabs
.iter()
.find(|t| t.pane_group.id() == pane_group.id())
{
let selected = source_tab.selected_color;
let default = source_tab.default_directory_color;
self.tabs[self.active_tab_index].selected_color = selected;
self.tabs[self.active_tab_index].default_directory_color =
default;
}
}
}
}
#[cfg_attr(target_family = "wasm", allow(unused_variables))]
TabBarHoverIndex::OverTab(workspace_tab_index) => {
#[cfg(not(target_family = "wasm"))]
{
let prefers_tabbed_editor_view = FeatureFlag::TabbedEditorView
.is_enabled()
&& *EditorSettings::as_ref(ctx)
.prefer_tabbed_editor_view
.value();
let target_pane_group =
self.get_pane_group_view(workspace_tab_index);
let target_code_view = target_pane_group.and_then(|pane_group| {
pane_group
.as_ref(ctx)
.code_panes(ctx)
.next()
.map(|(_, view)| view)
});
if let ActionOrigin::EditorTab(editor_tab_index) = origin {
// If the target pane group has an existing editor view, we want to open the dragged file as a tab in it.
if let Some(target_code_view) = target_code_view {
// If an editor tab is dropped onto its originating workspace tab, ensure that tab becomes the active editor within that workspace tab.
if self.active_tab_index() == workspace_tab_index {
target_code_view.update(ctx, |view, ctx| {
view.set_active_tab_index(*editor_tab_index, ctx);
});
return;
}
let moved_file_path =
pane_group.update(ctx, |pane_group, ctx| {
pane_group.code_pane_by_id(*pane_id).and_then(
|pane| {
pane.file_view(ctx).update(
ctx,
|file_view, ctx| {
let moved_file_path = file_view
.tab_at(*editor_tab_index)
.and_then(|t| t.path());
file_view.remove_tab_for_move(
*editor_tab_index,
ctx,
);
moved_file_path
},
)
},
)
});
// After removing the file from the origin's editor, we want to open it in the target's editor.
if let Some(path) = moved_file_path {
target_code_view.update(ctx, |view, ctx| {
view.open_or_focus_existing(Some(path), None, ctx);
});
}
return;
} else if let Some(target_pane_group) = target_pane_group {
// Otherwise, we want to open the dragged file in a new editor pane in the hovered tab's pane group.
pane_group.update(ctx, |pane_group, ctx| {
pane_group
.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(|new_pane| {
target_pane_group.update(
ctx,
|pane_group, ctx| {
pane_group.add_pane_with_direction(
Direction::Right,
new_pane,
true, /* focus_new_pane */
ctx,
);
},
);
})
});
}
self.set_active_tab_index(workspace_tab_index, ctx);
return;
} else if prefers_tabbed_editor_view
&& pane_id.is_code_pane()
&& target_code_view.is_some()
&& self.active_tab_index() != workspace_tab_index
{
// If a CodePane is dropped onto a tab with another CodePane and grouping is enabled, we want to merge them.
if let Some(target_code_view) = target_code_view.as_ref() {
pane_group.update(ctx, |pane_group, ctx| {
if let Some(pane) = pane_group.code_pane_by_id(*pane_id)
{
pane.file_view(ctx).update(
ctx,
|source_file_view, ctx| {
target_code_view.update(
ctx,
|target_code_view, ctx| {
target_code_view.merge_tabs(
source_file_view,
ctx,
);
},
);
},
);
pane_group.remove_pane_for_move(pane_id, ctx);
}
});
}
self.set_active_tab_index(workspace_tab_index, ctx);
return;
}
}
#[allow(unreachable_code)]
// Otherwise, we want to perform a left split on the root.
pane_group.update(ctx, |pane_group, ctx| {
pane_group.move_pane_with_root_split(
*pane_id,
Direction::Left,
ctx,
);
});
}
}
}
}
pane_group::Event::SwitchTabFocusAndMovePane {
tab_idx,
pane_id,
hidden_pane_preview_direction,
} => {
#[cfg(feature = "local_fs")]
let prefers_tabbed_editor_view = FeatureFlag::TabbedEditorView.is_enabled()
&& *EditorSettings::as_ref(ctx)
.prefer_tabbed_editor_view
.value();
#[cfg(not(feature = "local_fs"))]
let prefers_tabbed_editor_view = false;
// If a code pane is being dragged over a workspace tab with an existing code pane,
// we don't allow it to be placed freely. Instead, it should be merged into the existing
// code pane in the target tab (handled above in the OverTab case).
let should_not_move_pane = prefers_tabbed_editor_view
&& pane_id.is_code_pane()
&& self
.get_pane_group_view(*tab_idx)
.map(|target_pane_group| {
target_pane_group.read(ctx, |pane_group, _| pane_group.has_code_panes())
})
.unwrap_or(false);
// If we are already on this tab, then we should just make sure the pane is hidden.
if self.active_tab_index() == *tab_idx || should_not_move_pane {
pane_group.update(ctx, |pane_group, ctx| {
pane_group.hide_pane_for_move(*pane_id, ctx)
});
return;
};
if let Some(pane) = pane_group.update(ctx, |pane_group, ctx| {
pane_group.remove_pane_for_move(pane_id, ctx)
}) {
self.set_active_tab_index(*tab_idx, ctx);
self.active_tab_pane_group().update(ctx, |pane_group, ctx| {
pane_group.add_pane_as_hidden(pane, *hidden_pane_preview_direction, ctx)
});
}
}
pane_group::Event::UpdateHoveredTabIndex { tab_hover_index } => {
self.hovered_tab_index = Some(*tab_hover_index);
ctx.notify();
}
pane_group::Event::ClearHoveredTabIndex => self.hovered_tab_index = None,
pane_group::Event::OpenWarpDriveObjectInPane(uid) => {
self.open_warp_drive_object_in_new_pane(uid, ctx);
}
pane_group::Event::OpenSuggestedAgentModeWorkflowModal { workflow_and_id } => {
self.open_suggested_agent_mode_workflow_modal(workflow_and_id, ctx);
}
pane_group::Event::OpenSuggestedRuleModal { rule_and_id } => {
self.open_suggested_rule_modal(rule_and_id, ctx);
}
pane_group::Event::AnonymousUserSignup => {
self.initiate_user_signup(AnonymousUserSignupEntrypoint::RenotificationBlock, ctx);
}
pane_group::Event::OpenDriveObjectShareDialog {
cloud_object_type_and_id,
invitee_email,
source,
} => {
self.open_object_sharing_settings(
*cloud_object_type_and_id,
invitee_email.clone(),
*source,
ctx,
);
}
pane_group::Event::OpenPalette {
mode,
source,
query,
} => self.open_palette_action(*mode, *source, query.as_deref(), ctx),
pane_group::Event::FileUploadCommand {
upload_id,
command: _,
remote_pane_id,
local_pane_id,
} => {
self.file_upload_sessions
.local_to_remote_map
.insert(*local_pane_id, *remote_pane_id);
self.file_upload_sessions
.local_to_upload_id_map
.insert(*local_pane_id, (*remote_pane_id, *upload_id));
self.file_upload_sessions
.upload_id_to_local_map
.insert((*remote_pane_id, *upload_id), *local_pane_id);
}
pane_group::Event::FileUploadPasswordPending { local_pane_id } => {
if let Some(remote_pane_id) = self
.file_upload_sessions
.local_to_remote_map
.get(local_pane_id)
{
let (_, upload_id) = self
.file_upload_sessions
.local_to_upload_id_map
.get(local_pane_id)
.expect("Local session should map to upload ID");
let terminal_view =
self.active_tab_pane_group().read(ctx, |pane_group, ctx| {
pane_group
.terminal_view_from_pane_id(*remote_pane_id, ctx)
.expect("PaneGroup should find remote pane ID")
});
terminal_view.update(ctx, |terminal_view, ctx| {
terminal_view
.ssh_file_upload()
.update(ctx, |file_upload, ctx| {
file_upload.prompt_for_file_upload_password(*upload_id, ctx);
});
});
}
}
pane_group::Event::FileUploadFinished {
local_pane_id,
exit_code,
} => {
if let Some(remote_pane_id) = self
.file_upload_sessions
.local_to_remote_map
.get(local_pane_id)
{
let (_, upload_id) = self
.file_upload_sessions
.local_to_upload_id_map
.get(local_pane_id)
.expect("Local session should map to upload ID");
let terminal_view =
self.active_tab_pane_group().read(ctx, |pane_group, ctx| {
pane_group
.terminal_view_from_pane_id(*remote_pane_id, ctx)
.expect("PaneGroup should find remote pane ID")
});
terminal_view.update(ctx, |terminal_view, ctx| {
terminal_view
.ssh_file_upload()
.update(ctx, |file_upload, ctx| {
file_upload.file_upload_finished(*upload_id, exit_code, ctx);
});
});
}
}
pane_group::Event::OpenFileUploadSession {
remote_pane_id,
upload_id,
} => {
// Find the local pane handling the upload.
let local_pane_id = *self
.file_upload_sessions
.upload_id_to_local_map
.get(&(*remote_pane_id, *upload_id))
.expect("Upload ID should map to a local session");
// Toggle the visibility of the local pane.
let local_pane_open =
self.active_tab_pane_group().update(ctx, |pane_group, ctx| {
pane_group.toggle_pane_visibility_for_job(local_pane_id.into(), ctx)
});
// Inform the remote pane of the state of the local pane.
let terminal_view = self.active_tab_pane_group().read(ctx, |pane_group, ctx| {
pane_group
.terminal_view_from_pane_id(*remote_pane_id, ctx)
.expect("PaneGroup should find remote pane ID")
});
terminal_view.update(ctx, |terminal_view, ctx| {
terminal_view
.ssh_file_upload()
.update(ctx, |file_upload, ctx| {
file_upload.local_session_state_changed(
*upload_id,
local_pane_open,
ctx,
);
});
});
}
pane_group::Event::TerminateFileUploadSession {
remote_pane_id,
upload_id,
} => {
// Find the local pane handling the upload.
let local_pane_id = *self
.file_upload_sessions
.upload_id_to_local_map
.get(&(*remote_pane_id, *upload_id))
.expect("Upload ID should map to a local session");
// Close the local pane.
self.active_tab_pane_group().update(ctx, |pane_group, ctx| {
pane_group.close_pane(local_pane_id.into(), ctx);
});
}
pane_group::Event::ShowToast {
message,
flavor,
pane_id,
} => {
let pane_group_id = pane_group.id();
self.toast_stack.update(ctx, |toast_stack, ctx| {
let mut toast = DismissibleToast::new(message.clone(), *flavor);
if let Some(pane_id) = pane_id {
let locator = PaneViewLocator {
pane_group_id,
pane_id: *pane_id,
};
toast = toast.with_on_body_click(move |ctx| {
ctx.dispatch_typed_action(&WorkspaceAction::FocusPane(locator));
});
}
toast_stack.add_ephemeral_toast(toast, ctx);
});
}
pane_group::Event::SignupAnonymousUser { entrypoint } => {
self.initiate_user_signup(*entrypoint, ctx);
}
pane_group::Event::OpenThemeChooser => {
self.show_theme_chooser_for_custom_theme(ctx);
}
pane_group::Event::OpenConversationHistory => {
self.open_palette_action(
PaletteMode::Conversations,
PaletteSource::ConversationManager,
None,
ctx,
);
}
pane_group::Event::OpenAddPromptPane { initial_content } => {
if UserWorkspaces::as_ref(ctx).personal_drive(ctx).is_some() {
self.update_warp_drive_view(ctx, |drive_view, ctx| {
if let Some(initial_content) = initial_content {
drive_view.create_workflow_with_content(
Space::Personal,
None,
initial_content.clone(),
true, // is_for_agent_mode
ctx,
);
} else {
drive_view.open_cloud_object_dialog(
DriveObjectType::AgentModeWorkflow,
Space::Personal,
None,
ctx,
);
}
});
}
}
pane_group::Event::OpenFilesPalette { source } => {
self.open_palette_action(PaletteMode::Files, *source, None, ctx);
}
pane_group::Event::ToggleLeftPanel {
target_view,
force_open,
} => {
let is_target_active =
self.left_panel_view
.read(ctx, |left_panel, _| match target_view {
LeftPanelTargetView::FileTree => left_panel.is_file_tree_active(),
LeftPanelTargetView::WarpDrive => left_panel.is_warp_drive_active(),
});
if self.active_tab_pane_group().as_ref(ctx).left_panel_open && is_target_active {
// No-op if we are forcing the target to open when it is already active.
if !*force_open {
self.toggle_left_panel(ctx);
}
} else {
if !self.active_tab_pane_group().as_ref(ctx).left_panel_open {
self.toggle_left_panel(ctx);
}
self.left_panel_view.update(ctx, |left_panel, ctx| {
let action = match target_view {
LeftPanelTargetView::FileTree => LeftPanelAction::ProjectExplorer,
LeftPanelTargetView::WarpDrive => LeftPanelAction::WarpDrive,
};
left_panel.handle_action_with_force_open(&action, *force_open, ctx);
});
}
}
#[cfg(feature = "local_fs")]
pane_group::Event::OpenFileWithTarget {
path,
target,
line_col,
} => {
self.open_file_with_target(
path.clone(),
target.clone(),
*line_col,
CodeSource::Link {
path: path.clone(),
range_start: None,
range_end: None,
},
ctx,
);
}
#[cfg(feature = "local_fs")]
pane_group::Event::FileRenamed { old_path, new_path } => {
self.rename_tabs_with_file_path(old_path, new_path, ctx);
}
#[cfg(feature = "local_fs")]
pane_group::Event::FileDeleted { path } => {
self.close_tabs_with_file_path(path, ctx);
}
pane_group::Event::OpenAgentProfileEditor { profile_id } => {
self.open_execution_profile_editor_pane(None, *profile_id, ctx);
}
pane_group::Event::OpenEnvironmentManagementPane => {
self.open_environment_management_pane(
None,
crate::settings_view::environments_page::EnvironmentsPage::Create,
ctx,
);
}
pane_group::Event::OpenLspLogs { log_path } => {
self.open_lsp_logs(log_path, ctx);
}
pane_group::Event::LeftPanelToggled { is_open } => {
// Only handle visibility changes from the active pane group.
if pane_group.id() == self.active_tab_pane_group().id() {
self.left_panel_open = *is_open;
self.left_panel_view.update(ctx, |left_panel, ctx| {
left_panel.on_left_panel_visibility_changed(*is_open, ctx);
});
}
}
pane_group::Event::InsertCodeReviewComments {
repo_path,
comments,
diff_mode,
open_code_review,
} => {
if let Some(open_code_review) = open_code_review {
self.open_code_review_panel_from_arg(open_code_review, pane_group.clone(), ctx);
}
self.working_directories_model
.update(ctx, |working_directories, ctx| {
working_directories.insert_code_review_comments(
pane_group.id(),
repo_path.as_path(),
comments,
diff_mode,
ctx,
)
});
}
pane_group::Event::OpenCodeReviewPaneAndScrollToComment {
open_code_review,
comment,
diff_mode,
} => {
self.open_code_review_panel_from_arg(open_code_review, pane_group.clone(), ctx);
let Some(repo_path) = &open_code_review.repo_path else {
return;
};
self.working_directories_model
.update(ctx, |working_directories, ctx| {
working_directories.upsert_flattened_code_review_comments(
repo_path,
vec![comment.clone()],
ctx,
);
});
let Some(code_review_view) = self
.working_directories_model
.as_ref(ctx)
.get_code_review_view(pane_group.id(), repo_path)
else {
return;
};
code_review_view.update(ctx, |code_review, ctx| {
code_review.navigate_to_imported_comment(comment.id, diff_mode.clone(), ctx);
});
}
pane_group::Event::ImportAllCodeReviewComments {
comments,
diff_mode,
open_code_review,
} => {
self.open_code_review_panel_from_arg(open_code_review, pane_group.clone(), ctx);
let Some(repo_path) = &open_code_review.repo_path else {
return;
};
self.working_directories_model
.update(ctx, |working_directories, ctx| {
working_directories.upsert_flattened_code_review_comments(
repo_path,
comments.clone(),
ctx,
);
});
if let Some(code_review_view) = self
.working_directories_model
.as_ref(ctx)
.get_code_review_view(pane_group.id(), repo_path.as_path())
{
code_review_view.update(ctx, |code_review_view, ctx| {
code_review_view.set_diff_base(diff_mode.clone(), ctx);
code_review_view.expand_comment_list(ctx);
});
}
}
pane_group::Event::ShowCloudAgentCapacityModal { variant } => {
self.open_cloud_agent_capacity_modal(*variant, ctx);
}
pane_group::Event::FreeTierLimitCheckTriggered => {
self.free_tier_limit_check_triggered = true;
}
}
}
fn handle_theme_chooser_event(
&mut self,
event: &ThemeChooserEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
ThemeChooserEvent::Click => self.focus_theme_chooser(ctx),
ThemeChooserEvent::Close(mode) => {
self.save_theme_chooser(mode, ctx);
self.restore_previous_workspace_state(ctx);
}
ThemeChooserEvent::OpenThemeCreatorModal => {
self.open_theme_creator_modal(ctx);
}
ThemeChooserEvent::OpenThemeDeletionModal(theme_kind) => {
self.open_theme_deletion_modal(theme_kind.clone(), ctx);
}
};
}
fn handle_resource_center_event(
&mut self,
event: &ResourceCenterEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
ResourceCenterEvent::Close => {
self.current_workspace_state.is_resource_center_open = false;
ctx.notify();
}
ResourceCenterEvent::Escape => {
// Calls terminal view focus to determine where focus should be
if let Some(pane_group_handle) = self.get_pane_group_view(self.active_tab_index) {
pane_group_handle.update(ctx, |pane_group, ctx| {
if let Some(terminal_view_handle) = pane_group.active_session_view(ctx) {
terminal_view_handle.update(ctx, |terminal, ctx| {
terminal.redetermine_global_focus(ctx);
});
}
});
}
}
};
}
fn show_command_search(
&mut self,
query_filter: Option<search::QueryFilter>,
init_content: &InitContent,
ctx: &mut ViewContext<Self>,
) {
// View-only sessions should not show command search
if self.is_readonly_shared_session_active(ctx) {
return;
}
// Close all overlays including chip menus before opening command search
self.close_all_overlays(ctx);
if let Some(session_id) = self.active_session_id(ctx) {
let active_input_handle = self.get_active_input_view_handle(ctx);
let initial_query = match init_content {
InitContent::FromInputBuffer => {
if let Some(input_handle) = &active_input_handle {
input_handle.read(ctx, |input, ctx| input.buffer_text(ctx))
} else {
"".to_owned()
}
}
InitContent::Custom(query) => query.to_owned(),
};
let session_context = active_input_handle.as_ref().and_then(|input_handle| {
input_handle.read(ctx, |input, ctx| input.completion_session_context(ctx))
});
let ai_execution_context = session_context
.as_ref()
.map(|session_context| WarpAiExecutionContext::new(&session_context.session));
let menu_positioning = active_input_handle
.as_ref()
.map_or_else(MenuPositioning::default, |input_handle| {
input_handle.read(ctx, |input, ctx| input.menu_positioning(ctx))
});
if !self.current_workspace_state.is_command_search_open {
send_telemetry_from_ctx!(
TelemetryEvent::CommandSearchOpened {
has_initial_query: !initial_query.is_empty(),
},
ctx
);
}
// Make sure we close any already-open input suggestions panel.
if let Some(input_handle) = &active_input_handle {
input_handle.update(ctx, |input, ctx| {
input.close_input_suggestions(false, ctx);
});
};
self.current_workspace_state.is_command_search_open = true;
self.command_search_view.update(ctx, |view, ctx| {
view.reset_state(
session_id,
session_context,
initial_query,
query_filter,
menu_positioning,
ai_execution_context,
ctx,
);
});
let tip = match query_filter {
Some(search::QueryFilter::History) => Tip::Action(TipAction::HistorySearch),
_ => Tip::Action(TipAction::CommandSearch),
};
self.tips_completed.update(ctx, |tips_completed, ctx| {
mark_feature_used_and_write_to_user_defaults(tip, tips_completed, ctx);
ctx.notify();
});
ctx.notify();
ctx.focus(&self.command_search_view);
} else {
log::error!("Command search keybinding triggered but no session is active!");
}
}
fn get_active_input_view_handle(&self, app: &AppContext) -> Option<ViewHandle<Input>> {
app.view(self.active_tab_pane_group())
.active_session_view(app)
.map(|terminal_view_handle| app.view(&terminal_view_handle).input().clone())
}
fn get_active_session_terminal_model(
&self,
app: &AppContext,
) -> Option<Arc<FairMutex<TerminalModel>>> {
self.active_tab_pane_group()
.as_ref(app)
.active_session_terminal_model(app)
}
/// Replace the active terminal input's buffer with `contents`. Adds to the
/// undo stack.
pub fn set_active_terminal_input_contents_and_focus_app(
&mut self,
contents: &str,
ctx: &mut ViewContext<Self>,
) {
let window_id = ctx.window_id();
if let Some(active_input_view_handle) = self.get_active_input_view_handle(ctx) {
active_input_view_handle.update(ctx, |input_view, input_ctx| {
input_view.replace_buffer_content(contents, input_ctx);
});
ctx.windows().show_window_and_focus_app(window_id);
ctx.notify();
} else {
log::error!("workspace::view::fill_input(): no active input view handle to fill");
}
}
/// Insert the given command that should open a subshell. And set a flag that we should
/// automatically bootstrap AKA "warpify" that subshell if we support it. No-op if there is
/// no active terminal session.
pub fn insert_subshell_command_and_bootstrap_if_supported(
&mut self,
command: &str,
shell: Option<ShellType>,
ctx: &mut ViewContext<Self>,
) {
self.active_tab_pane_group()
.update(ctx, |pane_group_view, pane_group_ctx| {
pane_group_view
.active_session_view(pane_group_ctx)
.map(|terminal_view_handle| {
terminal_view_handle.update(
pane_group_ctx,
|terminal_view, terminal_view_ctx| {
terminal_view.insert_subshell_command_and_bootstrap_if_supported(
command,
shell,
terminal_view_ctx,
);
},
)
})
});
}
/// Update the active session model state.
fn update_active_session(&mut self, ctx: &mut ViewContext<Self>) {
let pane_group_handle = self.active_tab_pane_group();
let file_tree_and_global_search_are_enabled = {
#[cfg(feature = "local_fs")]
{
Self::should_enable_file_tree_and_global_search_for_pane_group(
self.active_tab_pane_group().as_ref(ctx),
)
}
#[cfg(not(feature = "local_fs"))]
{
false
}
};
// Update working directories for the current pane group
let pane_group_handle = pane_group_handle.clone();
self.refresh_working_directories_for_pane_group(&pane_group_handle, ctx);
if let Some(terminal_handle) = pane_group_handle.as_ref(ctx).active_session_view(ctx) {
#[cfg_attr(not(feature = "local_fs"), allow(unused_variables))]
let (session, path_if_local, is_local, is_wsl_session, session_id, pwd) =
terminal_handle.read(ctx, |terminal, ctx| {
let active_session_id = terminal.active_block_session_id();
let session = active_session_id
.and_then(|id| terminal.sessions_model().as_ref(ctx).get(id));
let path_if_local = terminal.active_session_path_if_local(ctx);
let is_local = terminal.active_session_is_local(ctx);
let is_wsl_session = session.as_ref().map(|s| s.is_wsl()).unwrap_or(false);
let pwd = terminal.pwd();
(
session,
path_if_local,
is_local,
is_wsl_session,
active_session_id,
pwd,
)
});
let window_id = ctx.window_id();
let working_directory_clone = path_if_local.clone();
let path_if_local_clone = path_if_local.clone();
ActiveSession::handle(ctx).update(ctx, |active_session, ctx| {
active_session.set_session_state(
window_id,
session,
path_if_local_clone.clone(),
Some(terminal_handle.id()),
ctx,
);
});
CodebaseIndexManager::handle(ctx).update(ctx, |manager, _ctx| {
if let Some(working_directory) = working_directory_clone {
manager.handle_active_session_changed(working_directory.as_path());
}
});
let is_remote = matches!(is_local, Some(false));
let is_unsupported_session = is_wsl_session;
// Check whether this remote session has an active remote server
// connection (or is in the process of connecting). This is only
// true for Auto SSH Warpification (mode 1) sessions where
// `connect_session` was called at `InitShell` time.
let has_remote_server = is_remote
&& FeatureFlag::SshRemoteServer.is_enabled()
&& session_id
.is_some_and(|sid| RemoteServerManager::as_ref(ctx).session(sid).is_some());
// When the session has a remote server, tell it about the current
// directory so it can start indexing and push repo metadata back.
#[cfg(feature = "local_fs")]
if has_remote_server {
if let (Some(sid), Some(cwd)) = (session_id, pwd) {
RemoteServerManager::handle(ctx).update(ctx, |mgr, ctx| {
mgr.navigate_to_directory(sid, cwd, ctx);
});
}
}
let enablement = CodingPanelEnablementState::from_session_env(
file_tree_and_global_search_are_enabled,
is_remote,
is_unsupported_session,
has_remote_server,
);
self.left_panel_view.update(ctx, |left_panel, ctx| {
left_panel.update_coding_panel_enablement(enablement, ctx);
});
#[cfg(feature = "local_fs")]
{
self.right_panel_view.update(ctx, |right_panel, ctx| {
right_panel.update_session_env(is_remote, is_wsl_session, ctx);
});
if self.active_tab_pane_group().as_ref(ctx).right_panel_open {
self.setup_code_review_panel(None, ctx);
}
}
} else {
let enablement = CodingPanelEnablementState::from_session_env(
file_tree_and_global_search_are_enabled,
false,
false,
false,
);
self.left_panel_view.update(ctx, |left_panel, ctx| {
left_panel.update_coding_panel_enablement(enablement, ctx);
});
#[cfg(feature = "local_fs")]
{
self.right_panel_view.update(ctx, |right_panel, ctx| {
right_panel.update_session_env(false, false, ctx);
});
}
}
}
fn handle_warp_drive_event(&mut self, event: &DrivePanelEvent, ctx: &mut ViewContext<Self>) {
match event {
DrivePanelEvent::RunWorkflow(workflow) => {
self.run_cloud_workflow_in_active_input(
workflow.as_ref().clone(),
WorkflowSelectionSource::WarpDrive,
TerminalSessionFallbackBehavior::default(),
ctx,
);
}
DrivePanelEvent::InvokeEnvironmentVariables {
env_var_collection,
in_subshell,
} => {
self.invoke_environment_variables(
env_var_collection.as_ref().clone(),
*in_subshell,
ctx,
);
}
DrivePanelEvent::OpenTeamSettingsPage => {
self.show_settings_with_section(Some(SettingsSection::Teams), ctx);
}
DrivePanelEvent::OpenImportModal {
owner,
initial_folder_id,
} => self.open_import_modal(*owner, initial_folder_id, ctx),
DrivePanelEvent::OpenWorkflowModalWithNew {
space,
initial_folder_id,
} => {
self.open_workflow_modal(*space, *initial_folder_id, ctx);
}
DrivePanelEvent::OpenWorkflowModalWithCloudWorkflow(workflow_id) => {
self.open_workflow_with_existing(
*workflow_id,
&OpenWarpDriveObjectSettings::default(),
ctx,
);
}
DrivePanelEvent::OpenSearch => {
self.open_palette_action(
PaletteMode::WarpDrive,
PaletteSource::WarpDrive,
None,
ctx,
);
}
DrivePanelEvent::OpenNotebook(source) => {
self.open_notebook(source, &OpenWarpDriveObjectSettings::default(), ctx, true)
}
DrivePanelEvent::OpenEnvVarCollection(source) => {
self.open_env_var_collection(source, false, ctx)
}
DrivePanelEvent::OpenWorkflowInPane(source, mode) => self.open_workflow_in_pane(
source,
&OpenWarpDriveObjectSettings::default(),
*mode,
ctx,
),
DrivePanelEvent::OpenAIFactCollection => {
self.open_ai_fact_collection_pane(None, None, ctx);
send_telemetry_from_ctx!(
TelemetryEvent::KnowledgePaneOpened {
entrypoint: KnowledgePaneEntrypoint::WarpDrive,
},
ctx
);
}
DrivePanelEvent::OpenMCPServerCollection => {
self.show_settings_with_section(Some(SettingsSection::MCPServers), ctx);
send_telemetry_from_ctx!(
TelemetryEvent::MCPServerCollectionPaneOpened {
entrypoint: MCPServerCollectionPaneEntrypoint::WarpDrive,
},
ctx
);
}
DrivePanelEvent::FocusWarpDrive => {
ctx.focus(&self.left_panel_view);
}
DrivePanelEvent::OpenSharedObjectsCreationDeniedModal(object_type, team_uid) => {
self.open_shared_objects_creation_denied_modal(*object_type, *team_uid, ctx)
}
DrivePanelEvent::AttachPlanAsContext(id) => {
self.attach_plan_as_context(*id, ctx);
}
}
}
fn attach_plan_as_context(&mut self, id: AIDocumentId, ctx: &mut ViewContext<Self>) {
let Some(view) = self.active_session_view(ctx) else {
let window_id = ctx.window_id();
WorkspaceToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
let toast = DismissibleToast::default(
"No terminal pane open. Open a new pane to attach as context.".to_owned(),
);
toast_stack.add_ephemeral_toast(toast, window_id, ctx);
});
return;
};
// Check if the plan's conversation is already selected in the target terminal before
// attaching as context. This is to stop users from reattaching plans to conversations that already
// have them in context.
if let Some(conversation_id) =
AIDocumentModel::as_ref(ctx).get_conversation_id_for_document_id(&id)
{
if view
.as_ref(ctx)
.is_conversation_selected(&conversation_id, ctx)
{
let window_id = ctx.window_id();
WorkspaceToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
let toast =
DismissibleToast::default("This plan is already in context.".to_owned());
toast_stack.add_ephemeral_toast(toast, window_id, ctx);
});
return;
}
}
view.update(ctx, |session, ctx| {
session.attach_plan_as_context(id, ctx);
});
}
/// Runs a cloud workflow in whichever input is currently active.
fn run_cloud_workflow_in_active_input(
&mut self,
workflow: CloudWorkflow,
workflow_selection_source: WorkflowSelectionSource,
fallback_behavior: TerminalSessionFallbackBehavior,
ctx: &mut ViewContext<Self>,
) {
let owner = workflow.clone().permissions.owner.into();
self.run_workflow_in_active_input(
&WorkflowType::Cloud(Box::new(workflow.clone())),
owner,
workflow_selection_source,
None,
fallback_behavior,
ctx,
);
ctx.focus_self();
ctx.notify();
self.set_selected_object(
Some(WarpDriveItemId::Object(workflow.cloud_object_type_and_id())),
ctx,
);
}
/// Focus and return the active terminal input. If there is no active terminal input (either
/// because a command is running or because there are no terminal panes), this may create a new
/// terminal pane according to the [`UnavailableTerminalBehavior`].
fn focus_terminal_input(
&mut self,
object_id: Option<CloudObjectTypeAndId>,
fallback_behavior: TerminalSessionFallbackBehavior,
ctx: &mut ViewContext<Self>,
) -> Option<ViewHandle<TerminalView>> {
let active_pane_group = self.active_tab_pane_group();
// If there's an active terminal session and it's not busy, return it.
// If there is no terminal session open, add a terminal pane to the right and return the new terminal view handle.
let terminal_view_handle = active_pane_group
.as_ref(ctx)
.active_session_view(ctx)
.unwrap_or_else(|| {
let active_pane_group = self.active_tab_pane_group();
active_pane_group.update(ctx, |pane_group, ctx| {
pane_group.add_terminal_pane(Direction::Right, None /*chosen_shell*/, ctx);
});
active_pane_group
.as_ref(ctx)
.active_session_view(ctx)
.unwrap()
});
let is_env_var_block = terminal_view_handle.read(ctx, |terminal_view, ctx| {
terminal_view.has_active_env_var_block(ctx)
});
if self.is_input_box_visible(ctx) {
active_pane_group.update(ctx, |pane_group, ctx| pane_group.focus_active_session(ctx));
return Some(terminal_view_handle);
} else if is_env_var_block {
terminal_view_handle.update(ctx, |terminal_view, ctx| {
terminal_view.cancel_env_var_block(ctx);
});
active_pane_group.update(ctx, |pane_group, ctx| pane_group.focus_active_session(ctx));
return Some(terminal_view_handle);
} else if fallback_behavior != TerminalSessionFallbackBehavior::OpenIfNeeded {
// The active terminal exists but is busy, and the fallback behavior is
// RequireExisting or OpenIfNone. In those cases, show a toast and no-op.
self.toast_stack.update(ctx, |toast_stack, ctx| {
let mut toast = DismissibleToast::error(
"A command in this session is still running.".to_string(),
);
if let Some(id) = object_id {
toast = toast.with_object_id(id.uid());
}
toast_stack.add_ephemeral_toast(toast, ctx);
});
return None;
}
// There's no available session and we were asked not to create one.
if fallback_behavior == TerminalSessionFallbackBehavior::RequireExisting {
return None;
}
// Either:
// * There's no active session
// * The active session is busy but the fallback behavior is OpenIfNeeded
// In this case, open a new terminal pane to the right.
if !ContextFlag::CreateNewSession.is_enabled() {
self.toast_stack.update(ctx, |toast_stack, ctx| {
let toast =
DismissibleToast::error("Cannot open a new terminal session".to_string());
toast_stack.add_ephemeral_toast(toast, ctx);
});
return None;
}
active_pane_group.as_ref(ctx).active_session_view(ctx)
}
/// Opens the LSP log file in a new terminal pane using `tail -f`.
fn open_lsp_logs(&mut self, log_path: &PathBuf, ctx: &mut ViewContext<Self>) {
use crate::workflows::local_workflows::tail_command_for_shell;
let active_pane_group = self.active_tab_pane_group();
// Add a terminal pane to the right
active_pane_group.update(ctx, |pane_group, ctx| {
pane_group.add_terminal_pane(PaneGroupDirection::Right, None, ctx);
});
let Some(terminal_view_handle) = active_pane_group.as_ref(ctx).active_session_view(ctx)
else {
log::error!("Could not get terminal view handle when attempting to open LSP logs.");
return;
};
terminal_view_handle.update(ctx, |terminal, ctx| {
let shell_family = terminal.shell_family(ctx);
let tail_command = tail_command_for_shell(shell_family, log_path);
terminal.set_pending_command(&tail_command, ctx);
});
}
fn run_tab_config_skill(&mut self, path: &Path, ctx: &mut ViewContext<Self>) {
if !AISettings::as_ref(ctx).is_any_ai_enabled(ctx) {
return;
}
let Some(terminal_view_handle) =
self.focus_terminal_input(None, TerminalSessionFallbackBehavior::OpenIfNeeded, ctx)
else {
return;
};
let prefix = CLIAgentSessionsModel::as_ref(ctx)
.session(terminal_view_handle.id())
.map(|session| session.agent.skill_command_prefix())
.unwrap_or("/");
let prompt = format!("{prefix}update-tab-config Update {} to...", path.display());
terminal_view_handle.update(ctx, |terminal_view, ctx| {
terminal_view.input().update(ctx, |input, ctx| {
input.clear_buffer_and_reset_undo_stack(ctx);
input.set_input_mode_agent(true, ctx);
input.ensure_agent_mode_for_ai_features(true, ctx);
input.replace_buffer_content(&prompt, ctx);
input.focus_input_box(ctx);
});
});
}
/// Runs a workflow in whichever terminal input is currently active.
/// No-ops if the active session is long-running.
fn run_workflow_in_active_input(
&mut self,
workflow: &WorkflowType,
workflow_source: WorkflowSource,
workflow_selection_source: WorkflowSelectionSource,
argument_override: Option<HashMap<String, String>>,
fallback_behavior: TerminalSessionFallbackBehavior,
ctx: &mut ViewContext<Self>,
) {
// View-only sessions should not be able to run workflows
if self.is_readonly_shared_session_active(ctx) {
return;
}
if self.auth_state.is_anonymous_or_logged_out()
&& workflow.as_workflow().is_agent_mode_workflow()
{
AuthManager::handle(ctx).update(ctx, |auth_manager, ctx| {
auth_manager.attempt_login_gated_feature(
"Run Agent Mode Workflow",
AuthViewVariant::RequireLoginCloseable,
ctx,
)
});
return;
}
if let Some(terminal_view_handle) =
self.focus_terminal_input(workflow.object_id(), fallback_behavior, ctx)
{
let terminal_input =
terminal_view_handle.read(ctx, |terminal_view, _| terminal_view.input().clone());
terminal_input.update(ctx, |input, ctx| {
input.show_workflows_info_box_on_workflow_selection(
workflow.clone(),
workflow_source,
workflow_selection_source,
argument_override,
ctx,
);
ctx.notify();
});
}
}
/// Inserts given command into active Input Editor, optionally replacing the current buffer. No-ops if
/// there is no active terminal pane open, with an input box active.
fn insert_in_input(
&mut self,
content: &str,
replace_buffer: bool,
should_submit: bool,
ensure_agent_mode: bool,
ctx: &mut ViewContext<Self>,
) {
let active_input_handle = self.get_active_input_view_handle(ctx);
if let Some(active_input_handle) = active_input_handle {
active_input_handle.update(ctx, |input, ctx| {
if replace_buffer {
input.replace_buffer_content(content, ctx);
} else {
input.append_to_buffer(content, ctx);
}
if ensure_agent_mode {
input.ensure_agent_mode_for_ai_features(true, ctx);
}
if should_submit {
input.input_enter(ctx);
}
ctx.notify();
});
}
}
fn invoke_environment_variables(
&mut self,
env_var_collection: CloudEnvVarCollection,
in_subshell: bool,
ctx: &mut ViewContext<Self>,
) {
if self.is_readonly_shared_session_active(ctx) {
return;
}
if let Some(terminal_view_handle) = self.focus_terminal_input(
Some(env_var_collection.cloud_object_type_and_id()),
TerminalSessionFallbackBehavior::default(),
ctx,
) {
send_telemetry_from_ctx!(
TelemetryEvent::EnvVarCollectionInvoked(EnvVarTelemetryMetadata {
object_id: env_var_collection.id.into_server().map(Into::into),
team_uid: env_var_collection.permissions.owner.into(),
space: env_var_collection.space(ctx).into(),
}),
ctx
);
terminal_view_handle.update(ctx, |view, ctx| {
view.invoke_environment_variables(env_var_collection, in_subshell, ctx);
ctx.notify();
});
}
}
fn handle_command_search_event(
&mut self,
event: &CommandSearchEvent,
ctx: &mut ViewContext<Self>,
) {
use CommandSearchEvent::*;
let Some(active_input_handle) = self.get_active_input_view_handle(ctx) else {
return;
};
match event {
Close {
query: query_when_closed,
filter: filter_when_closed,
} => {
self.current_workspace_state.is_command_search_open = false;
active_input_handle.update(ctx, |input, ctx| {
input.handle_command_search_closed(query_when_closed, filter_when_closed, ctx);
ctx.notify();
});
ctx.notify();
}
Blur => {
self.current_workspace_state.is_command_search_open = false;
ctx.notify();
}
ItemSelected { query, payload } => {
use CommandSearchItemAction::*;
match payload.as_ref() {
AcceptHistory(AcceptedHistoryItem {
command,
linked_workflow_data,
}) => {
// Switch to shell input mode so the history command is
// treated as a shell command, not an agent prompt.
active_input_handle.update(ctx, |input, ctx| {
input.set_input_mode_terminal(false, ctx);
input.replace_buffer_content(command.as_str(), ctx);
input.focus_input_box(ctx);
});
if let Some(linked_workflow_data) = linked_workflow_data {
active_input_handle.update(ctx, |input, ctx| {
if let Some((workflow_type, workflow_source)) =
linked_workflow_data.linked_workflow(ctx)
{
input.show_workflow_info_box_for_history_command(
command.as_str(),
workflow_type,
workflow_source,
WorkflowSelectionSource::UniversalSearch,
ctx,
);
}
ctx.notify();
});
}
}
ExecuteHistory(command) => {
active_input_handle.update(ctx, |input, ctx| {
input.try_execute_command(command.as_str(), ctx);
ctx.notify();
});
}
AcceptWorkflow(accepted) => {
let (workflow, workflow_source) = match accepted {
AcceptedWorkflow::Cloud { id, source } => {
let Some(cloud_workflow) =
CloudModel::as_ref(ctx).get_workflow(id).cloned()
else {
self.toast_stack.update(ctx, |view, ctx| {
view.add_ephemeral_toast(
DismissibleToast::error(
"This workflow is no longer available.".to_string(),
),
ctx,
);
});
return;
};
(WorkflowType::Cloud(Box::new(cloud_workflow)), *source)
}
AcceptedWorkflow::Local {
workflow, source, ..
} => ((**workflow).clone(), *source),
};
active_input_handle.update(ctx, |input, ctx| {
input.show_workflows_info_box_on_workflow_selection(
workflow,
workflow_source,
WorkflowSelectionSource::UniversalSearch,
None,
ctx,
);
ctx.notify();
});
}
TranslateUsingWarpAI => {
active_input_handle.update(ctx, |input, ctx| {
let content = format!("# {query}");
input.focus_input_box(ctx);
// Mimic the user replacing the editor text, as the replacement
// is done in response to an explicit user action.
input.user_replace_editor_text(content.as_str(), ctx);
ctx.notify();
});
}
AcceptNotebook(sync_id) => {
self.open_notebook(
&NotebookSource::Existing(*sync_id),
&OpenWarpDriveObjectSettings::default(),
ctx,
true,
);
}
AcceptEnvVarCollection(env_var_collection) => {
self.invoke_environment_variables(
(**env_var_collection).clone(),
false,
ctx,
);
}
OpenWarpAI => {
if !AISettings::as_ref(ctx).is_any_ai_enabled(ctx) {
return;
}
if FeatureFlag::AgentMode.is_enabled() {
let active_terminal_view = self.active_session_view(ctx).expect("There must be an active terminal view if the user selected a command search result");
active_terminal_view.update(ctx, |terminal_view, ctx| {
terminal_view.ask_blocklist_ai(
&AskAIType::FromAICommandSearch {
query: Arc::new(query.to_owned()),
},
ctx,
)
});
} else {
active_input_handle.update(ctx, |input, ctx| {
input.replace_buffer_content("", ctx);
});
self.ask_ai_assistant(
&AskAIType::FromAICommandSearch {
query: Arc::new(query.to_string()),
},
ctx,
);
}
}
AcceptAIQuery(ai_query) => {
let active_terminal_view = self.active_session_view(ctx).expect("There must be an active terminal view if the user selected a command search result");
active_terminal_view.update(ctx, |terminal_view, ctx| {
terminal_view.set_ai_input_mode_with_query(Some(ai_query), ctx);
});
}
RunAIQuery(ai_query) => {
let active_terminal_view = self.active_session_view(ctx).expect("There must be an active terminal view if the user selected a command search result");
active_terminal_view.update(ctx, |terminal_view, ctx| {
terminal_view.set_ai_input_mode_with_query(Some(ai_query), ctx);
});
active_input_handle.update(ctx, |input, ctx| input.input_enter(ctx));
}
}
}
Resize => {
// A resize of universal search should write the app snapshot to sqlite.
ctx.dispatch_global_action("workspace:save_app", ());
}
}
}
fn handle_window_settings_changed_event(
&mut self,
event: &WindowSettingsChangedEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
WindowSettingsChangedEvent::BackgroundOpacity { .. } => {
ctx.notify();
}
WindowSettingsChangedEvent::LeftPanelVisibilityAcrossTabs { .. } => {
if self.left_panel_visibility_across_tabs_enabled(ctx) {
self.left_panel_open = self
.active_tab_pane_group()
.read(ctx, |pane_group, _| pane_group.left_panel_open);
}
}
WindowSettingsChangedEvent::ZoomLevel { .. } => {
self.update_titlebar_height(ctx);
}
_ => {}
}
}
fn handle_update_manager_event(
&mut self,
event: &UpdateManagerEvent,
ctx: &mut ViewContext<Self>,
) {
let UpdateManagerEvent::ObjectOperationComplete { result } = event else {
return;
};
let cloud_model = CloudModel::as_ref(ctx);
let object_id = result
.server_id
.map(|server_id| server_id.uid())
.or_else(|| result.client_id.map(|client_id| client_id.to_string()));
if let Some(object_id) = object_id {
if let Some(object) = cloud_model.get_by_uid(&object_id) {
let cloud_object_type_and_id = object.cloud_object_type_and_id();
if !object.should_show_activity_toasts() {
// Early exit for objects that don't show toasts.
return;
}
if let Some(message) = CloudObjectToastMessage::toast_message(
object,
&result.operation,
&result.success_type,
ctx,
) {
let workflow: Option<&CloudWorkflow> = object.into();
let cloned_workflow = workflow.cloned();
let env_var_collection: Option<&CloudEnvVarCollection> = object.into();
let cloned_env_var_collection = env_var_collection.cloned();
let notebook: Option<&CloudNotebook> = object.into();
let cloned_notebook = notebook.cloned();
self.toast_stack
.update(ctx, |view, ctx| match result.success_type {
OperationSuccessType::Success => {
let object_id_clone = object_id.clone();
let mut new_toast =
DismissibleToast::success(message).with_object_id(object_id);
if let Some(notebook) = cloned_notebook {
if (matches!(result.operation, ObjectOperation::Create { .. })
|| result.operation == ObjectOperation::Update)
&& notebook.model().ai_document_id.is_some()
{
// This is a plan. Only show the "Plan synced" toast if the plan is open in
// the currently active pane group, to avoid confusing users
// who are working in a different tab.
if notebook.model().ai_document_id.is_some_and(
|ai_doc_id| {
self.active_tab_pane_group()
.as_ref(ctx)
.contains_ai_document(&ai_doc_id, ctx)
},
) {
new_toast = DismissibleToast::success(
"Plan synced to your Galaxy Drive".to_string(),
)
.with_object_id(object_id_clone)
.with_link(
ToastLink::new("View".to_string())
.with_onclick_action(
WorkspaceAction::ViewObjectInWarpDrive(
WarpDriveItemId::Object(
CloudObjectTypeAndId::Notebook(
notebook.id,
),
),
),
),
);
} else {
return;
}
}
}
if let Some(workflow) = cloned_workflow {
if matches!(result.operation, ObjectOperation::Create { .. })
|| result.operation == ObjectOperation::Update
{
new_toast = new_toast.with_link(
ToastLink::new("View".to_string()).with_onclick_action(
WorkspaceAction::ViewObjectInWarpDrive(
WarpDriveItemId::Object(
CloudObjectTypeAndId::Workflow(workflow.id),
),
),
),
)
}
}
if result.operation == ObjectOperation::Trash {
new_toast = new_toast.with_link(
ToastLink::new("Undo".to_string()).with_onclick_action(
WorkspaceAction::UndoTrash(cloud_object_type_and_id),
),
)
}
view.add_ephemeral_toast(new_toast, ctx);
}
OperationSuccessType::Failure => {
// Suppress failure toasts for plan notebook updates
// that are not visible in the active pane group.
// Plan notebooks auto-save in the background, and
// showing persistent error toasts for transient
// failures is confusing when the user didn't
// initiate the action.
if let Some(notebook) = &cloned_notebook {
if result.operation == ObjectOperation::Update
&& notebook.model().ai_document_id.is_some_and(
|ai_doc_id| {
!self
.active_tab_pane_group()
.as_ref(ctx)
.contains_ai_document(&ai_doc_id, ctx)
},
)
{
return;
}
}
let new_toast =
DismissibleToast::error(message).with_object_id(object_id);
view.add_persistent_toast(new_toast, ctx);
}
OperationSuccessType::Rejection => {
let new_toast = if let Some(workflow) = cloned_workflow {
DismissibleToast::error(message)
.with_link(
ToastLink::new(
"Check out the latest version and try again."
.to_string(),
)
.with_onclick_action(
WorkspaceAction::HandleConflictingWorkflow(
workflow.id,
),
),
)
.with_object_id(object_id)
} else if let Some(env_var_collection) = cloned_env_var_collection {
DismissibleToast::error(message)
.with_link(
ToastLink::new(
"Check out the latest version and try again."
.to_string(),
)
.with_onclick_action(
WorkspaceAction::HandleConflictingEnvVarCollection(
env_var_collection.id,
),
),
)
.with_object_id(object_id)
} else {
return;
};
view.add_persistent_toast(new_toast, ctx);
}
OperationSuccessType::FeatureNotAvailable => {
if cloned_workflow.is_some() {
log::error!(
"Getting feature not available message for workflows"
);
}
}
OperationSuccessType::Denied(_) => {
let new_toast =
DismissibleToast::error(message).with_object_id(object_id);
view.add_persistent_toast(new_toast, ctx);
}
});
}
}
}
// For confirmation toast of permadeletion
if let Some(n) = result.num_objects {
if let Some(message) = CloudObjectToastMessage::toast_deletion_confirm_message(
n,
&result.operation,
&result.success_type,
) {
self.toast_stack
.update(ctx, |view, ctx| match result.success_type {
OperationSuccessType::Success => {
let new_toast = DismissibleToast::success(message);
view.add_ephemeral_toast(new_toast, ctx);
}
OperationSuccessType::Failure => {
let new_toast: DismissibleToast<WorkspaceAction> =
DismissibleToast::error(message);
view.add_ephemeral_toast(new_toast, ctx);
}
OperationSuccessType::Rejection => {
let new_toast = DismissibleToast::error(message);
view.add_ephemeral_toast(new_toast, ctx);
}
OperationSuccessType::FeatureNotAvailable => {
log::error!("Should not get deletion confirmation message when feature is not available. Operation type {:?}", result.operation);
}
OperationSuccessType::Denied(_) => {
let new_toast = DismissibleToast::error(message);
view.add_ephemeral_toast(new_toast, ctx);
},
})
}
}
// If this was a successful update on a workflow - caused by this client - then we may need
// to update the contents of the workflow info box to represent the new synced state.
if result.success_type == OperationSuccessType::Success
&& result.operation == ObjectOperation::Update
{
let cloud_model = CloudModel::as_ref(ctx);
let updated_object = cloud_model
.get_by_uid(&result.server_id.expect("Expect server id on success").uid());
if let Some(CloudObjectTypeAndId::Workflow(workflow_id)) =
updated_object.map(|o| o.cloud_object_type_and_id())
{
self.maybe_refresh_workflow_info_box_and_input(&workflow_id, ctx)
}
}
// If this was a successful personal object creation, then potentially show the sharing
// onboarding block.
if result.success_type == OperationSuccessType::Success
&& matches!(result.operation, ObjectOperation::Create { .. })
{
if let Some(created_object) = result
.server_id
.and_then(|id| CloudModel::as_ref(ctx).get_by_uid(&id.uid()))
{
if created_object.space(ctx) == Space::Personal
&& created_object.renders_in_warp_drive()
{
self.check_and_trigger_drive_sharing_onboarding_block(
created_object.cloud_object_type_and_id(),
ctx,
);
}
}
}
}
fn restore_previous_workspace_state(&mut self, ctx: &mut ViewContext<Self>) {
if let Some(previous_state) = self.previous_workspace_state.take() {
self.current_workspace_state = previous_state;
// Assumption: at most one of the states will be active.
// If none are, then we focus the terminal view instead.
if self.current_workspace_state.is_palette_open {
self.open_command_palette(ctx);
} else if self.current_workspace_state.is_theme_chooser_open {
self.focus_theme_chooser(ctx);
} else if self.current_workspace_state.is_resource_center_open {
ctx.focus(&self.resource_center_view);
} else if self.current_workspace_state.is_ai_assistant_panel_open {
ctx.focus(&self.ai_assistant_panel);
} else if self
.current_workspace_state
.is_close_session_confirmation_dialog_open
{
ctx.focus(&self.close_session_confirmation_dialog);
} else if self
.current_workspace_state
.is_rewind_confirmation_dialog_open
{
ctx.focus(&self.rewind_confirmation_dialog);
} else if self.current_workspace_state.is_native_quit_modal_open {
ctx.focus(&self.native_modal);
} else {
ctx.focus_self();
}
self.cancel_tab_rename(ctx);
}
}
fn should_keep_theme(system_theme: SystemTheme, ctx: &mut ViewContext<Self>) -> bool {
if system_theme == ctx.system_theme() {
let respect_system_theme = respect_system_theme(ThemeSettings::as_ref(ctx));
if let RespectSystemTheme::On { .. } = respect_system_theme {
return true;
}
}
false
}
fn save_theme_chooser(&mut self, mode: &ThemeChooserMode, ctx: &mut ViewContext<Self>) {
let keep_theme = match mode {
ThemeChooserMode::SystemAgnostic => true,
ThemeChooserMode::SystemLight => Workspace::should_keep_theme(SystemTheme::Light, ctx),
ThemeChooserMode::SystemDark => Workspace::should_keep_theme(SystemTheme::Dark, ctx),
};
if keep_theme {
self.keep_theme(ctx);
} else {
self.revert_theme(ctx);
}
}
fn revert_theme(&mut self, ctx: &mut ViewContext<Self>) {
AppearanceManager::handle(ctx).update(ctx, |appearance_manager, ctx| {
appearance_manager.clear_transient_theme(ctx);
});
self.current_workspace_state.is_theme_chooser_open = false;
self.previous_theme = None;
ctx.notify();
}
fn keep_theme(&mut self, ctx: &mut ViewContext<Self>) {
self.current_workspace_state.is_theme_chooser_open = false;
self.previous_theme = None;
ctx.notify();
}
fn apply_update(&mut self, ctx: &mut ViewContext<Self>) {
if let Ok(autoupdate::ReadyForRelaunch::Yes) = autoupdate::apply_update(self, ctx) {
autoupdate::initiate_relaunch_for_update(ctx);
}
self.close_tab_bar_overflow_menu(ctx);
}
fn download_new_version(&mut self, ctx: &mut ViewContext<Self>) {
autoupdate::manually_download_new_version(ctx);
self.close_tab_bar_overflow_menu(ctx);
}
fn active_session_ps1_grid_info(&self, app: &AppContext) -> Option<(BlockGrid, SizeInfo)> {
self.get_active_session_terminal_model(app)
.and_then(|model| {
let lock = model.lock();
lock.prompt_grid()
.cloned()
.zip(Some(*lock.block_list().size()))
})
.or_else(|| {
(0..self.tabs.len()).find_map(|i| {
self.get_pane_group_view(i)?
.as_ref(app)
.active_session_terminal_model(app)
.and_then(|model| {
let lock = model.lock();
lock.prompt_grid()
.cloned()
.zip(Some(*lock.block_list().size()))
})
})
})
}
fn show_close_session_confirmation_dialog(
&mut self,
source: OpenDialogSource,
ctx: &mut ViewContext<Self>,
) {
self.close_session_confirmation_dialog
.update(ctx, |view, _| {
view.set_open_confirmation_source(source);
});
self.current_workspace_state
.is_close_session_confirmation_dialog_open = true;
ctx.focus(&self.close_session_confirmation_dialog);
ctx.notify();
}
pub fn show_rewind_confirmation_dialog(
&mut self,
source: RewindDialogSource,
ctx: &mut ViewContext<Self>,
) {
self.rewind_confirmation_dialog.update(ctx, |view, _| {
view.set_rewind_source(source);
});
self.current_workspace_state
.is_rewind_confirmation_dialog_open = true;
ctx.focus(&self.rewind_confirmation_dialog);
ctx.notify();
}
pub fn show_delete_conversation_confirmation_dialog(
&mut self,
source: DeleteConversationDialogSource,
ctx: &mut ViewContext<Self>,
) {
self.delete_conversation_confirmation_dialog
.update(ctx, |view, _| {
view.set_source(source);
});
self.current_workspace_state
.is_delete_conversation_confirmation_dialog_open = true;
ctx.focus(&self.delete_conversation_confirmation_dialog);
ctx.notify();
}
pub fn show_native_modal(
&mut self,
dialog: AlertDialogWithCallbacks<AppModalCallback>,
ctx: &mut ViewContext<Self>,
) {
self.native_modal.update(ctx, |view, ctx| {
view.set_alert_dialog(dialog);
ctx.notify();
});
self.current_workspace_state.is_native_quit_modal_open = true;
ctx.focus(&self.native_modal);
ctx.notify();
}
fn handle_native_modal_event(&mut self, event: &NativeModalEvent, ctx: &mut ViewContext<Self>) {
match event {
NativeModalEvent::Close => {
self.current_workspace_state.is_native_quit_modal_open = false;
ctx.notify();
}
}
}
/// Mock pressing a button on the native quit modal. This function has an unusual signature so
/// that the workspace view is not borrowed while the button press is handled.
#[cfg(any(test, feature = "integration_tests"))]
pub fn press_native_modal_button(
handle: &ViewHandle<Self>,
button_index: usize,
app: &mut AppContext,
) {
use super::native_modal::NativeModalAction;
let modal_handle = handle.as_ref(app).native_modal.clone();
modal_handle.update(app, |modal, ctx| {
modal.handle_action(&NativeModalAction::TriggerButtonCallback(button_index), ctx);
});
}
#[cfg(any(test, feature = "integration_tests"))]
pub fn is_native_quit_modal_open(&self, ctx: &AppContext) -> bool {
self.current_workspace_state.is_native_quit_modal_open
&& self.native_modal.as_ref(ctx).has_alert_dialog()
}
fn show_settings(&mut self, ctx: &mut ViewContext<Self>) {
self.show_settings_with_section(None, ctx);
}
fn show_settings_with_section(
&mut self,
section: Option<SettingsSection>,
ctx: &mut ViewContext<Self>,
) {
self.close_all_overlays(ctx);
self.open_settings_pane(section, None, ctx);
}
fn show_settings_with_search(
&mut self,
search_query: &str,
section: Option<SettingsSection>,
ctx: &mut ViewContext<Self>,
) {
self.close_all_overlays(ctx);
self.open_settings_pane(section, Some(search_query), ctx);
}
/// Opens the team settings page and fills the invite field with the given email. This is used when linking directing to
/// settings with the intent of inviting a user.
pub fn show_team_settings_page_with_email_invite(
&mut self,
_email_invite: Option<&String>,
_ctx: &mut ViewContext<Self>,
) {
}
/// Opens the MCP servers settings page, optionally triggering auto-install of a gallery MCP.
pub fn open_mcp_servers_page(
&mut self,
page: MCPServersSettingsPage,
autoinstall_gallery_title: Option<&str>,
ctx: &mut ViewContext<Self>,
) {
self.show_settings_with_section(Some(SettingsSection::MCPServers), ctx);
self.settings_pane.update(ctx, |view, ctx| {
view.open_mcp_servers_page(page, autoinstall_gallery_title, ctx);
});
}
/// Shows the theme chooser so the user can change the active theme.
pub fn show_theme_chooser_for_active_theme(&mut self, ctx: &mut ViewContext<Self>) {
self.show_theme_chooser(Some(ThemeChooserMode::for_active_theme(ctx)), ctx)
}
pub fn show_theme_chooser_for_custom_theme(&mut self, ctx: &mut ViewContext<Self>) {
self.show_theme_chooser(None, ctx)
}
/// Shows the theme chooser so the user can change a specific theme.
pub fn show_theme_chooser(
&mut self,
theme_chooser_mode: Option<ThemeChooserMode>,
ctx: &mut ViewContext<Self>,
) {
let current_theme = active_theme_kind(ThemeSettings::as_ref(ctx), ctx);
self.close_tab_bar_overflow_menu(ctx);
self.current_workspace_state.close_all_left_panels();
// When showing the theme chooser, let's close the command palette
// in case it was used to open the theme chooser.
self.current_workspace_state.is_palette_open = false;
self.current_workspace_state.is_ctrl_tab_palette_open = false;
self.previous_workspace_state = Some(self.current_workspace_state);
self.current_workspace_state.is_ai_assistant_panel_open = false;
self.current_workspace_state.is_theme_chooser_open = true;
self.previous_theme = Some(current_theme);
self.theme_chooser_view.update(ctx, |view, ctx| {
view.record_open_theme(ctx);
if let Some(theme_chooser_mode) = theme_chooser_mode {
view.select_theme(theme_chooser_mode.into_theme_kind(ctx), ctx);
view.set_mode(theme_chooser_mode);
} else {
view.reload_and_set_latest_theme(ctx);
}
});
self.focus_theme_chooser(ctx);
}
pub fn show_keyboard_settings(
&mut self,
keybinding_name: Option<&str>,
ctx: &mut ViewContext<Self>,
) {
self.show_settings_with_section(Some(SettingsSection::Keybindings), ctx);
if let Some(keybinding_name) = keybinding_name {
self.settings_pane.update(ctx, |settings_pane, ctx| {
settings_pane.search_for_keybinding(keybinding_name, ctx);
});
}
}
pub fn is_theme_chooser_open(&self) -> bool {
self.current_workspace_state.is_theme_chooser_open
}
/// Returns whether the workspace is currently showing a settings file
/// error banner (i.e. settings_file_error is set and not dismissed).
#[cfg(feature = "integration_tests")]
pub fn has_settings_file_error_banner(&self) -> bool {
self.settings_file_error.is_some() && !self.settings_error_banner_dismissed
}
fn increase_font_size(&mut self, ctx: &mut ViewContext<Self>) {
self.adjust_terminal_font_size(FONT_SIZE_INCREMENT, ctx);
}
fn decrease_font_size(&mut self, ctx: &mut ViewContext<Self>) {
self.adjust_terminal_font_size(-FONT_SIZE_INCREMENT, ctx);
}
fn reset_font_size(&mut self, ctx: &mut ViewContext<Self>) {
self.set_terminal_font_size(MonospaceFontSize::default_value(), ctx);
}
fn increase_zoom(&mut self, ctx: &mut ViewContext<Self>) {
self.adjust_zoom(true /* increase */, ctx);
}
fn decrease_zoom(&mut self, ctx: &mut ViewContext<Self>) {
self.adjust_zoom(false /* increase */, ctx);
}
fn reset_zoom(&mut self, ctx: &mut ViewContext<Self>) {
WindowSettings::handle(ctx).update(ctx, |window_settings, ctx| {
report_if_error!(window_settings
.zoom_level
.set_value(ZoomLevel::default_value(), ctx));
});
}
fn adjust_zoom(&mut self, increase: bool, ctx: &mut ViewContext<Self>) {
let current_zoom = *WindowSettings::as_ref(ctx).zoom_level.value();
let Some(current_index) = crate::window_settings::ZoomLevel::VALUES
.iter()
.position(|zoom| *zoom == current_zoom)
else {
return;
};
let next_index = if increase {
(current_index + 1).min(crate::window_settings::ZoomLevel::VALUES.len() - 1)
} else {
current_index.saturating_sub(1)
};
WindowSettings::handle(ctx).update(ctx, |window_settings, ctx| {
report_if_error!(window_settings
.zoom_level
.set_value(crate::window_settings::ZoomLevel::VALUES[next_index], ctx));
});
}
fn adjust_terminal_font_size(&mut self, font_size_delta: f32, ctx: &mut ViewContext<Self>) {
let appearance = Appearance::as_ref(ctx);
let new_font_size = (appearance.monospace_font_size() + font_size_delta)
.clamp(MIN_FONT_SIZE, MAX_FONT_SIZE);
self.set_terminal_font_size(new_font_size, ctx);
}
fn set_terminal_font_size(&mut self, new_font_size: f32, ctx: &mut ViewContext<Self>) {
FontSettings::handle(ctx).update(ctx, |font_settings, ctx| {
report_if_error!(font_settings
.monospace_font_size
.set_value(new_font_size, ctx));
});
}
fn set_selected_object(&mut self, id: Option<WarpDriveItemId>, ctx: &mut ViewContext<Self>) {
// Set Warp drive index selected state
self.update_warp_drive_view(ctx, |drive_panel, ctx| {
drive_panel.set_selected_object(id, ctx);
});
// If WD open, show the highlighted object (force expand necessary ancestors)
if self.current_workspace_state.is_warp_drive_open {
if let Some(id) = id {
self.view_in_warp_drive(id, ctx);
ctx.notify();
}
}
}
fn toggle_mouse_reporting(&mut self, ctx: &mut ViewContext<Self>) {
let prev_mouse_reporting_enabled =
AltScreenReporting::handle(ctx).update(ctx, |reporting, ctx| {
let prev_mouse_reporting_enabled = *reporting.mouse_reporting_enabled.value();
reporting
.mouse_reporting_enabled
.set_value(!prev_mouse_reporting_enabled, ctx)
.expect("MouseReportingEnabled failed to serialize");
prev_mouse_reporting_enabled
});
let verb = if prev_mouse_reporting_enabled {
"disabled"
} else {
"enabled"
};
let mut message = format!("You {verb} mouse reporting.");
if let Some(keystroke) =
keybinding_name_to_keystroke("workspace:toggle_mouse_reporting", ctx)
{
let _ = write!(message, " Press {} to undo.", keystroke.displayed());
}
self.toast_stack.update(ctx, |view, ctx| {
let new_toast = DismissibleToast::default(message);
view.add_ephemeral_toast(new_toast, ctx);
});
}
fn toggle_scroll_reporting(&mut self, ctx: &mut ViewContext<Self>) {
AltScreenReporting::handle(ctx).update(ctx, |reporting, ctx| {
reporting
.scroll_reporting_enabled
.toggle_and_save_value(ctx)
.expect("ScrollReportingEnabled failed to serialize");
});
}
fn toggle_focus_reporting(&mut self, ctx: &mut ViewContext<Self>) {
AltScreenReporting::handle(ctx).update(ctx, |reporting, ctx| {
reporting
.focus_reporting_enabled
.toggle_and_save_value(ctx)
.expect("FocusReportingEnabled failed to serialize");
});
}
/// Handle an event from the referral theme status model, showing the reward modal if necessary
fn handle_referral_theme_status_event(
&mut self,
event: &ReferralThemeEvent,
ctx: &mut ViewContext<Self>,
) {
// A referral theme was activated, so we need to show the reward modal
// If the changelog modal is currently shown or pending, then we delay showing the
// reward modal until after that is completed / closed.
// Also: We overwrite the pending reward modal kind, so that only one is ever shown
// This could, in theory, lead to a user activating both in the same login and only
// seeing one modal, however that is low impact since it the modal still takes them to
// the theme picker, which will show both themes anyway.
let kind = match event {
ReferralThemeEvent::SentReferralThemeActivated => RewardKind::SentReferralTheme,
ReferralThemeEvent::ReceivedReferralThemeActivated => RewardKind::ReceivedReferralTheme,
};
if self.is_changelog_open_or_pending(ctx) {
self.reward_modal_pending = Some(kind);
} else {
self.show_reward_modal(kind, ctx);
}
}
/// This listens for changes to keybindings and keeps the cached versions up-to-date in our
/// tooltips.
fn handle_keybinding_changed(
&mut self,
event: &KeybindingChangedEvent,
ctx: &mut ViewContext<Self>,
) {
match &event {
KeybindingChangedEvent::BindingChanged {
binding_name,
new_trigger: new_trigger_option,
} => self
.cached_keybindings
.entry(binding_name.to_owned())
.and_modify(|keystroke| {
*keystroke = new_trigger_option.as_ref().map(|key| key.displayed())
}),
};
ctx.notify()
}
fn handle_window_state_change(&mut self, event: &StateEvent, ctx: &mut ViewContext<Self>) {
match &event {
StateEvent::ValueChanged { current, previous } => {
// Re-render if fullscreen state for active window has changed.
if current.is_active_window_fullscreen != previous.is_active_window_fullscreen {
ctx.notify();
} else if WindowManager::did_window_change_focus(self.window_id, current, previous)
{
// Re-render if this window's focus state has changed.
ctx.notify();
} else if current.stage != previous.stage {
// Re-render if the app's focus state has changed (Active/Inactive)
// This ensures dimming updates properly when the app gains/loses focus
ctx.notify();
}
}
};
}
fn handle_ai_assistant_panel_event(
&mut self,
event: &AIAssistantPanelEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
AIAssistantPanelEvent::ClosePanel => {
self.current_workspace_state.is_ai_assistant_panel_open = false;
self.focus_active_tab(ctx);
ctx.notify();
}
AIAssistantPanelEvent::PasteInTerminalInput(code) => {
let command = code.trim().to_string();
let args_state =
ArgumentsState::for_command_workflow(&Default::default(), command.clone());
let workflow = Workflow::new("Command from Galaxy AI", command)
.with_arguments(args_state.arguments);
self.run_workflow_in_active_input(
&WorkflowType::AIGenerated {
workflow,
origin: AIWorkflowOrigin::LegacyWarpAI,
},
WorkflowSource::WarpAI,
WorkflowSelectionSource::WarpAI,
None,
TerminalSessionFallbackBehavior::default(),
ctx,
);
ctx.notify();
}
AIAssistantPanelEvent::FocusTerminalInput => {
self.focus_active_tab(ctx);
ctx.notify();
}
AIAssistantPanelEvent::OpenWorkflowModalWithCommand(command) => {
self.open_workflow_with_command(command.clone(), ctx);
}
}
}
fn handle_openwarp_launch_modal_event(
&mut self,
event: &OpenWarpLaunchModalEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
OpenWarpLaunchModalEvent::Close => {
OneTimeModalModel::handle(ctx).update(ctx, |model, ctx| {
model.mark_openwarp_launch_modal_dismissed(ctx);
});
self.focus_active_tab(ctx);
ctx.notify();
}
}
}
fn handle_oz_launch_modal_event(
&mut self,
event: &LaunchModalEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
LaunchModalEvent::Close => {
OneTimeModalModel::handle(ctx).update(ctx, |model, ctx| {
model.mark_oz_launch_modal_dismissed(ctx);
});
// Clear the "Introducing Oz" custom tab name so normal tab naming rules apply.
if let Some(pane_group_id) = self.oz_launch_modal.tab_pane_group_id.take() {
if let Some(tab) = self
.tabs
.iter()
.find(|tab| tab.pane_group.id() == pane_group_id)
{
tab.pane_group.update(ctx, |view, ctx| {
view.clear_title(ctx);
});
}
}
self.focus_active_tab(ctx);
}
LaunchModalEvent::ToggleCheckbox => {
PrivacySettings::handle(ctx).update(ctx, |settings, ctx| {
let current_value = settings.is_cloud_conversation_storage_enabled;
settings.set_is_cloud_conversation_storage_enabled(!current_value, ctx);
});
}
}
}
fn handle_build_plan_migration_modal_event(
&mut self,
event: &BuildPlanMigrationModalEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
BuildPlanMigrationModalEvent::Close => {
OneTimeModalModel::handle(ctx).update(ctx, |model, ctx| {
model.mark_build_plan_migration_modal_dismissed(ctx);
});
self.focus_active_tab(ctx);
ctx.notify();
}
BuildPlanMigrationModalEvent::ShowToast { message, flavor } => {
use crate::view_components::{DismissibleToast, ToastFlavor};
self.toast_stack.update(ctx, |toast_stack, ctx| {
let toast = match flavor {
ToastFlavor::Success => DismissibleToast::success(message.clone()),
ToastFlavor::Error => DismissibleToast::error(message.clone()),
_ => DismissibleToast::error(message.clone()),
};
toast_stack.add_ephemeral_toast(toast, ctx);
});
}
}
}
fn handle_codex_modal_event(&mut self, event: &CodexModalEvent, ctx: &mut ViewContext<Self>) {
use crate::ai::blocklist::agent_view::AgentViewEntryOrigin;
use crate::AIExecutionProfilesModel;
match event {
CodexModalEvent::Close => {
self.current_workspace_state.is_codex_modal_open = false;
self.focus_active_tab(ctx);
ctx.notify();
}
CodexModalEvent::UseCodex => {
// Add a new terminal tab
self.add_new_session_tab_internal_with_default_session_mode_behavior(
NewSessionSource::Tab,
Some(ctx.window_id()),
None,
None,
false,
DefaultSessionModeBehavior::Ignore,
ctx,
);
ctx.notify();
// Get the active terminal view
let Some(terminal_view) = self
.active_tab_pane_group()
.as_ref(ctx)
.active_session_view(ctx)
else {
log::error!("No active terminal view after adding tab for Codex session");
return;
};
let Some(codex_model_id) = LLMPreferences::as_ref(ctx)
.get_preferred_codex_model()
.map(|info| info.id.clone())
else {
log::error!("No preferred codex model found");
return;
};
// Set codex as the model for the default profile and make the default profile active.
AIExecutionProfilesModel::handle(ctx).update(ctx, |profiles, ctx| {
let default_profile_id = profiles.default_profile_id();
profiles.set_base_model(default_profile_id, Some(codex_model_id), ctx);
profiles.set_active_profile(terminal_view.id(), default_profile_id, ctx);
});
// Enter agent view and submit the initial prompt
let initial_prompt = "Hello, Agent Mode x Codex!".to_string();
terminal_view.update(ctx, |terminal_view, ctx| {
terminal_view.enter_agent_view_for_new_conversation(
Some(initial_prompt),
AgentViewEntryOrigin::CodexModal,
ctx,
);
});
self.current_workspace_state.is_codex_modal_open = false;
ctx.notify();
send_telemetry_from_ctx!(TelemetryEvent::CodexModalUseCodexClicked, ctx);
}
}
}
#[cfg(not(target_family = "wasm"))]
fn open_plugin_instructions_pane(
&mut self,
agent: crate::terminal::CLIAgent,
kind: PluginModalKind,
ctx: &mut ViewContext<Self>,
) {
use crate::terminal::model::rich_content::RichContentType;
use crate::terminal::view::plugin_instructions_block::{
PluginInstructionsBlock, PluginInstructionsBlockEvent,
};
use crate::terminal::view::rich_content::{
RichContentInsertionPosition, RichContentMetadata,
};
let Some(manager) = plugin_manager_for(agent) else {
return;
};
let instructions = match kind {
PluginModalKind::Install => manager.install_instructions(),
PluginModalKind::Update => manager.update_instructions(),
};
// Read session metadata from the originating terminal before creating the instructions pane.
let active_view = self
.active_tab_pane_group()
.as_ref(ctx)
.active_session_view(ctx);
let is_remote_session = active_view
.as_ref()
.and_then(|view| view.as_ref(ctx).active_session_is_local(ctx))
.is_some_and(|is_local| !is_local);
let custom_command_prefix = active_view.and_then(|view| {
CLIAgentSessionsModel::as_ref(ctx)
.session(view.id())
.and_then(|s| s.custom_command_prefix.clone())
});
self.active_tab_pane_group().update(ctx, |pane_group, ctx| {
let pane_id = pane_group.add_terminal_pane_ignoring_default_session_mode(
pane_group::Direction::Right,
None,
ctx,
);
if let Some(terminal_view) = pane_group.terminal_view_from_pane_id(pane_id, ctx) {
terminal_view.update(ctx, |view, ctx| {
let custom_command_prefix = custom_command_prefix.clone();
let block = ctx.add_typed_action_view(|ctx| {
PluginInstructionsBlock::new(
instructions,
agent,
custom_command_prefix,
is_remote_session,
ctx,
)
});
ctx.subscribe_to_view(&block, |view, block, event, ctx| match event {
PluginInstructionsBlockEvent::Close => {
view.remove_plugin_instructions_block(block.clone(), ctx);
}
});
view.insert_rich_content(
Some(RichContentType::PluginInstructionsBlock),
block,
Some(RichContentMetadata::PluginInstructionsBlock),
RichContentInsertionPosition::Append {
insert_below_long_running_block: false,
},
ctx,
);
});
}
});
}
/// Opens the Codex modal.
pub fn open_codex_modal(&mut self, ctx: &mut ViewContext<Self>) {
self.current_workspace_state.is_codex_modal_open = true;
ctx.focus(&self.codex_modal);
ctx.notify();
send_telemetry_from_ctx!(TelemetryEvent::CodexModalOpened, ctx);
}
/// Opens a new tab and enters agent view with a prompt from a Linear deeplink.
pub fn open_linear_issue_work(
&mut self,
args: &crate::linear::LinearIssueWork,
ctx: &mut ViewContext<Self>,
) {
send_telemetry_from_ctx!(TelemetryEvent::LinearIssueLinkOpened, ctx);
self.add_new_session_tab_internal_with_default_session_mode_behavior(
NewSessionSource::Tab,
Some(ctx.window_id()),
None, // Chosen shell
None, // Conversation restoration
false, // Hide the agent view homepage
DefaultSessionModeBehavior::Ignore,
ctx,
);
let Some(terminal_view) = self
.active_tab_pane_group()
.as_ref(ctx)
.active_session_view(ctx)
else {
log::error!("No active terminal view after adding tab for Linear issue work");
return;
};
let prompt = args.prompt.clone();
terminal_view.update(ctx, |terminal_view, ctx| {
terminal_view.enter_agent_view_for_new_conversation(
prompt,
AgentViewEntryOrigin::LinearDeepLink,
ctx,
);
});
if let Some(conversation_id) = terminal_view
.as_ref(ctx)
.agent_view_controller()
.as_ref(ctx)
.agent_view_state()
.active_conversation_id()
{
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, _ctx| {
if let Some(conversation) = history.conversation_mut(&conversation_id) {
conversation.set_fallback_display_title("Linear Issue".to_string());
}
});
}
}
fn handle_cloud_agent_capacity_modal_event(
&mut self,
event: &CloudAgentCapacityModalEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
CloudAgentCapacityModalEvent::Close => {
self.current_workspace_state
.is_cloud_agent_capacity_modal_open = false;
self.focus_active_tab(ctx);
ctx.notify();
}
}
}
pub fn open_cloud_agent_capacity_modal(
&mut self,
variant: CloudAgentCapacityModalVariant,
ctx: &mut ViewContext<Self>,
) {
if !FeatureFlag::CloudMode.is_enabled() {
return;
}
self.cloud_agent_capacity_modal.update(ctx, |modal, ctx| {
modal.set_variant(variant);
ctx.notify();
});
self.current_workspace_state
.is_cloud_agent_capacity_modal_open = true;
ctx.focus(&self.cloud_agent_capacity_modal);
ctx.notify();
send_telemetry_from_ctx!(TelemetryEvent::CloudAgentCapacityModalOpened, ctx);
}
fn handle_free_tier_limit_modal_event(
&mut self,
event: &FreeTierLimitHitModalEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
FreeTierLimitHitModalEvent::MaybeOpen => {
if self.free_tier_limit_check_triggered
&& self.check_and_open_free_tier_limit_modal(ctx)
{
self.free_tier_limit_check_triggered = false;
}
}
FreeTierLimitHitModalEvent::Close => {
self.current_workspace_state
.is_free_tier_limit_hit_modal_open = false;
GeneralSettings::handle(ctx).update(ctx, |settings, ctx| {
if let Err(e) = settings
.free_tier_limit_hit_modal_dismissed
.set_value(true, ctx)
{
log::warn!("Failed to mark free tier limit hit modal as dismissed: {e}");
}
});
self.focus_active_tab(ctx);
ctx.notify();
}
}
}
pub fn check_and_open_free_tier_limit_modal(&mut self, ctx: &mut ViewContext<Self>) -> bool {
let is_free_tier = !UserWorkspaces::as_ref(ctx)
.current_workspace()
.is_some_and(|workspace| workspace.billing_metadata.is_user_on_paid_plan());
if !is_free_tier {
return false;
}
if AIRequestUsageModel::as_ref(ctx).has_any_ai_remaining(ctx) {
return false;
}
if self
.current_workspace_state
.is_free_tier_limit_hit_modal_open
|| *GeneralSettings::as_ref(ctx).free_tier_limit_hit_modal_dismissed
{
return false;
}
self.current_workspace_state
.is_free_tier_limit_hit_modal_open = true;
send_telemetry_from_ctx!(TelemetryEvent::FreeTierLimitHitInterstitialDisplayed, ctx);
ctx.focus(&self.free_tier_limit_hit_modal);
ctx.notify();
true
}
fn ask_ai_assistant(&mut self, ask_type: &AskAIType, ctx: &mut ViewContext<Self>) {
if !self.current_workspace_state.is_ai_assistant_panel_open {
self.toggle_ai_assistant_panel(ctx);
}
ctx.focus(&self.ai_assistant_panel);
self.ai_assistant_panel.update(ctx, |ai_assistant, ctx| {
ai_assistant.ask_ai(ask_type, ctx);
});
ctx.notify();
}
/// Determines if the changelog is currently being shown or if the changelog request is
/// in-flight
///
fn is_changelog_open_or_pending(&self, ctx: &mut ViewContext<Self>) -> bool {
self.current_workspace_state.is_resource_center_open
|| self.changelog_model.as_ref(ctx).is_check_pending()
}
fn focus_active_tab(&mut self, ctx: &mut ViewContext<Self>) {
self.active_tab_pane_group().update(ctx, |tab, ctx| {
tab.focus(ctx);
})
}
fn focus_theme_chooser(&self, ctx: &mut ViewContext<Self>) {
ctx.focus(&self.theme_chooser_view);
ctx.notify();
}
fn open_prompt_editor(
&mut self,
open_source: PromptEditorOpenSource,
ctx: &mut ViewContext<Self>,
) {
// Try to get a prompt preview from an active session. Otherwise, read it from the settings
// view.
let ps1_grid_info = self.active_session_ps1_grid_info(ctx).or_else(|| {
self.settings_pane
.read(ctx, |settings, app| settings.get_ps1_info(app))
});
let chip_runtime_capabilities = self
.active_tab_pane_group()
.as_ref(ctx)
.active_session_view(ctx)
.and_then(|terminal_view| {
terminal_view.read(ctx, |terminal, ctx| {
let required_executables = crate::context_chips::available_chips()
.into_iter()
.filter_map(|kind| kind.to_chip())
.flat_map(|chip| chip.runtime_policy().required_executables().to_vec())
.collect::<std::collections::HashSet<_>>();
terminal
.active_block_session_id()
.and_then(|id| terminal.sessions_model().as_ref(ctx).get(id))
.as_deref()
.map(|session| {
ChipRuntimeCapabilities::from_session_with_external_command_queries(
session,
required_executables.iter().map(String::as_str),
false,
)
})
})
})
.unwrap_or_default();
self.prompt_editor_modal.update(ctx, |prompt_editor, ctx| {
prompt_editor.open(ps1_grid_info, chip_runtime_capabilities, ctx);
});
self.close_all_overlays(ctx);
self.current_workspace_state.is_prompt_editor_open = true;
ctx.focus(&self.prompt_editor_modal);
send_telemetry_from_ctx!(
TelemetryEvent::OpenPromptEditor {
entrypoint: open_source
},
ctx
);
}
fn open_agent_toolbar_editor(
&mut self,
mode: AgentToolbarEditorMode,
ctx: &mut ViewContext<Self>,
) {
if !FeatureFlag::AgentToolbarEditor.is_enabled() {
return;
}
self.agent_toolbar_editor_modal
.update(ctx, |modal, ctx| modal.open(mode, ctx));
self.close_all_overlays(ctx);
self.current_workspace_state.is_agent_toolbar_editor_open = true;
ctx.focus(&self.agent_toolbar_editor_modal);
}
fn open_theme_creator_modal(&mut self, ctx: &mut ViewContext<Self>) {
self.current_workspace_state.is_theme_creator_modal_open = true;
ctx.focus(&self.theme_creator_modal);
ctx.notify();
}
fn open_theme_deletion_modal(&mut self, theme_kind: ThemeKind, ctx: &mut ViewContext<Self>) {
self.current_workspace_state.is_theme_deletion_modal_open = true;
self.theme_deletion_modal
.update(ctx, |theme_deletion_modal, ctx| {
theme_deletion_modal.set_theme_kind(theme_kind, ctx);
});
ctx.focus(&self.theme_deletion_modal);
ctx.notify();
}
fn open_shared_objects_creation_denied_modal(
&mut self,
object_type: DriveObjectType,
team_uid: ServerId,
ctx: &mut ViewContext<Self>,
) {
let team = UserWorkspaces::as_ref(ctx).team_from_uid(team_uid);
if let Some(team) = team {
let current_user_email = self.auth_state.user_email().unwrap_or_default();
let has_admin_permissions = team.has_admin_permissions(&current_user_email);
let team_uid = team.uid;
let is_delinquent_due_to_payment_issue =
team.billing_metadata.is_delinquent_due_to_payment_issue();
let customer_type = team.billing_metadata.customer_type;
// Send telemetry event only if the team is not delinquent. If the team is
// delinquent, then they haven't technically hit any tier limits are just in a
// restricted state.
if !is_delinquent_due_to_payment_issue {
send_telemetry_from_ctx!(
TelemetryEvent::TierLimitHit(TierLimitHitEvent {
team_uid: team.uid,
feature: object_type.to_string(),
}),
ctx
);
}
self.current_workspace_state
.is_shared_objects_creation_denied_modal_open = true;
self.shared_objects_creation_denied_modal
.update(ctx, |modal, ctx| {
modal.update_modal_state(
team_uid,
object_type,
has_admin_permissions,
is_delinquent_due_to_payment_issue,
customer_type,
ctx,
);
});
ctx.focus(&self.shared_objects_creation_denied_modal);
ctx.notify();
}
}
/// Opens the workflow modal in the provided space and folder with no existing content (i.e. a new workflow modal).
fn open_workflow_modal(
&mut self,
space: Space,
initial_folder_id: Option<SyncId>,
ctx: &mut ViewContext<Self>,
) {
// We only check whether a user can open a workflow modal for new workflows in the
// team space. We don't do this check if opening a pre-existing workflow, or
// creating a new workflow in the personal space, e.g.:
// - open_workflow_modal_with_existing
// - open_workflow_modal_with_temporary
// - open_workflow_modal_with_command
let owner = match space {
Space::Team { team_uid } => {
if !UserWorkspaces::has_capacity_for_shared_workflows(team_uid, ctx, 1) {
self.open_shared_objects_creation_denied_modal(
DriveObjectType::Workflow,
team_uid,
ctx,
);
return;
}
Owner::Team { team_uid }
}
Space::Shared => {
// TODO(ben): Use an owner-or-folder API, so we can check on creating an object in
// the folder.
return;
}
Space::Personal => match UserWorkspaces::as_ref(ctx).personal_drive(ctx) {
Some(drive) => drive,
None => {
log::warn!("Unable to open workflow modal due to unset personal drive");
return;
}
},
};
self.current_workspace_state.is_workflow_modal_open = true;
self.workflow_modal.update(ctx, |workflow_modal, ctx| {
workflow_modal.open_with_new(owner, initial_folder_id, ctx);
});
ctx.focus(&self.workflow_modal);
ctx.notify();
}
/// Opens the workflow from a given [`CloudWorkflow`]'s server ID.
fn open_workflow_with_existing(
&mut self,
workflow_id: SyncId,
settings: &OpenWarpDriveObjectSettings,
ctx: &mut ViewContext<Self>,
) {
let source = WorkflowOpenSource::Existing(workflow_id);
self.open_workflow_in_pane(&source, settings, WorkflowViewMode::Edit, ctx)
}
/// Opens the workflow using a mocked [`Workflow`] object as the base
fn open_workflow_with_temporary(&mut self, workflow: Workflow, ctx: &mut ViewContext<Self>) {
let Some(owner) = UserWorkspaces::as_ref(ctx).personal_drive(ctx) else {
log::warn!("Unable to open temporary workflow - unset personal drive");
return;
};
let source = WorkflowOpenSource::NewFromWorkflow {
workflow: workflow.into(),
owner,
initial_folder_id: None,
};
self.open_workflow_in_pane(
&source,
&OpenWarpDriveObjectSettings::default(),
WorkflowViewMode::Create,
ctx,
);
}
/// Opens the workflow for create with a prepopulated command specified
fn open_workflow_with_command(&mut self, command: String, ctx: &mut ViewContext<Self>) {
let Some(owner) = UserWorkspaces::as_ref(ctx).personal_drive(ctx) else {
log::warn!("Unable to open workflow with command - unset personal drive");
return;
};
let source = WorkflowOpenSource::New {
title: None,
content: Some(command),
owner,
initial_folder_id: None,
is_for_agent_mode: false,
};
self.open_workflow_in_pane(
&source,
&OpenWarpDriveObjectSettings::default(),
WorkflowViewMode::Create,
ctx,
);
ctx.notify();
}
fn render_ai_assistant_warm_welcome(&self, appearance: &Appearance) -> Box<dyn Element> {
let theme = appearance.theme();
let background_color = theme.surface_2();
let border_color = theme.surface_3();
let sub_text_color = blended_colors::text_sub(theme, background_color);
let header = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(
Container::new(
ConstrainedBox::new(
WarpUiIcon::new(icons::Icon::AiAssistant.into(), *AI_ASSISTANT_LOGO_COLOR)
.finish(),
)
.with_width(16.)
.with_height(16.)
.finish(),
)
.with_margin_right(4.)
.finish(),
)
.with_child(
Text::new_inline(AI_ASSISTANT_FEATURE_NAME, appearance.ui_font_family(), 14.)
.with_style(Properties {
weight: galaxyui::fonts::Weight::Bold,
..Default::default()
})
.finish(),
)
.with_child(
Shrinkable::new(
1.,
Align::new(
appearance
.ui_builder()
.close_button(
20.,
self.ai_assistant_close_warm_welcome_mouse_state_handle
.clone(),
)
.build()
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(
WorkspaceAction::DismissAIAssistantWarmWelcome,
)
})
.finish(),
)
.right()
.finish(),
)
.finish(),
)
.finish();
let body = appearance
.ui_builder()
.wrappable_text(
"Ask Galaxy AI to explain errors, suggest commands or write scripts.".to_owned(),
true,
)
.with_style(UiComponentStyles {
font_size: Some(12.),
font_color: Some(sub_text_color),
..Default::default()
})
.build()
.finish();
ConstrainedBox::new(
EventHandler::new(
Container::new(
Flex::column()
.with_child(header)
.with_child(Container::new(body).with_margin_top(5.).finish())
.finish(),
)
.with_background(background_color)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.)))
.with_uniform_padding(10.)
.with_border(Border::all(1.).with_border_color(border_color.into()))
.finish(),
)
.on_left_mouse_down(|ctx, _, _| {
ctx.dispatch_typed_action(WorkspaceAction::ClickedAIAssistantWarmWelcome);
DispatchEventResult::StopPropagation
})
.finish(),
)
.with_height(85.)
.with_width(210.)
.finish()
}
fn render_tab_in_tab_bar(
&self,
tab_index: usize,
tab_bar_state: TabBarState,
ctx: &AppContext,
) -> Box<dyn Element> {
let tab = &self.tabs[tab_index];
let close_button_position = if FeatureFlag::TabCloseButtonOnLeft.is_enabled() {
TabSettings::as_ref(ctx).close_button_position
} else {
TabCloseButtonPosition::default()
};
let is_drag_target = self
.hovered_tab_index
.as_ref()
.is_some_and(|hovered_index| match hovered_index {
TabBarHoverIndex::OverTab(idx) => *idx == tab_index,
TabBarHoverIndex::BeforeTab(_) => false,
});
TabComponent::new(
tab_index,
tab_bar_state,
tab,
self.tab_rename_editor.clone(),
close_button_position,
is_drag_target,
ctx,
)
.build()
.finish()
}
fn render_agent_management_view_button(
&self,
appearance: &Appearance,
ctx: &AppContext,
) -> Box<dyn Element> {
let is_active = self.current_workspace_state.is_agent_management_view_open;
SavePosition::new(
Container::new(
Align::new(
self.render_tab_bar_icon_button(
appearance,
icons::Icon::Grid,
&self.mouse_states.agent_management_view_button,
WorkspaceAction::ToggleAgentManagementView,
"Agent management panel".to_string(),
keybinding_name_to_display_string(
"workspace:toggle_agent_management_view",
ctx,
),
is_active,
false,
)
.finish(),
)
.finish(),
)
.finish(),
"workspace:toggle_agent_management_view",
)
.finish()
}
fn render_left_toggle_button(
&self,
appearance: &Appearance,
ctx: &AppContext,
) -> Box<dyn Element> {
let vertical_tabs_active =
FeatureFlag::VerticalTabs.is_enabled() && *TabSettings::as_ref(ctx).use_vertical_tabs;
let (is_active, tooltip_text, action, keybinding_name, save_position_id) =
if vertical_tabs_active {
(
self.vertical_tabs_panel_open,
"Tabs panel",
WorkspaceAction::ToggleVerticalTabsPanel,
"workspace:toggle_vertical_tabs_panel",
"workspace:toggle_vertical_tabs_panel",
)
} else {
let tooltip = if self.left_panel_views.len() <= 1 {
match self
.left_panel_views
.first()
.copied()
.unwrap_or(ToolPanelView::WarpDrive)
{
ToolPanelView::ProjectExplorer => "Project explorer",
ToolPanelView::GlobalSearch { .. } => "Global search",
ToolPanelView::WarpDrive => "Galaxy Drive",
ToolPanelView::ConversationListView => "Agent conversations",
}
} else {
"Tools panel"
};
(
self.active_tab_pane_group().as_ref(ctx).left_panel_open,
tooltip,
WorkspaceAction::ToggleLeftPanel,
"workspace:toggle_left_panel",
"workspace:toggle_left_panel",
)
};
SavePosition::new(
Container::new(
Align::new(
self.render_tab_bar_icon_button(
appearance,
icons::Icon::Menu,
&self.mouse_states.left_panel_icon,
action,
tooltip_text.to_string(),
keybinding_name_to_display_string(keybinding_name, ctx),
is_active,
false,
)
.finish(),
)
.finish(),
)
.finish(),
save_position_id,
)
.finish()
}
fn render_tools_panel_button(
&self,
appearance: &Appearance,
ctx: &AppContext,
) -> Box<dyn Element> {
let is_active = self.active_tab_pane_group().as_ref(ctx).left_panel_open;
let tooltip_text = if self.left_panel_views.len() <= 1 {
match self
.left_panel_views
.first()
.copied()
.unwrap_or(ToolPanelView::WarpDrive)
{
ToolPanelView::ProjectExplorer => "Project explorer",
ToolPanelView::GlobalSearch { .. } => "Global search",
ToolPanelView::WarpDrive => "Galaxy Drive",
ToolPanelView::ConversationListView => "Agent conversations",
}
} else {
"Tools panel"
};
SavePosition::new(
Container::new(
Align::new(
self.render_tab_bar_icon_button(
appearance,
icons::Icon::Tool2,
&self.mouse_states.tools_panel_icon,
WorkspaceAction::ToggleLeftPanel,
tooltip_text.to_string(),
keybinding_name_to_display_string("workspace:toggle_left_panel", ctx),
is_active,
false,
)
.finish(),
)
.finish(),
)
.finish(),
"workspace:toggle_left_panel",
)
.finish()
}
fn should_enable_file_tree_and_global_search_for_pane_group(pane_group: &PaneGroup) -> bool {
pane_group
.pane_ids()
.filter(|id| !pane_group.is_pane_hidden_for_close(*id))
.any(|id| {
id.is_terminal_pane()
|| id.is_file_pane()
|| id.is_code_pane()
|| id.is_code_diff_pane()
})
}
fn render_right_panel_button(
&self,
appearance: &Appearance,
ctx: &AppContext,
) -> Box<dyn Element> {
let is_active = self.active_tab_pane_group().as_ref(ctx).right_panel_open;
let is_enabled = Self::should_enable_file_tree_and_global_search_for_pane_group(
self.active_tab_pane_group().as_ref(ctx),
);
let disable = !is_enabled;
let theme = appearance.theme();
let font_color = if disable {
theme.disabled_text_color(theme.background())
} else if is_active {
theme.main_text_color(theme.background())
} else {
theme.sub_text_color(theme.background())
};
// Build the button content: Diff icon + optional diff stats
let icon = ConstrainedBox::new(icons::Icon::Diff.to_galaxyui_icon(font_color).finish())
.with_width(16.)
.with_height(16.)
.finish();
let show_diff_stats = *TabSettings::as_ref(ctx).show_code_review_diff_stats;
let line_changes = if show_diff_stats {
self.active_tab_pane_group()
.as_ref(ctx)
.active_session_view(ctx)
.and_then(|tv| tv.as_ref(ctx).current_diff_line_changes(ctx))
.filter(|lc| {
// Only show the stat badge when there are actual line-level changes
// (files_changed alone, e.g. mode-only changes, is not surfaced here).
lc.lines_added > 0 || lc.lines_removed > 0
})
} else {
None
};
let has_stats = line_changes.is_some();
let mut row = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center);
row.add_child(icon);
if let Some(lc) = line_changes {
let stat = |value: u32, prefix: &str, color: ColorU| -> Box<dyn Element> {
Container::new(
Text::new_inline(format!("{prefix}{value}"), appearance.ui_font_family(), 12.)
.with_color(color)
.with_style(Properties::default().weight(Weight::Semibold))
.finish(),
)
.with_margin_left(4.)
.finish()
};
row.add_child(stat(lc.lines_added, "+", add_color(appearance)));
row.add_child(stat(lc.lines_removed, "-", remove_color(appearance)));
}
let label = row.finish();
// The diff icon SVG has intrinsic horizontal whitespace in its 14px viewBox: its visible
// paths start around x=3 and end around x=11. When stats are shown, equal container padding
// makes the gap between the button edge and the visible icon look wider than the gap after
// the text. Locally compensate for that artwork padding without changing the shared icon.
let (header_padding_left, header_padding_right) =
if has_stats { (5., 8.) } else { (4., 4.) };
let default_styles = UiComponentStyles {
font_color: Some(font_color.into()),
font_size: Some(12.),
font_weight: Some(Weight::Medium),
font_family_id: Some(appearance.ui_font_family()),
height: Some(24.),
border_radius: Some(CornerRadius::with_all(Radius::Pixels(4.))),
border_width: Some(0.),
padding: Some(Coords {
top: 0.,
bottom: 0.,
left: header_padding_left,
right: header_padding_right,
}),
..Default::default()
};
let hover_styles = UiComponentStyles {
background: Some(theme.surface_2().into()),
..default_styles
};
let clicked_styles = UiComponentStyles {
background: Some(theme.background().into()),
..default_styles
};
let mut button = Button::new(
self.mouse_states.right_panel_icon.clone(),
default_styles,
Some(hover_styles),
Some(clicked_styles),
None,
)
.with_custom_label(label);
if is_active {
button = button.active().with_active_styles(UiComponentStyles {
background: Some(internal_colors::fg_overlay_3(theme).into()),
..UiComponentStyles::default()
});
}
let hoverable = if disable {
button.build().disable()
} else {
button
.with_tooltip(self.render_tab_bar_icon_button_tooltip(
appearance,
"Code review panel".to_string(),
keybinding_name_to_display_string("workspace:toggle_right_panel", ctx),
))
.build()
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(WorkspaceAction::ToggleRightPanel);
})
};
SavePosition::new(
Container::new(Align::new(hoverable.finish()).finish()).finish(),
"workspace:right_panel_button",
)
.finish()
}
/// Renders an invisible rect for detecting hovers over the tab bar.
fn render_tab_bar_hover_area(&self) -> Box<dyn Element> {
self.render_tab_bar_hoverable(
ConstrainedBox::new(Empty::new().finish())
.with_height(TAB_BAR_HOVER_HEIGHT)
.finish(),
)
}
/// Renders the provided content wrapped in the tab bar hover behavior.
fn render_tab_bar_hoverable(&self, content: Box<dyn Element>) -> Box<dyn Element> {
Hoverable::new(self.tab_bar_hover_state.clone(), |_| content)
.with_hover_out_delay(Duration::from_millis(500))
.on_hover(|_is_hovered, ctx, _app, _position| {
ctx.dispatch_typed_action(WorkspaceAction::SyncTrafficLights);
})
.finish()
}
fn render_tab_hover_indicator(&self, appearance: &Appearance) -> Box<dyn Element> {
ConstrainedBox::new(
Rect::new()
.with_background(appearance.theme().accent())
.finish(),
)
.with_height(32.)
.with_width(4.)
.finish()
}
fn render_title_bar_search_bar(&self, appearance: &Appearance) -> Box<dyn Element> {
let theme = appearance.theme();
let text_color = theme.sub_text_color(theme.background());
Hoverable::new(
self.mouse_states.title_bar_search_bar.clone(),
|mouse_state| {
let row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_spacing(10.)
.with_child(
ConstrainedBox::new(
icons::Icon::Search.to_galaxyui_icon(text_color).finish(),
)
.with_width(16.)
.with_height(16.)
.finish(),
)
.with_child(
Shrinkable::new(
1.,
Text::new_inline(
"Search sessions, agents, files...",
appearance.ui_font_family(),
14.,
)
.with_color(text_color.into())
.with_clip(ClipConfig::ellipsis())
.finish(),
)
.finish(),
)
.finish();
ConstrainedBox::new(
Container::new(row)
.with_background(if mouse_state.is_hovered() {
internal_colors::fg_overlay_2(theme)
} else {
internal_colors::fg_overlay_1(theme)
})
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
.with_padding_left(16.)
.with_padding_right(16.)
.with_padding_top(4.)
.with_padding_bottom(4.)
.finish(),
)
.with_width(TITLE_BAR_SEARCH_BAR_MAX_WIDTH)
.finish()
},
)
.with_cursor(Cursor::PointingHand)
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(WorkspaceAction::OpenPalette {
mode: PaletteMode::Command,
source: PaletteSource::TitleBarSearchBar,
query: None,
});
})
.finish()
}
fn render_tab_bar_contents(
&self,
hover_fixed_width: Option<f32>,
appearance: &Appearance,
ctx: &AppContext,
) -> Box<dyn Element> {
let mut tab_bar = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center);
let is_web_anonymous_user = self
.auth_state
.is_user_web_anonymous_user()
.unwrap_or_default();
// Simplified mode for viewing Warp Drive objects, shared sessions, or conversation transcripts on WASM
#[cfg(target_family = "wasm")]
if let Some(content_type) = self.get_simplified_wasm_tab_bar_content(ctx) {
// Use MainAxisAlignment::SpaceBetween and expand to fill width
tab_bar = tab_bar
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_main_axis_size(MainAxisSize::Max);
let bg_color = blended_colors::neutral_1(appearance.theme());
// Left: Warp logo - clickable to link to warp.dev
let warp_logo = Hoverable::new(self.mouse_states.warp_logo.clone(), |_state| {
ConstrainedBox::new(
galaxy_core::ui::Icon::Warp
.to_galaxyui_icon(appearance.theme().foreground())
.finish(),
)
.with_height(24.)
.with_width(24.)
.finish()
})
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(WorkspaceAction::OpenLink("https://warp.dev".to_owned()));
})
.with_cursor(Cursor::PointingHand)
.finish();
tab_bar.add_child(warp_logo);
// Right: Info button + "View all cloud runs" button (for ambient agent sessions) + "Open in Warp" button
let mut right_row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_size(MainAxisSize::Min);
// Extract task_id from conversation transcripts and shared sessions
let task_id = match content_type {
SimplifiedWasmTabBarContent::ConversationTranscript { task_id }
| SimplifiedWasmTabBarContent::SharedSession { task_id } => task_id,
SimplifiedWasmTabBarContent::WarpDriveObject => None,
};
// Show info button for conversation transcripts and shared sessions (if there's content to display)
let should_show_info_button =
!matches!(content_type, SimplifiedWasmTabBarContent::WarpDriveObject)
&& self
.active_tab_pane_group()
.as_ref(ctx)
.focused_session_view(ctx)
.is_some_and(|view| {
Self::should_show_conversation_details_panel(&view, ctx)
});
if should_show_info_button {
right_row.add_child(
Container::new(ChildView::new(&self.transcript_info_button).finish())
.with_margin_right(8.)
.finish(),
);
// Add "View all cloud runs" button when task_id exists (with 4px gap)
if task_id.is_some() {
right_row.add_child(
Container::new(ChildView::new(&self.view_cloud_runs_button).finish())
.with_margin_right(4.)
.finish(),
);
}
}
// Hide "Open in Warp" button on mobile devices
if !galaxyui::platform::wasm::is_mobile_device() {
right_row.add_child(ChildView::new(&self.open_in_warp_button).finish());
}
tab_bar.add_child(right_row.finish());
return Container::new(tab_bar.finish())
.with_background_color(bg_color)
.with_border(
Border::bottom(1.0)
.with_border_fill(blended_colors::neutral_2(appearance.theme())),
)
.with_padding_left(24.)
.with_padding_right(24.)
.with_padding_top(4.)
.with_padding_bottom(4.)
.finish();
}
// Check if vertical tabs mode is active
let vertical_tabs_active =
FeatureFlag::VerticalTabs.is_enabled() && *TabSettings::as_ref(ctx).use_vertical_tabs;
// Render config-driven left-side toolbar buttons (both horizontal and vertical tabs)
let knowledge_center_closed = true;
let config = TabSettings::as_ref(ctx)
.header_toolbar_chip_selection
.clone();
if knowledge_center_closed && !self.is_theme_chooser_open() {
let left_toolbar_buttons = config
.left_items()
.into_iter()
.filter_map(|item| self.render_header_toolbar_button(&item, appearance, ctx))
.collect::<Vec<_>>();
let left_toolbar_button_count = left_toolbar_buttons.len();
for (index, button) in left_toolbar_buttons.into_iter().enumerate() {
let is_last_left_toolbar_button = index + 1 == left_toolbar_button_count;
if !vertical_tabs_active && is_last_left_toolbar_button {
tab_bar.add_child(Container::new(button).with_margin_right(8.).finish());
} else {
tab_bar.add_child(button);
}
}
}
if vertical_tabs_active {
let mut right_controls = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_size(MainAxisSize::Min);
self.add_configurable_right_side_tab_bar_controls(
&mut right_controls,
&config,
is_web_anonymous_user,
appearance,
ctx,
);
let left_padding = self.compute_tab_bar_left_padding(ctx);
let tab_bar = Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(tab_bar.finish())
.with_child(
Shrinkable::new(
1.,
Clipped::new(
Container::new(
Align::new(self.render_title_bar_search_bar(appearance)).finish(),
)
.with_padding_left(TITLE_BAR_SEARCH_BAR_SLOT_PADDING)
.with_padding_right(TITLE_BAR_SEARCH_BAR_SLOT_PADDING)
.finish(),
)
.finish(),
)
.finish(),
)
.with_child(right_controls.finish())
.finish();
return EventHandler::new(
Container::new(tab_bar)
.with_padding_left(left_padding)
.with_padding_right(TAB_BAR_PADDING_RIGHT)
.finish(),
)
.on_right_mouse_down(|ctx, _, position| {
ctx.dispatch_typed_action(WorkspaceAction::ShowHeaderToolbarContextMenu {
position,
});
DispatchEventResult::StopPropagation
})
.finish();
} else {
// Copy from our saved tab_bar_state to ensure all tabs get rendered with the same state
let active_tab_index = if FeatureFlag::AgentManagementView.is_enabled()
&& self.current_workspace_state.is_agent_management_view_open
{
None
} else {
Some(self.active_tab_index)
};
let tab_bar_state = TabBarState {
tab_count: self.tabs.len(),
active_tab_index,
is_any_tab_renaming: self.current_workspace_state.is_tab_being_renamed(),
is_any_tab_dragging: self.current_workspace_state.is_tab_being_dragged,
hover_fixed_width,
};
for i in 0..self.tabs.len() {
// If we are hovered between two tabs, show the drop hover indicator
if self.hovered_tab_index.as_ref().is_some_and(
|hovered_index| match hovered_index {
TabBarHoverIndex::BeforeTab(idx) => i == *idx,
TabBarHoverIndex::OverTab(_) => false,
},
) {
tab_bar.add_child(self.render_tab_hover_indicator(appearance));
}
tab_bar.add_child(self.render_tab_in_tab_bar(i, tab_bar_state, ctx));
}
// Fencepost problem - add the indicator at the end if needed
if self
.hovered_tab_index
.as_ref()
.is_some_and(|hovered_index| match hovered_index {
TabBarHoverIndex::BeforeTab(idx) => self.tabs.len() == *idx,
TabBarHoverIndex::OverTab(_) => false,
})
{
tab_bar.add_child(self.render_tab_hover_indicator(appearance));
}
if ContextFlag::CreateNewSession.is_enabled() {
tab_bar.add_child(self.render_new_session_button(ctx));
}
}
// Placeholder to make sure the flex row expands across the entire width of the app.
tab_bar.add_child(Shrinkable::new(0.5, Empty::new().finish()).finish());
self.add_configurable_right_side_tab_bar_controls(
&mut tab_bar,
&config,
is_web_anonymous_user,
appearance,
ctx,
);
let left_padding = self.compute_tab_bar_left_padding(ctx);
EventHandler::new(
Container::new(tab_bar.finish())
.with_padding_left(left_padding)
.with_padding_right(TAB_BAR_PADDING_RIGHT)
.finish(),
)
.on_right_mouse_down(|ctx, _, position| {
ctx.dispatch_typed_action(WorkspaceAction::ShowHeaderToolbarContextMenu { position });
DispatchEventResult::StopPropagation
})
.finish()
}
/// Renders a single header toolbar button for the given item kind.
/// Returns `None` if the item is not currently available.
/// The button is wrapped with a right-click handler that opens the
/// toolbar configurator.
fn render_header_toolbar_button(
&self,
item: &HeaderToolbarItemKind,
appearance: &Appearance,
ctx: &AppContext,
) -> Option<Box<dyn Element>> {
if !item.is_available(ctx) {
return None;
}
let vertical_tabs_active =
FeatureFlag::VerticalTabs.is_enabled() && *TabSettings::as_ref(ctx).use_vertical_tabs;
let inner = match item {
HeaderToolbarItemKind::TabsPanel => self.render_left_toggle_button(appearance, ctx),
HeaderToolbarItemKind::ToolsPanel => {
if self.left_panel_views.is_empty() {
return None;
}
if vertical_tabs_active {
self.render_tools_panel_button(appearance, ctx)
} else {
self.render_left_toggle_button(appearance, ctx)
}
}
HeaderToolbarItemKind::AgentManagement => {
self.render_agent_management_view_button(appearance, ctx)
}
HeaderToolbarItemKind::CodeReview => self.render_right_panel_button(appearance, ctx),
HeaderToolbarItemKind::NotificationsMailbox => {
self.render_notifications_mailbox_button(appearance, ctx)
}
};
Some(
Container::new(
EventHandler::new(inner)
.on_right_mouse_down(|ctx, _, position| {
ctx.dispatch_typed_action(WorkspaceAction::ShowHeaderToolbarContextMenu {
position,
});
DispatchEventResult::StopPropagation
})
.finish(),
)
.with_margin_left(TAB_BAR_ICON_PADDING)
.finish(),
)
}
/// Renders the notifications mailbox button (extracted for reuse from
/// add_right_side_tab_bar_controls).
fn render_notifications_mailbox_button(
&self,
appearance: &Appearance,
ctx: &AppContext,
) -> Box<dyn Element> {
let is_inbox_active = self.current_workspace_state.is_notification_mailbox_open
|| self.hoa_onboarding_flow.as_ref().is_some_and(|flow| {
flow.as_ref(ctx).step() == HoaOnboardingStep::AgentInboxCallout
});
let mailbox_button = self
.render_tab_bar_icon_button(
appearance,
icons::Icon::Inbox,
&self.mouse_states.notifications_mailbox,
WorkspaceAction::ToggleNotificationMailbox {
select_first: false,
},
"Notifications".to_string(),
keybinding_name_to_display_string(TOGGLE_NOTIFICATION_MAILBOX_BINDING_NAME, ctx),
is_inbox_active,
false,
)
.finish();
let unread_count = AgentNotificationsModel::as_ref(ctx)
.notifications()
.filtered_count(NotificationFilter::Unread);
let mailbox_element = if unread_count > 0 {
let indicator = Container::new(
ConstrainedBox::new(
Rect::new()
.with_background(appearance.theme().accent())
.with_corner_radius(CornerRadius::with_all(Radius::Percentage(50.)))
.finish(),
)
.with_width(6.)
.with_height(6.)
.finish(),
)
.finish();
let mut stack = Stack::new();
stack.add_child(mailbox_button);
stack.add_positioned_child(
indicator,
OffsetPositioning::offset_from_parent(
Vector2F::zero(),
ParentOffsetBounds::WindowByPosition,
ParentAnchor::TopRight,
ChildAnchor::TopRight,
),
);
stack.finish()
} else {
mailbox_button
};
SavePosition::new(
Container::new(Align::new(mailbox_element).finish()).finish(),
NOTIFICATIONS_MAILBOX_POSITION_ID,
)
.finish()
}
/// Adds the configurable right-side toolbar items plus the fixed controls
/// (update pill, offline indicator, avatar, etc.) that are not configurable.
fn add_configurable_right_side_tab_bar_controls(
&self,
target: &mut Flex,
config: &crate::workspace::tab_settings::HeaderToolbarChipSelection,
is_web_anonymous_user: bool,
appearance: &Appearance,
ctx: &AppContext,
) {
if let Some(update_pill) = self.render_tab_overflow_menu(ctx, appearance) {
target.add_child(
Container::new(update_pill)
.with_margin_left(TAB_BAR_PADDING_LEFT)
.finish(),
);
}
let is_online = NetworkStatus::as_ref(ctx).is_online();
if !is_online {
target.add_child(
Container::new(self.render_offline_button(appearance))
.with_margin_right(4.)
.finish(),
);
}
for item in config.right_items() {
if let Some(button) = self.render_header_toolbar_button(&item, appearance, ctx) {
target.add_child(button);
}
}
// Legacy AI assistant button (non-agent-mode only)
if is_online
&& !FeatureFlag::AgentMode.is_enabled()
&& !is_web_anonymous_user
&& !self.current_workspace_state.is_ai_assistant_panel_open
{
target.add_child(
Container::new(
SavePosition::new(
self.render_legacy_warp_ai_entrypoint_button(appearance),
AI_ASSISTANT_BUTTON_ID,
)
.finish(),
)
.with_margin_left(TAB_BAR_PADDING_LEFT)
.finish(),
);
}
target.add_child(
Container::new(self.render_settings_button(appearance))
.with_margin_left(TAB_BAR_PADDING_LEFT)
.finish(),
);
if self.auth_state.is_anonymous_or_logged_out()
&& !FeatureFlag::OpenWarpNewSettingsModes.is_enabled()
{
if is_web_anonymous_user {
target.add_child(
Container::new(self.render_web_anonymous_user_sign_in_button(appearance))
.with_margin_left(8.)
.finish(),
);
} else {
target.add_child(
Container::new(self.render_anonymous_sign_up_user_button(appearance))
.with_margin_left(8.)
.finish(),
);
}
}
let zoom_factor = WindowSettings::as_ref(ctx).zoom_level.as_zoom_factor();
let traffic_light_data = traffic_light_data(ctx, self.window_id);
if let Some(traffic_light_data) = traffic_light_data.as_ref() {
let vertical_tabs_active = FeatureFlag::VerticalTabs.is_enabled()
&& *TabSettings::as_ref(ctx).use_vertical_tabs;
let right_panel_open = self.current_workspace_state.is_right_panel_open();
let should_reserve_right_traffic_light_space =
vertical_tabs_active || !right_panel_open;
if traffic_light_data.side == TrafficLightSide::Right
&& should_reserve_right_traffic_light_space
{
target.add_child(
ConstrainedBox::new(Empty::new().finish())
.with_width(traffic_light_data.width(zoom_factor))
.finish(),
);
}
}
}
fn compute_tab_bar_left_padding(&self, ctx: &AppContext) -> f32 {
let zoom_factor = WindowSettings::as_ref(ctx).zoom_level.as_zoom_factor();
let traffic_light_data = traffic_light_data(ctx, self.window_id);
let is_window_fullscreen = ctx
.windows()
.platform_window(self.window_id)
.map(|window| window.fullscreen_state() == FullscreenState::Fullscreen)
.unwrap_or(false);
if self.current_workspace_state.is_left_panel_open() {
0.
} else if is_window_fullscreen && cfg!(target_os = "macos") {
// Full-screen mode on MacOS does not need as much padding (traffic lights are hidden).
TAB_BAR_PADDING_LEFT
} else {
traffic_light_data
.as_ref()
.filter(|data| data.side == TrafficLightSide::Left)
.map(|data| data.width(zoom_factor))
.unwrap_or(0.)
+ 16.
}
}
/// Renders the tab bar contents, wrapped in hover and drag-drop behaviors.
fn render_tab_bar(
&self,
tab_fixed_width: Option<f32>,
appearance: &Appearance,
ctx: &AppContext,
) -> Box<dyn Element> {
let bar_contents = ConstrainedBox::new(
// We can wrap the whole tab bar in the a drop target with the `AfterTabIndex` drop target data since the API for accepting a drop target with nested
// drop target elements will default to the inner ones (in this case the tabs or the button before the tabs)
DropTarget::new(
self.render_tab_bar_contents(tab_fixed_width, appearance, ctx),
TabBarDropTargetData {
tab_bar_location: TabBarLocation::AfterTabIndex(self.tabs.len()),
},
)
.finish(),
)
.with_height(TAB_BAR_HEIGHT)
.finish();
let tab_bar_border =
Border::bottom(TAB_BAR_BORDER_HEIGHT).with_border_fill(appearance.theme().outline());
let mut tab_bar_container = Container::new(
EventHandler::new(Clipped::new(self.render_tab_bar_hoverable(bar_contents)).finish())
.on_back_mouse_down(move |ctx, _app, _position| {
ctx.dispatch_typed_action(WorkspaceAction::ActivatePrevTab);
DispatchEventResult::StopPropagation
})
.on_forward_mouse_down(move |ctx, _app, _position| {
ctx.dispatch_typed_action(WorkspaceAction::ActivateNextTab);
DispatchEventResult::StopPropagation
})
.finish(),
)
.with_border(tab_bar_border);
if FeatureFlag::NewTabStyling.is_enabled() {
tab_bar_container = tab_bar_container
.with_background(internal_colors::fg_overlay_1(appearance.theme()));
}
let tab_bar_element = tab_bar_container.finish();
let dimming_color = appearance.theme().background().into();
SavePosition::new(
WindowFocusDimming::apply_panel_header_dimming(
tab_bar_element,
self.mouse_states.header_dimming.clone(),
TAB_BAR_HEIGHT,
dimming_color,
self.window_id,
ctx,
),
TAB_BAR_POSITION_ID,
)
.finish()
}
// Render traffic lights, if appropriate for the current platform.
fn maybe_render_traffic_lights(&self, stack: &mut Stack, app: &AppContext) {
let Some(traffic_light_data) = traffic_light_data(app, self.window_id) else {
return;
};
let appearance = Appearance::as_ref(app);
let fullscreen_state = app
.windows()
.platform_window(self.window_id)
.map(|window| window.fullscreen_state())
.unwrap_or_default();
stack.add_positioned_child(
traffic_light_data.render(
fullscreen_state,
&self.traffic_light_mouse_states,
appearance.theme(),
app,
),
OffsetPositioning::offset_from_parent(
Vector2F::zero(),
ParentOffsetBounds::WindowByPosition,
ParentAnchor::TopRight,
ChildAnchor::TopRight,
),
);
}
fn render_new_session_button(&self, ctx: &AppContext) -> Box<dyn Element> {
const CORNER_RADIUS: Radius = Radius::Pixels(4.);
const BUTTON_HEIGHT: f32 = 24.;
const SIDE_MENU_WIDTH: f32 = 16.;
const BUTTON_WIDTH: f32 = 24. + SIDE_MENU_WIDTH;
const BUTTON_LEFT_MARGIN: f32 = 4.;
let new_tab_tool_tip_label_text = "New Tab".to_string();
let new_tab_tool_tip_sublabel_text =
keybinding_name_to_display_string(NEW_TAB_BINDING_NAME, ctx);
let tab_configs_tool_tip_label_text = "Tab configs".to_string();
let tab_configs_tool_tip_sublabel_text =
keybinding_name_to_display_string(TOGGLE_TAB_CONFIGS_MENU_BINDING_NAME, ctx);
let appearance = Appearance::as_ref(ctx);
if !FeatureFlag::ShellSelector.is_enabled() {
// Legacy new tab button, which shows the menu on right click.
let new_tab_button = self
.render_tab_bar_icon_button(
appearance,
icons::Icon::Plus,
&self.mouse_states.new_tab_button.clone(),
WorkspaceAction::AddDefaultTab,
new_tab_tool_tip_label_text,
new_tab_tool_tip_sublabel_text,
false,
false,
)
.on_right_click(move |ctx, _, position| {
ctx.dispatch_typed_action(WorkspaceAction::ToggleNewSessionMenu {
position,
is_vertical_tabs: false,
});
})
.finish();
return Container::new(
SavePosition::new(
Align::new(new_tab_button).finish(),
NEW_TAB_BUTTON_POSITION_ID,
)
.finish(),
)
.with_margin_left(BUTTON_LEFT_MARGIN)
.finish();
}
let theme = appearance.theme();
Hoverable::new(self.mouse_states.new_tab.clone(), |state| {
let window_id = self.window_id;
let is_active = self.show_new_session_dropdown_menu.is_some();
let new_tab_button = combo_inner_button(
appearance,
icons::Icon::Plus,
false,
self.mouse_states.new_tab_button.clone(),
)
.with_style(
UiComponentStyles::default()
.set_border_radius(CornerRadius::with_left(CORNER_RADIUS)),
)
.with_tooltip(self.render_tab_bar_icon_button_tooltip(
appearance,
new_tab_tool_tip_label_text.clone(),
new_tab_tool_tip_sublabel_text.clone(),
))
.build()
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(WorkspaceAction::AddDefaultTab);
})
.finish();
let new_session_menu_button = combo_inner_button(
appearance,
icons::Icon::ChevronDown,
is_active,
self.mouse_states.new_tab_menu.clone(),
)
.with_style(
UiComponentStyles::default()
.set_border_radius(CornerRadius::with_right(CORNER_RADIUS))
.set_width(SIDE_MENU_WIDTH),
)
.with_active_styles(
UiComponentStyles::default()
.set_background(internal_colors::fg_overlay_3(theme).into()),
)
.with_tooltip(self.render_tab_bar_icon_button_tooltip(
appearance,
tab_configs_tool_tip_label_text.clone(),
tab_configs_tool_tip_sublabel_text.clone(),
))
.build()
.on_click(move |ctx, app, _| {
// We are positioning the menu to the lower-left corner of the new tab button.
// This gives the impression that both individual buttons are one big button.
if let Some(position) =
app.element_position_by_id_at_last_frame(window_id, NEW_TAB_BUTTON_POSITION_ID)
{
ctx.dispatch_typed_action(WorkspaceAction::ToggleNewSessionMenu {
position: position.lower_left(),
is_vertical_tabs: false,
});
}
})
.finish();
let row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(
SavePosition::new(
Align::new(new_tab_button).finish(),
NEW_TAB_BUTTON_POSITION_ID,
)
.finish(),
)
.with_child(
SavePosition::new(
Align::new(new_session_menu_button).finish(),
NEW_SESSION_MENU_BUTTON_POSITION_ID,
)
.finish(),
)
.finish();
let mut ret = Container::new(
ConstrainedBox::new(row)
.with_height(BUTTON_HEIGHT)
.with_width(BUTTON_WIDTH)
.finish(),
)
.with_corner_radius(CornerRadius::with_all(CORNER_RADIUS))
.with_margin_left(BUTTON_LEFT_MARGIN);
if state.is_hovered() {
ret = ret.with_background(internal_colors::neutral_1(theme));
}
ret.finish()
})
.finish()
}
#[allow(dead_code)]
fn render_avatar_button(&self, appearance: &Appearance, ctx: &AppContext) -> Box<dyn Element> {
let is_anonymous = self.auth_state.is_anonymous_or_logged_out();
let display_name = self
.auth_state
.username_for_display()
.unwrap_or(DEFAULT_USER_DISPLAY_NAME.to_owned());
let avatar_content = if self.auth_state.is_anonymous_or_logged_out() {
AvatarContent::Icon(icons::Icon::Gear)
} else {
self.auth_state
.user_photo_url()
.map(|url| AvatarContent::Image {
url,
display_name: display_name.clone(),
})
.unwrap_or(AvatarContent::DisplayName(display_name.clone()))
};
let mut avatar = Avatar::new(
avatar_content,
UiComponentStyles {
width: Some(20.),
height: Some(20.),
border_radius: Some(CornerRadius::with_all(Radius::Percentage(50.))),
font_family_id: Some(appearance.ui_font_family()),
font_weight: Some(Weight::Bold),
background: Some(appearance.theme().accent().into()),
font_size: Some(12.),
font_color: Some(ColorU::black()),
..Default::default()
},
);
// Render the subtle autoupdate UI if autoupdate is ready and there is no incoming prominent update version.
let autoupdate_stage = autoupdate::get_update_state(ctx);
if FeatureFlag::AutoupdateUIRevamp.is_enabled()
&& autoupdate_stage.ready_for_update()
&& autoupdate_stage
.available_new_version()
.map(|version| {
!is_incoming_version_past_current(version.last_prominent_update.as_deref())
})
.unwrap_or(false)
{
avatar = avatar.with_status_element(
StatusElementTypes::Circle,
RedNotificationDot::default_styles(appearance),
);
}
let button = Hoverable::new(self.mouse_states.avatar_icon.clone(), |state| {
let mut stack = Stack::new();
let mut container = Container::new(avatar.build().finish())
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
.with_uniform_padding(2.);
if state.is_mouse_over_element() {
if !state.is_clicked() {
container = container.with_background(appearance.theme().surface_2());
}
// On hover, show tooltip of user's display name (if it exists)
if !self.is_user_menu_open && !is_anonymous {
stack.add_positioned_overlay_child(
appearance
.ui_builder()
.tool_tip(display_name.clone())
.with_style(UiComponentStyles {
background: Some(appearance.theme().tooltip_background().into()),
font_color: Some(appearance.theme().background().into_solid()),
..Default::default()
})
.build()
.finish(),
OffsetPositioning::offset_from_parent(
vec2f(0., 4.),
ParentOffsetBounds::WindowByPosition,
ParentAnchor::BottomMiddle,
ChildAnchor::TopMiddle,
),
);
}
}
stack.add_child(container.finish());
stack.finish()
})
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(WorkspaceAction::ToggleUserMenu);
})
.with_cursor(Cursor::PointingHand)
.finish();
SavePosition::new(Align::new(button).finish(), USER_AVATAR_BUTTON_POSITION_ID).finish()
}
fn render_settings_button(&self, appearance: &Appearance) -> Box<dyn Element> {
Align::new(
self.render_tab_bar_icon_button(
appearance,
icons::Icon::Stars,
&self.mouse_states.settings_icon,
WorkspaceAction::ShowSettings,
"Settings".to_string(),
self.cached_keybindings[SHOW_SETTINGS_KEYBINDING_NAME].clone(),
false,
false,
)
.finish(),
)
.finish()
}
fn render_web_anonymous_user_sign_in_button(
&self,
appearance: &Appearance,
) -> Box<dyn Element> {
let default_styles = UiComponentStyles {
font_color: Some(appearance.theme().active_ui_text_color().into()),
font_size: Some(12.),
font_weight: Some(Weight::Light),
font_family_id: Some(appearance.ui_font_family()),
border_color: None,
border_radius: Some(CornerRadius::with_all(Radius::Pixels(5.))),
border_width: Some(1.),
width: Some(80.),
height: Some(24.),
..Default::default()
};
let hovered_styles = UiComponentStyles {
font_color: Some(appearance.theme().accent().into()),
border_color: Some(appearance.theme().accent().into()),
..default_styles
};
let button = appearance
.ui_builder()
.button_with_custom_styles(
ButtonVariant::Text,
self.mouse_states.sign_in_button.clone(),
default_styles,
Some(hovered_styles),
Some(hovered_styles),
None,
)
.with_centered_text_label(String::from("Sign up"));
Align::new(
button
.build()
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(WorkspaceAction::SignInAnonymousWebUser)
})
.finish(),
)
.finish()
}
fn render_anonymous_sign_up_user_button(&self, appearance: &Appearance) -> Box<dyn Element> {
let default_styles = UiComponentStyles {
font_color: Some(appearance.theme().active_ui_text_color().into()),
font_size: Some(12.),
font_weight: Some(Weight::Semibold),
font_family_id: Some(appearance.ui_font_family()),
border_color: Some(appearance.theme().active_ui_text_color().into()),
border_radius: Some(CornerRadius::with_all(Radius::Pixels(5.))),
border_width: Some(1.),
width: Some(80.),
height: Some(24.),
..Default::default()
};
let hovered_styles = UiComponentStyles {
font_color: Some(appearance.theme().accent().into()),
border_color: Some(appearance.theme().accent().into()),
..default_styles
};
let button = appearance
.ui_builder()
.button_with_custom_styles(
ButtonVariant::Text,
self.mouse_states.sign_up_button.clone(),
default_styles,
Some(hovered_styles),
Some(hovered_styles),
None,
)
.with_centered_text_label(String::from("Sign up"));
Align::new(
button
.build()
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(WorkspaceAction::SignupAnonymousUser)
})
.finish(),
)
.finish()
}
fn render_offline_button(&self, appearance: &Appearance) -> Box<dyn Element> {
let ui_builder = appearance.ui_builder().clone();
let tool_tip_label_text = "Some features may be unavailable offline".to_string();
let icon = ConstrainedBox::new(
Container::new(
icons::Icon::CloudOffline
.to_galaxyui_icon(appearance.theme().foreground())
.finish(),
)
.with_uniform_padding(3.)
.finish(),
)
.with_width(icons::ICON_DIMENSIONS)
.with_height(icons::ICON_DIMENSIONS)
.finish();
let hoverable = Hoverable::new(self.mouse_states.offline_icon.clone(), |state| {
let mut stack = Stack::new().with_child(icon);
if state.is_hovered() {
let tool_tip = ui_builder.tool_tip(tool_tip_label_text);
stack.add_positioned_overlay_child(
tool_tip.build().finish(),
OffsetPositioning::offset_from_parent(
vec2f(0., 4.),
ParentOffsetBounds::WindowByPosition,
ParentAnchor::BottomMiddle,
ChildAnchor::TopMiddle,
),
);
}
stack.finish()
});
Align::new(hoverable.finish()).finish()
}
fn render_legacy_warp_ai_entrypoint_button(&self, appearance: &Appearance) -> Box<dyn Element> {
let (icon, action, label) = (
icons::Icon::AiAssistant,
WorkspaceAction::ClickedAIAssistantIcon,
AI_ASSISTANT_FEATURE_NAME.to_owned(),
);
Align::new(
self.render_tab_bar_icon_button(
appearance,
icon,
&self.mouse_states.ai_tab_bar_button,
action,
label,
self.cached_keybindings[ASK_AI_ASSISTANT_KEYBINDING_NAME].clone(),
false,
false,
)
.finish(),
)
.finish()
}
fn render_tab_bar_icon_button_tooltip(
&self,
appearance: &Appearance,
tool_tip_label_text: String,
tool_tip_sublabel_text: Option<String>,
) -> Box<dyn FnOnce() -> Box<dyn Element>> {
let ui_builder = appearance.ui_builder().clone();
Box::new(move || {
if let Some(tool_tip_sublabel_text) = tool_tip_sublabel_text {
ui_builder
.tool_tip_with_sublabel(tool_tip_label_text, tool_tip_sublabel_text)
.build()
.finish()
} else {
ui_builder.tool_tip(tool_tip_label_text).build().finish()
}
})
}
#[allow(clippy::too_many_arguments)]
fn render_tab_bar_icon_button(
&self,
appearance: &Appearance,
icon_type: icons::Icon,
mouse_state_handle: &MouseStateHandle,
action: WorkspaceAction,
tool_tip_label_text: String,
tool_tip_sublabel_text: Option<String>,
is_active: bool,
disable: bool,
) -> Hoverable {
let theme = appearance.theme();
let icon_color = if is_active {
theme.main_text_color(theme.background())
} else {
theme.sub_text_color(theme.background())
};
let mut button = icon_button_with_color(
appearance,
icon_type,
is_active,
mouse_state_handle.clone(),
icon_color,
);
button = button
.with_hovered_styles(UiComponentStyles {
font_color: Some(icon_color.into()),
background: Some(theme.surface_2().into()),
..UiComponentStyles::default()
})
.with_clicked_styles(UiComponentStyles {
font_color: Some(icon_color.into()),
background: Some(theme.background().into()),
..UiComponentStyles::default()
});
if is_active {
button = button.with_active_styles(UiComponentStyles {
background: Some(internal_colors::fg_overlay_3(theme).into()),
..UiComponentStyles::default()
});
}
if disable {
button = button.with_style(UiComponentStyles {
font_color: Some(theme.disabled_text_color(theme.background()).into()),
..UiComponentStyles::default()
});
button.build().disable()
} else {
button
.with_tooltip(self.render_tab_bar_icon_button_tooltip(
appearance,
tool_tip_label_text,
tool_tip_sublabel_text,
))
.build()
.on_click(move |ctx, _, _| ctx.dispatch_typed_action(action.clone()))
}
}
fn render_tab_overflow_menu(
&self,
app: &AppContext,
appearance: &Appearance,
) -> Option<Box<dyn Element>> {
if !ContextFlag::PromptForVersionUpdates.is_enabled() {
return None;
}
let autoupdate_stage = autoupdate::get_update_state(app);
// Render the prominent autoupdate pill if autoupdate is ready and the current version is behind a prominent update version.
if autoupdate_stage.ready_for_update()
&& (!FeatureFlag::AutoupdateUIRevamp.is_enabled()
|| autoupdate_stage
.available_new_version()
.map(|version| {
is_incoming_version_past_current(version.last_prominent_update.as_deref())
})
.unwrap_or(false))
{
let pill = ConstrainedBox::new(
Container::new(
Flex::row()
.with_child(
Text::new_inline(
UPDATE_READY_TEXT,
appearance.ui_font_family(),
PILL_FONT_SIZE,
)
.with_color(Fill::warn().into())
.finish(),
)
.with_main_axis_size(MainAxisSize::Max)
.with_main_axis_alignment(MainAxisAlignment::Center)
.finish(),
)
.with_border(Border::all(1.).with_border_color(Fill::warn().into()))
.with_corner_radius(CornerRadius::with_all(Radius::Percentage(50.)))
.with_uniform_margin(4.)
.with_uniform_padding(4.)
.with_margin_right(5.)
.finish(),
)
.with_width(TAB_BAR_PILL_WIDTH)
.finish();
let button = if self.show_tab_bar_overflow_menu {
pill
} else {
// Only attach the event handler in the case where the menu isn't already showing
// Otherwise we have a race condition in the case that someone clicks on the button
// where the menu tries to dismiss itself onclick and the menu gets reshown on mouseup
Hoverable::new(self.mouse_states.overflow_button.clone(), |_state| pill)
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(WorkspaceAction::ToggleTabBarOverflowMenu);
})
.with_reset_cursor_after_click()
.with_cursor(Cursor::PointingHand)
.finish()
};
Some(Align::new(SavePosition::new(button, "tab_bar_overflow_button").finish()).finish())
} else {
None
}
}
fn render_banner_and_active_tab(
&self,
app: &AppContext,
appearance: &Appearance,
) -> Box<dyn Element> {
let active_tab_data = &self.tabs[self.active_tab_index];
let active_content = if FeatureFlag::AgentManagementView.is_enabled()
&& self.current_workspace_state.is_agent_management_view_open
{
ChildView::new(&self.agent_management_view).finish()
} else {
ChildView::new(&active_tab_data.pane_group).finish()
};
let terminal_content = match self.maybe_render_workspace_banner(app, appearance) {
Some(banner_element) => Flex::column()
.with_child(banner_element)
.with_child(Shrinkable::new(1., active_content).finish())
.finish(),
None => active_content,
};
let vertical_tabs_active =
FeatureFlag::VerticalTabs.is_enabled() && *TabSettings::as_ref(app).use_vertical_tabs;
let pane_group = self.active_tab_pane_group().as_ref(app);
let is_right_open = pane_group.right_panel_open;
let is_right_maximized = is_right_open && pane_group.is_right_panel_maximized;
let mut main_content = Flex::row();
// In horizontal tabs mode, config-driven panels render inside this row
// so they share the same background/corner-radius wrapper from render_main_panel.
// In vertical tabs mode, panels are rendered in render_panels instead.
if !vertical_tabs_active {
let config = TabSettings::as_ref(app)
.header_toolbar_chip_selection
.clone();
let mut prev_panel_added = false;
for item in config.left_items() {
Self::add_panel_with_separator(
&mut main_content,
&mut prev_panel_added,
self.render_config_panel(&item, pane_group, &config, app),
app,
);
}
if !is_right_maximized {
if prev_panel_added {
main_content.add_child(Self::render_panel_separator(app));
}
main_content =
main_content.with_child(Shrinkable::new(1.0, terminal_content).finish());
prev_panel_added = true;
}
for item in config.right_items() {
Self::add_panel_with_separator(
&mut main_content,
&mut prev_panel_added,
self.render_config_panel(&item, pane_group, &config, app),
app,
);
}
if is_right_maximized {
Self::add_panel_with_separator(
&mut main_content,
&mut prev_panel_added,
self.render_config_panel_maximized(pane_group, &config, app),
app,
);
}
} else if !is_right_maximized {
main_content = main_content.with_child(Shrinkable::new(1.0, terminal_content).finish());
}
let clickable_element = EventHandler::new(main_content.finish())
.on_back_mouse_down(|ctx, _app, _position| {
ctx.dispatch_typed_action(WorkspaceAction::ActivatePrevTab);
DispatchEventResult::StopPropagation
})
.on_forward_mouse_down(|ctx, _app, _position| {
ctx.dispatch_typed_action(WorkspaceAction::ActivateNextTab);
DispatchEventResult::StopPropagation
})
.finish();
Shrinkable::new(
THEME_CHOOSER_RATIO,
SavePosition::new(clickable_element, TAB_CONTENT_POSITION_ID).finish(),
)
.finish()
}
fn render_theme_chooser(&self) -> Box<dyn Element> {
let theme_chooser = ChildView::new(&self.theme_chooser_view).finish();
ConstrainedBox::new(theme_chooser)
.with_max_width(240.0)
.finish()
}
#[cfg(not(target_family = "wasm"))]
fn render_resource_center(&self) -> Box<dyn Element> {
ConstrainedBox::new(ChildView::new(&self.resource_center_view).finish())
.with_width(RESOURCE_CENTER_WIDTH)
.finish()
}
// Allow let and return because of the conditional linux compilation (otherwise we get a clippy
// warning on mac)
#[allow(clippy::let_and_return)]
fn banner_fields(&self, app: &AppContext) -> Option<WorkspaceBannerFields> {
// The settings error banner sits just below reauth in priority — it's
// more important that users are notified their settings file is broken
// than that they continue to see any of the autoupdate or crash recovery
// banners.
let banner_fields = self
.render_reauth_banner_element()
.or_else(|| self.render_settings_error_banner(app))
.or_else(|| self.render_autoupdate_banner_element(app));
#[cfg(enable_crash_recovery)]
let banner_fields = banner_fields.or_else(|| crash_recovery::banner_metadata(app));
banner_fields
}
fn render_settings_error_banner(&self, app: &AppContext) -> Option<WorkspaceBannerFields> {
if self.settings_error_banner_dismissed {
return None;
}
let error = self.settings_file_error.as_ref()?;
let (heading, description) = error.heading_and_description();
let secondary_button =
AISettings::as_ref(app)
.is_any_ai_enabled(app)
.then(|| WorkspaceBannerButtonDetails {
text: "Fix with Oz".to_owned(),
action: WorkspaceAction::FixSettingsWithOz {
error_description: error.to_string(),
},
variant: BannerButtonVariant::Naked,
icon: Some(Icon::Oz),
more_info_button_action: None,
});
Some(WorkspaceBannerFields {
banner_type: WorkspaceBanner::InvalidSettings,
severity: BannerSeverity::Warning,
heading: Some(heading),
description,
secondary_button,
button: Some(WorkspaceBannerButtonDetails {
text: "Open file".to_owned(),
action: WorkspaceAction::OpenSettingsFile,
variant: BannerButtonVariant::Outlined,
icon: None,
more_info_button_action: None,
}),
})
}
fn maybe_render_workspace_banner(
&self,
app: &AppContext,
appearance: &Appearance,
) -> Option<Box<dyn Element>> {
self.banner_fields(app)
.map(|fields| self.render_workspace_banner(fields, appearance))
}
fn render_reauth_banner_element(&self) -> Option<WorkspaceBannerFields> {
if self.reauth_banner_dismissed || !self.auth_state.needs_reauth() {
return None;
}
Some(WorkspaceBannerFields {
banner_type: WorkspaceBanner::Reauth,
severity: BannerSeverity::Warning,
heading: Some("Your login has expired.".into()),
description: "Please sign in again to restore access to cloud-based features.".into(),
secondary_button: None,
button: Some(WorkspaceBannerButtonDetails {
text: "Sign in".into(),
action: WorkspaceAction::Reauth,
variant: BannerButtonVariant::Outlined,
icon: None,
more_info_button_action: None,
}),
})
}
fn render_autoupdate_banner_element(&self, app: &AppContext) -> Option<WorkspaceBannerFields> {
if FeatureFlag::Autoupdate.is_enabled() {
match autoupdate::get_update_state(app) {
AutoupdateStage::UnableToUpdateToNewVersion { new_version }
if !self.autoupdate_unable_to_update_banner_dismissed =>
{
let description =
if is_incoming_version_past_current(new_version.soft_cutoff.as_deref()) {
VERSION_DEPRECATION_WITHOUT_PERMISSIONS_BANNER_TEXT.to_owned()
} else {
"A new version is available but Galaxy is unable to perform the update."
.to_owned()
};
Some(WorkspaceBannerFields {
banner_type: WorkspaceBanner::UnableToUpdateToNewVersion,
severity: BannerSeverity::Error,
heading: None,
description,
secondary_button: None,
button: Some(WorkspaceBannerButtonDetails {
text: "Update Galaxy manually".to_string(),
action: WorkspaceAction::DownloadNewVersion,
variant: BannerButtonVariant::Outlined,
icon: None,
more_info_button_action: Some(WorkspaceAction::AutoupdateFailureLink),
}),
})
}
AutoupdateStage::UnableToLaunchNewVersion { new_version }
if !self.autoupdate_unable_to_launch_new_version =>
{
let description =
if is_incoming_version_past_current(new_version.soft_cutoff.as_deref()) {
VERSION_DEPRECATION_WITHOUT_PERMISSIONS_BANNER_TEXT.to_owned()
} else {
"Galaxy was unable to launch the new installed version.".to_owned()
};
Some(WorkspaceBannerFields {
banner_type: WorkspaceBanner::UnableToLaunchNewVersion,
severity: BannerSeverity::Error,
heading: None,
description,
secondary_button: None,
button: Some(WorkspaceBannerButtonDetails {
text: "Update Galaxy manually".to_string(),
action: WorkspaceAction::DownloadNewVersion,
variant: BannerButtonVariant::Outlined,
icon: None,
more_info_button_action: Some(WorkspaceAction::AutoupdateFailureLink),
}),
})
}
AutoupdateStage::UpdateReady { new_version, .. }
| AutoupdateStage::UpdatedPendingRestart { new_version } => {
if is_incoming_version_past_current(new_version.soft_cutoff.as_deref()) {
Some(WorkspaceBannerFields {
banner_type: WorkspaceBanner::VersionDeprecated,
severity: BannerSeverity::Error,
heading: None,
description: VERSION_DEPRECATION_BANNER_TEXT.to_string(),
secondary_button: None,
button: Some(WorkspaceBannerButtonDetails {
text: "Update now".to_string(),
action: WorkspaceAction::ApplyUpdate,
variant: BannerButtonVariant::Outlined,
icon: None,
more_info_button_action: None,
}),
})
} else if let Some(update_by) = new_version.update_by {
self.server_time.as_ref().and_then(|server_time| {
(server_time.current_time() > update_by).then(|| {
WorkspaceBannerFields {
banner_type: WorkspaceBanner::VersionDeprecated,
severity: BannerSeverity::Warning,
heading: None,
description: "Your app is out of date and needs to update."
.to_string(),
secondary_button: None,
button: Some(WorkspaceBannerButtonDetails {
text: "Restart app and update now".to_string(),
action: WorkspaceAction::ApplyUpdate,
variant: BannerButtonVariant::Outlined,
icon: None,
more_info_button_action: None,
}),
}
})
})
} else {
None
}
}
AutoupdateStage::NoUpdateAvailable
| AutoupdateStage::CheckingForUpdate
| AutoupdateStage::DownloadingUpdate
| AutoupdateStage::Updating { .. }
| AutoupdateStage::UnableToUpdateToNewVersion { .. }
| AutoupdateStage::UnableToLaunchNewVersion { .. } => None,
}
} else {
None
}
}
fn render_workspace_banner(
&self,
fields: WorkspaceBannerFields,
appearance: &Appearance,
) -> Box<dyn Element> {
let theme = appearance.theme();
let bg_color = match fields.severity {
BannerSeverity::Warning => theme.ansi_fg_yellow(),
BannerSeverity::Error => theme.ansi_fg_red(),
};
let text_color = theme.main_text_color(Fill::Solid(bg_color)).into_solid();
// Left side: alert icon + bold heading + regular description, all inline.
let icon = ConstrainedBox::new(
Icon::AlertCircle
.to_galaxyui_icon(text_color.into())
.finish(),
)
.with_width(16.)
.with_height(16.)
.finish();
let ui_font_family = appearance.ui_font_family();
const BANNER_FONT_SIZE: f32 = 12.;
// Combine heading and description into a single `Text` so it can
// elide with a trailing ellipsis when there isn't enough room for the
// buttons. The heading portion is highlighted with Semibold weight.
// See `ConversationSearchItem::render_item` for the same pattern.
let heading_char_count = fields
.heading
.as_ref()
.map(|heading| heading.chars().count())
.unwrap_or(0);
let combined_text = match fields.heading {
Some(heading) => format!("{heading} {}", fields.description),
None => fields.description,
};
let mut text = Text::new_inline(combined_text, ui_font_family, BANNER_FONT_SIZE)
.with_color(text_color)
.with_clip(ClipConfig::ellipsis());
if heading_char_count > 0 {
text = text.with_single_highlight(
Highlight::new().with_properties(Properties::default().weight(Weight::Semibold)),
(0..heading_char_count).collect(),
);
}
let mut banner = Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(Container::new(icon).with_margin_right(8.).finish())
// `Expanded` (not `Shrinkable`) so the text fills the remaining
// row width and pushes the action buttons to the right even when
// the text is short. Truncation still applies when the text would
// otherwise overflow.
.with_child(Expanded::new(1., text.finish()).finish());
if let Some(secondary_button) = fields.secondary_button {
banner.add_child(
Container::new(self.render_banner_action_button(
secondary_button,
self.mouse_states.banner_secondary_button.clone(),
text_color,
appearance,
))
.with_margin_left(4.)
.finish(),
);
}
if let Some(button) = fields.button {
let more_info_button_action = button.more_info_button_action.clone();
banner.add_child(
Container::new(self.render_banner_action_button(
button,
self.mouse_states.banner_button.clone(),
text_color,
appearance,
))
.with_margin_left(4.)
.finish(),
);
if let Some(more_info_button_action) = more_info_button_action {
let more_info_details = WorkspaceBannerButtonDetails {
text: "More info".to_owned(),
action: more_info_button_action,
variant: BannerButtonVariant::Outlined,
icon: None,
more_info_button_action: None,
};
banner.add_child(
Container::new(self.render_banner_action_button(
more_info_details,
self.mouse_states.more_info_banner_button.clone(),
text_color,
appearance,
))
.with_margin_left(4.)
.finish(),
);
}
}
if fields.banner_type.is_dismissible() {
let dismiss_target = fields.banner_type;
banner.add_child(
Container::new(
Hoverable::new(
self.mouse_states.dismiss_banner_button.clone(),
move |state| {
let mut container = Container::new(
ConstrainedBox::new(
// Plain x-close glyph (`Icon::X` →
// `x-close.svg`), matching the Figma
// design. `Icon::XCircle` wraps the x in
// a circle which is not what we want.
Icon::X.to_galaxyui_icon(text_color.into()).finish(),
)
.with_width(16.)
.with_height(16.)
.finish(),
)
.with_uniform_padding(2.)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)));
if state.is_hovered() {
container = container
.with_background_color(coloru_with_opacity(text_color, 20));
}
container.finish()
},
)
.with_cursor(Cursor::PointingHand)
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(WorkspaceAction::DismissWorkspaceBanner(
dismiss_target,
));
})
.finish(),
)
.with_margin_left(4.)
.finish(),
);
}
ConstrainedBox::new(
Container::new(banner.finish())
.with_background_color(bg_color)
.with_uniform_padding(8.)
.finish(),
)
.finish()
}
/// Renders a single banner action button using the Figma-spec'd Naked or
/// Secondary variants: no fill by default, optional 1px border, text and
/// icon tinted with the banner's contrast-safe text color.
fn render_banner_action_button(
&self,
details: WorkspaceBannerButtonDetails,
mouse_state: MouseStateHandle,
text_color: ColorU,
appearance: &Appearance,
) -> Box<dyn Element> {
let WorkspaceBannerButtonDetails {
text,
action,
variant,
icon,
..
} = details;
let ui_font_family = appearance.ui_font_family();
Hoverable::new(mouse_state, move |state| {
let mut row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_alignment(MainAxisAlignment::Center)
.with_main_axis_size(MainAxisSize::Min);
if let Some(icon) = icon {
row.add_child(
Container::new(
ConstrainedBox::new(icon.to_galaxyui_icon(text_color.into()).finish())
.with_width(14.)
.with_height(14.)
.finish(),
)
.with_margin_right(4.)
.finish(),
);
}
row.add_child(
Text::new_inline(text.clone(), ui_font_family, 12.)
.with_color(text_color)
.with_style(Properties {
weight: Weight::Semibold,
..Default::default()
})
.finish(),
);
let mut container = Container::new(row.finish())
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
.with_horizontal_padding(8.);
if matches!(variant, BannerButtonVariant::Outlined) {
container = container.with_border(Border::all(1.).with_border_color(text_color));
}
if state.is_hovered() {
container = container.with_background_color(coloru_with_opacity(text_color, 20));
}
ConstrainedBox::new(container.finish())
.with_height(24.)
.finish()
})
.with_cursor(Cursor::PointingHand)
.on_click(move |ctx, _, _| ctx.dispatch_typed_action(action.clone()))
.finish()
}
fn dismiss_workspace_banner(
&mut self,
ctx: &mut ViewContext<Self>,
banner_type: &WorkspaceBanner,
) {
match banner_type {
WorkspaceBanner::UnableToUpdateToNewVersion => {
self.autoupdate_unable_to_update_banner_dismissed = true;
}
WorkspaceBanner::UnableToLaunchNewVersion => {
self.autoupdate_unable_to_launch_new_version = true;
}
WorkspaceBanner::VersionDeprecated => {}
WorkspaceBanner::AnonymousUserAuth => {}
WorkspaceBanner::Reauth => {
self.reauth_banner_dismissed = true;
}
#[cfg(all(enable_crash_recovery, target_os = "linux"))]
WorkspaceBanner::WaylandCrashRecovery => {
crash_recovery::dismiss_workspace_banner(ctx);
}
WorkspaceBanner::InvalidSettings => {
self.settings_error_banner_dismissed = true;
self.sync_settings_error_state_into_settings_pane(ctx);
}
}
ctx.notify();
}
fn render_panel(
&self,
app: &AppContext,
contents: Box<dyn Element>,
side: &PanelPosition,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let mut col = Flex::column().with_main_axis_size(MainAxisSize::Max);
let mut contents = contents;
let traffic_light_data = traffic_light_data(app, self.window_id);
let vertical_tabs_active =
FeatureFlag::VerticalTabs.is_enabled() && *TabSettings::as_ref(app).use_vertical_tabs;
// Add a spacer for the traffic light buttons on Windows/Linux.
if traffic_light_data.is_some_and(|data| data.side == TrafficLightSide::Right)
&& *side == PanelPosition::Right
&& !vertical_tabs_active
{
col.add_child(
ConstrainedBox::new(Empty::new().finish())
.with_height(TAB_BAR_HEIGHT)
.finish(),
);
contents = Container::new(contents)
.with_border(Border::top(1.).with_border_fill(appearance.theme().surface_2()))
.finish();
}
col.add_child(Shrinkable::new(1.0, contents).finish());
self.wrap_in_panel_surface(appearance, side, col.finish(), *PANEL_CORNER_RADIUS)
}
fn wrap_in_panel_surface(
&self,
appearance: &Appearance,
side: &PanelPosition,
contents: Box<dyn Element>,
corner_radius: CornerRadius,
) -> Box<dyn Element> {
let mut container = Container::new(contents)
.with_background(appearance.theme().surface_1().with_opacity(90))
.with_corner_radius(corner_radius);
match side {
PanelPosition::Left => container = container.with_margin_right(2.0),
PanelPosition::Right => container = container.with_margin_left(2.0),
};
container.finish()
}
fn render_main_panel(
&self,
app: &AppContext,
terminal_view: Box<dyn Element>,
) -> Box<dyn Element> {
if FeatureFlag::VerticalTabs.is_enabled() && *TabSettings::as_ref(app).use_vertical_tabs {
Shrinkable::new(1.0, terminal_view).finish()
} else {
let main_content = Container::new(terminal_view)
.with_background(util::get_terminal_background_fill(self.window_id, app))
.with_corner_radius(*PANEL_CORNER_RADIUS)
.finish();
Shrinkable::new(1.0, main_content).finish()
}
}
fn render_panel_separator(app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
ConstrainedBox::new(
Rect::new()
.with_background_color(appearance.theme().outline().into_solid())
.finish(),
)
.with_width(1.0)
.finish()
}
fn add_panel_with_separator(
panels_view: &mut Flex,
prev_panel_added: &mut bool,
panel: Option<Box<dyn Element>>,
app: &AppContext,
) {
if let Some(panel) = panel {
if *prev_panel_added {
panels_view.add_child(Self::render_panel_separator(app));
}
panels_view.add_child(panel);
*prev_panel_added = true;
}
}
fn render_panels(
&self,
app: &AppContext,
terminal_view: Box<dyn Element>,
hide_vertical_tabs: bool,
) -> Box<dyn Element> {
let mut panels_view = Flex::row();
let mut prev_panel_added = false;
// Config-driven vertical-tabs-era panels (left side).
// Hidden for simplified WASM views (notebooks, shared sessions, etc.)
// where these panels are unnecessary.
let vertical_tabs_active = !hide_vertical_tabs
&& FeatureFlag::VerticalTabs.is_enabled()
&& *TabSettings::as_ref(app).use_vertical_tabs;
// In vertical tabs mode, config-driven panels are rendered here.
// In horizontal tabs mode, they're rendered inside render_banner_and_active_tab
// so they share the same background/corner-radius wrapper.
if vertical_tabs_active {
let config = TabSettings::as_ref(app)
.header_toolbar_chip_selection
.clone();
let pane_group = self.active_tab_pane_group().as_ref(app);
for item in config.left_items() {
Self::add_panel_with_separator(
&mut panels_view,
&mut prev_panel_added,
self.render_config_panel(&item, pane_group, &config, app),
app,
);
}
}
// Theme chooser (workspace-level, not configurable).
// Uses wrap_in_panel_surface which adds margin for its own visual separation,
// so we add a separator before it only if a config panel is to its left, then
// reset the flag so no separator is added between the theme chooser and the terminal.
if self.current_workspace_state.is_theme_chooser_open {
if prev_panel_added {
panels_view.add_child(Self::render_panel_separator(app));
}
panels_view.add_child(self.render_panel(
app,
self.render_theme_chooser(),
&PanelPosition::Left,
));
prev_panel_added = false;
}
if prev_panel_added {
panels_view.add_child(Self::render_panel_separator(app));
}
panels_view = panels_view.with_child(self.render_main_panel(app, terminal_view));
prev_panel_added = true;
if vertical_tabs_active {
let config = TabSettings::as_ref(app)
.header_toolbar_chip_selection
.clone();
let pane_group = self.active_tab_pane_group().as_ref(app);
for item in config.right_items() {
Self::add_panel_with_separator(
&mut panels_view,
&mut prev_panel_added,
self.render_config_panel(&item, pane_group, &config, app),
app,
);
}
if pane_group.right_panel_open && pane_group.is_right_panel_maximized {
Self::add_panel_with_separator(
&mut panels_view,
&mut prev_panel_added,
self.render_config_panel_maximized(pane_group, &config, app),
app,
);
}
}
#[cfg(target_family = "wasm")]
if !galaxyui::platform::wasm::is_mobile_device()
&& self
.current_workspace_state
.is_transcript_details_panel_open
{
if let Some(panel_content) = self.render_transcript_details_panel(app) {
panels_view = panels_view.with_child(panel_content);
}
}
// Resource center and AI assistant are workspace-level panels, not configurable.
#[cfg(not(target_family = "wasm"))]
if self.current_workspace_state.is_right_panel_open() {
let right_panel_content = if self.current_workspace_state.is_resource_center_open {
Some(self.render_panel(app, self.render_resource_center(), &PanelPosition::Right))
} else if self.current_workspace_state.is_ai_assistant_panel_open {
Some(self.render_panel(
app,
ChildView::new(&self.ai_assistant_panel).finish(),
&PanelPosition::Right,
))
} else {
log::warn!(
"is_right_panel_open() returned true, but neither the resource center nor AI \
assistant are open"
);
None
};
if let Some(right_panel_content) = right_panel_content {
panels_view = panels_view.with_child(right_panel_content);
}
}
panels_view.finish()
}
fn is_mailbox_on_left(config: &HeaderToolbarChipSelection) -> bool {
config
.left_items()
.contains(&HeaderToolbarItemKind::NotificationsMailbox)
}
fn tabs_panel_side(config: &HeaderToolbarChipSelection) -> PanelPosition {
if config
.left_items()
.contains(&HeaderToolbarItemKind::TabsPanel)
{
PanelPosition::Left
} else {
PanelPosition::Right
}
}
/// Renders a configurable panel for the given toolbar item, if it is open.
/// Returns `None` if the panel should not be rendered (item not available,
/// panel not open, or item is not a panel type).
fn render_config_panel(
&self,
item: &HeaderToolbarItemKind,
pane_group: &PaneGroup,
config: &HeaderToolbarChipSelection,
app: &AppContext,
) -> Option<Box<dyn Element>> {
if !item.is_available(app) || !item.is_panel() {
return None;
}
match item {
HeaderToolbarItemKind::TabsPanel => {
if !self.vertical_tabs_panel_open {
return None;
}
Some(
SavePosition::new(
self.render_vertical_tabs_panel(Self::tabs_panel_side(config), app),
VERTICAL_TABS_PANEL_POSITION_ID,
)
.finish(),
)
}
HeaderToolbarItemKind::ToolsPanel => {
if !pane_group.left_panel_open || galaxyui::platform::is_mobile_device() {
return None;
}
Some(ChildView::new(&self.left_panel_view).finish())
}
HeaderToolbarItemKind::CodeReview => {
if !pane_group.right_panel_open {
return None;
}
if pane_group.is_right_panel_maximized {
return None;
}
Some(ChildView::new(&self.right_panel_view).finish())
}
HeaderToolbarItemKind::AgentManagement
| HeaderToolbarItemKind::NotificationsMailbox => None,
}
}
/// Renders the maximized code review panel if it is configured and maximized.
fn render_config_panel_maximized(
&self,
pane_group: &PaneGroup,
_config: &HeaderToolbarChipSelection,
app: &AppContext,
) -> Option<Box<dyn Element>> {
if !pane_group.right_panel_open || !pane_group.is_right_panel_maximized {
return None;
}
if !HeaderToolbarItemKind::CodeReview.is_available(app) {
return None;
}
Some(Shrinkable::new(1.0, ChildView::new(&self.right_panel_view).finish()).finish())
}
/// Offset positioning for agent toasts.
/// TODO: update positioning based on input mode.
fn agent_toast_positioning(&self) -> OffsetPositioning {
OffsetPositioning::offset_from_save_position_element(
TAB_CONTENT_POSITION_ID,
vec2f(0., 16.),
PositionedElementOffsetBounds::WindowByPosition,
PositionedElementAnchor::TopRight,
ChildAnchor::TopRight,
)
}
/// Offset positioning for global toasts.
// TODO: update positioning based on input mode.
fn global_toast_positioning(&self) -> OffsetPositioning {
OffsetPositioning::offset_from_save_position_element(
TAB_CONTENT_POSITION_ID,
vec2f(0., 16.),
PositionedElementOffsetBounds::WindowByPosition,
PositionedElementAnchor::TopMiddle,
ChildAnchor::TopMiddle,
)
}
/// Offset positioning for the update toast.
fn update_toast_positioning(
&self,
input_position_id: String,
app: &AppContext,
) -> OffsetPositioning {
let input_mode = InputModeSettings::as_ref(app).input_mode.value();
match input_mode {
InputMode::PinnedToBottom => OffsetPositioning::offset_from_save_position_element(
input_position_id,
vec2f(-16., -16.),
PositionedElementOffsetBounds::WindowByPosition,
PositionedElementAnchor::TopRight,
ChildAnchor::BottomRight,
),
InputMode::PinnedToTop => OffsetPositioning::offset_from_save_position_element(
input_position_id,
vec2f(-16., 16.),
PositionedElementOffsetBounds::WindowByPosition,
PositionedElementAnchor::BottomRight,
ChildAnchor::TopRight,
),
InputMode::Waterfall => OffsetPositioning::offset_from_parent(
vec2f(-16., -16.),
ParentOffsetBounds::WindowByPosition,
ParentAnchor::BottomRight,
ChildAnchor::BottomRight,
),
}
}
fn add_toggle_setting_context_flags(&self, app: &AppContext, context: &mut Context) {
let privacy_settings = PrivacySettings::as_ref(app);
let editor_settings = AppEditorSettings::as_ref(app);
let semantic_selection_settings = SemanticSelection::as_ref(app);
let selection_settings = SelectionSettings::as_ref(app);
let session_settings = SessionSettings::as_ref(app);
let safe_mode_settings: &SafeModeSettings = SafeModeSettings::as_ref(app);
let block_list_settings = BlockListSettings::as_ref(app);
let tab_settings = TabSettings::as_ref(app);
let alias_expansion_settings = AliasExpansionSettings::as_ref(app);
let code_settings = CodeSettings::as_ref(app);
let input_settings = InputSettings::as_ref(app);
let reporting_setings = AltScreenReporting::as_ref(app);
let general_settings = GeneralSettings::as_ref(app);
let theme_settings = ThemeSettings::as_ref(app);
let ssh_settings = SshSettings::as_ref(app);
let warpify_settings = WarpifySettings::as_ref(app);
let terminal_settings = TerminalSettings::as_ref(app);
let pane_settings = PaneSettings::as_ref(app);
let keys_settings = KeysSettings::as_ref(app);
let is_compact_mode =
matches!(terminal_settings.spacing_mode.value(), SpacingMode::Compact);
if is_compact_mode {
context.set.insert(flags::COMPACT_MODE_CONTEXT_FLAG);
}
let respect_system_theme = respect_system_theme(theme_settings);
if matches!(respect_system_theme, RespectSystemTheme::On(_)) {
context.set.insert(flags::RESPECT_SYSTEM_THEME_CONTEXT_FLAG);
}
if SelectionSettings::as_ref(app).copy_on_select_enabled() {
context.set.insert(flags::COPY_ON_SELECT_CONTEXT_FLAG);
}
if SelectionSettings::as_ref(app).linux_selection_clipboard_enabled() {
context.set.insert(flags::LINUX_SELECTION_CLIPBOARD_FLAG);
}
if *editor_settings.autocomplete_symbols {
context.set.insert(flags::AUTOCOMPLETE_SYMBOLS_CONTEXT_FLAG);
}
if *general_settings.restore_session {
context.set.insert(flags::RESTORE_SESSION_CONTEXT_FLAG);
}
if *session_settings.honor_ps1 {
context.set.insert(flags::HONOR_PS1_CONTEXT_FLAG);
}
if session_settings
.saved_prompt
.value()
.same_line_prompt_enabled()
{
context.set.insert(flags::WARP_SAME_LINE_PROMPT_FLAG);
}
if *ssh_settings.enable_legacy_ssh_wrapper.value() {
#[allow(deprecated)]
context.set.insert(flags::LEGACY_SSH_WRAPPER_CONTEXT_FLAG);
}
if *warpify_settings.use_ssh_tmux_wrapper.value() {
context.set.insert(flags::SSH_TMUX_WRAPPER_CONTEXT_FLAG);
}
if keys_settings.extra_meta_keys.left_alt {
context.set.insert(flags::EXTRA_META_KEYS_LEFT_CONTEXT_FLAG);
}
if keys_settings.extra_meta_keys.right_alt {
context
.set
.insert(flags::EXTRA_META_KEYS_RIGHT_CONTEXT_FLAG);
}
if *reporting_setings.scroll_reporting_enabled.value() {
context.set.insert(flags::SCROLL_REPORTING_CONTEXT_FLAG);
}
if *reporting_setings.focus_reporting_enabled.value() {
context.set.insert(flags::FOCUS_REPORTING_CONTEXT_FLAG);
}
if *KeysSettings::as_ref(app).quake_mode_enabled {
context.set.insert(flags::QUAKE_MODE_ENABLED_CONTEXT_FLAG);
}
if matches!(
SessionSettings::as_ref(app).notifications.mode,
NotificationsMode::Enabled
) {
context.set.insert(flags::NOTIFICATIONS_CONTEXT_FLAG);
}
if *general_settings.link_tooltip {
context.set.insert(flags::LINK_TOOLTIP_CONTEXT_FLAG);
}
if *input_settings.completions_open_while_typing.value() {
context
.set
.insert(flags::COMPLETIONS_OPEN_WHILE_TYPING_CONTEXT_FLAG);
}
if *input_settings.command_corrections.value() {
context.set.insert(flags::COMMAND_CORRECTIONS_CONTEXT_FLAG);
}
if *input_settings.error_underlining.value() {
context.set.insert(flags::ERROR_UNDERLINING_FLAG);
}
if *input_settings.syntax_highlighting.value() {
context.set.insert(flags::SYNTAX_HIGHLIGHTING_FLAG);
}
if privacy_settings.is_telemetry_enabled {
context.set.insert(flags::TELEMETRY_FLAG);
}
let cloud_preferences_settings = CloudPreferencesSettings::as_ref(app);
if *cloud_preferences_settings.settings_sync_enabled.value() {
context.set.insert(flags::SETTINGS_SYNC_FLAG);
}
if *block_list_settings
.show_jump_to_bottom_of_block_button
.value()
{
context
.set
.insert(flags::JUMP_TO_BOTTOM_OF_BLOCK_BUTTON_CONTEXT_FLAG);
}
if *block_list_settings.show_block_dividers.value() {
context.set.insert(flags::BLOCK_DIVIDERS_CONTEXT_FLAG);
}
if *safe_mode_settings.safe_mode_enabled.value() {
context.set.insert(flags::SAFE_MODE_FLAG);
}
if privacy_settings.is_crash_reporting_enabled {
context.set.insert(flags::CRASH_REPORTING_FLAG);
}
if editor_settings.cursor_blink.value() == &CursorBlink::Enabled {
context.set.insert(flags::CURSOR_BLINK_CONTEXT_FLAG);
}
if *editor_settings.vim_mode.value() {
context.set.insert(flags::VIM_MODE_CONTEXT_FLAG);
if *editor_settings.vim_unnamed_system_clipboard.value() {
context.set.insert(flags::VIM_UNNAMED_SYSTEM_CLIPBOARD);
}
if *editor_settings.vim_status_bar.value() {
context.set.insert(flags::VIM_SHOW_STATUS_BAR);
}
}
if *pane_settings.should_dim_inactive_panes {
context.set.insert(flags::DIM_INACTIVE_PANES_FLAG);
}
if *pane_settings.focus_panes_on_hover {
context.set.insert(flags::FOCUS_PANES_ON_HOVER_CONTEXT_FLAG);
}
if *general_settings.show_warning_before_quitting.value() {
context.set.insert(flags::QUIT_WARNING_MODAL);
}
if semantic_selection_settings.smart_select_enabled() {
context.set.insert(flags::SMART_SELECT_FLAG);
}
if *KeysSettings::as_ref(app).activation_hotkey_enabled.value() {
context.set.insert(flags::ACTIVATION_HOTKEY_FLAG);
}
if *tab_settings.show_indicators.value() {
context.set.insert(flags::TAB_INDICATORS_FLAG);
}
if *tab_settings.show_code_review_button.value() {
context.set.insert(flags::SHOW_CODE_REVIEW_BUTTON_FLAG);
}
if *tab_settings.use_vertical_tabs.value() {
context.set.insert(flags::USE_VERTICAL_TABS_FLAG);
}
if self.should_show_session_config_tab_config_chip() {
context
.set
.insert(flags::SESSION_CONFIG_TAB_CONFIG_CHIP_OPEN);
}
if tab_settings
.workspace_decoration_visibility
.value()
.hides_decorations_by_default()
{
context
.set
.insert(flags::HIDE_WORKSPACE_DECORATIONS_CONTEXT_FLAG);
}
if *alias_expansion_settings.alias_expansion_enabled.value() {
context.set.insert(flags::ALIAS_EXPANSION_FLAG);
}
if *selection_settings.middle_click_paste_enabled.value() {
context.set.insert(flags::MIDDLE_CLICK_PASTE_FLAG);
}
if *code_settings.code_as_default_editor.value() {
context.set.insert(flags::CODE_AS_DEFAULT_EDITOR);
}
if *code_settings.codebase_context_enabled.value() {
context.set.insert(flags::IS_CODEBASE_INDEXING_ENABLED);
}
if *code_settings.auto_indexing_enabled.value() {
context.set.insert(flags::IS_AUTOINDEXING_ENABLED);
}
if *input_settings.show_hint_text.value() {
context.set.insert(flags::SHOW_INPUT_HINT_TEXT_CONTEXT_FLAG);
}
if *input_settings.show_agent_tips.value() {
context.set.insert(flags::SHOW_AGENT_TIPS_FLAG);
}
if *editor_settings.enable_autosuggestions {
context.set.insert(flags::AUTOSUGGESTIONS_ENABLED_FLAG);
}
if *editor_settings.autosuggestion_keybinding_hint.value() {
context
.set
.insert(flags::AUTOSUGGESTION_KEYBINDING_HINT_FLAG);
}
#[cfg(target_os = "linux")]
{
let force_x11 = *crate::settings::LinuxAppConfiguration::as_ref(app)
.force_x11
.value();
if !force_x11 {
context.set.insert(flags::ALLOW_NATIVE_WAYLAND);
}
}
let terminal_settings = TerminalSettings::as_ref(app);
if *terminal_settings.use_audible_bell {
context.set.insert(flags::USE_AUDIBLE_BELL_CONTEXT_FLAG);
}
let gpu_settings = GPUSettings::as_ref(app);
if *gpu_settings.prefer_low_power_gpu {
context.set.insert(flags::PREFER_LOW_POWER_GPU_FLAG);
}
let ai_settings = AISettings::as_ref(app);
if ai_settings.is_ai_autodetection_enabled(app) {
context.set.insert(flags::AI_INPUT_AUTODETECTION_FLAG);
}
if ai_settings.is_nld_in_terminal_enabled(app) {
context.set.insert(flags::NLD_IN_TERMINAL_FLAG);
}
if ai_settings.is_intelligent_autosuggestions_enabled(app) {
context.set.insert(flags::INTELLIGENT_AUTOSUGGESTIONS_FLAG);
}
if ai_settings.is_prompt_suggestions_enabled(app) {
context.set.insert(flags::PROMPT_SUGGESTIONS_FLAG);
}
if ai_settings.is_code_suggestions_enabled(app) {
context.set.insert(flags::CODE_SUGGESTIONS_FLAG);
}
if ai_settings.is_natural_language_autosuggestions_enabled(app) {
context
.set
.insert(flags::NATURAL_LANGUAGE_AUTOSUGGESTIONS_FLAG);
}
if ai_settings.is_shared_block_title_generation_enabled(app) {
context
.set
.insert(flags::SHARED_BLOCK_TITLE_GENERATION_FLAG);
}
if *ai_settings
.should_render_use_agent_footer_for_user_commands
.value()
{
context.set.insert(flags::USE_AGENT_FOOTER_FLAG);
}
match ai_settings.thinking_display_mode {
crate::settings::ThinkingDisplayMode::ShowAndCollapse => {
context
.set
.insert(flags::THINKING_DISPLAY_SHOW_AND_COLLAPSE);
}
crate::settings::ThinkingDisplayMode::AlwaysShow => {
context.set.insert(flags::THINKING_DISPLAY_ALWAYS_SHOW);
}
crate::settings::ThinkingDisplayMode::NeverShow => {
context.set.insert(flags::THINKING_DISPLAY_NEVER_SHOW);
}
}
if input_settings.is_terminal_input_message_bar_enabled() {
context
.set
.insert(flags::SHOW_TERMINAL_INPUT_MESSAGE_LINE_FLAG);
}
if *input_settings.enable_slash_commands_in_terminal.value() {
context.set.insert(flags::SLASH_COMMANDS_IN_TERMINAL_FLAG);
}
if ChannelState::enable_debug_features() {
let block_visibility_settings = BlockVisibilitySettings::as_ref(app);
if *block_visibility_settings
.should_show_bootstrap_block
.value()
{
context.set.insert(flags::INITIALIZATION_BLOCK_FLAG);
}
if *block_visibility_settings
.should_show_in_band_command_blocks
.value()
{
context.set.insert(flags::IN_BAND_COMMAND_BLOCKS_FLAG);
}
}
if should_use_ligature_rendering(app) {
context.set.insert(flags::LIGATURE_RENDERING_CONTEXT_FLAG);
}
}
/// Send SyncEvent to all synced pane groups.
fn process_sync_event_for_all_synced_pane_groups(
&mut self,
event: &SyncEvent,
ctx: &mut ViewContext<Self>,
) {
for tab in self.tab_views() {
// We have to get the latest SyncInputStatus each iteration because
// tab.update below could potentially change it.
let synced_pane_group_ids = SyncedInputState::as_ref(ctx);
if synced_pane_group_ids.should_sync_this_pane_group(tab.id(), ctx.window_id()) {
tab.update(ctx, |pane_group, ctx| {
pane_group.send_sync_event_to_panes(event, ctx);
});
}
}
self.update_pane_dimming_for_current_focus_region(ctx);
}
/// Sends SyncEvent to all synced terminal views.
/// The purpose of the event could be match the active terminal input,
/// expand the terminal input box, or collapse the terminal input box.
fn process_updated_sync_state(&self, ctx: &mut ViewContext<Self>) {
// If there is an active terminal, return a sync event that all
// other synced terminals should apply to match it.
// If there is no active terminal (like when all Warp windows are
// minimized), return an event to start syncing.
let sync_event = self
.active_tab_pane_group()
.as_ref(ctx)
.active_session_view(ctx)
.map_or(
SyncEvent {
source_view_id: ctx.view_id(),
data: SyncInputType::StartSyncing,
},
|terminal_view_handle| {
terminal_view_handle
.as_ref(ctx)
.create_sync_event_based_on_terminal_state(ctx)
},
);
let stop_syncing_event = SyncEvent {
source_view_id: ctx.view_id(),
data: terminal::view::SyncInputType::StopSyncing,
};
for tab in self.tab_views() {
// We have to get the latest SyncInputStatus each iteration because
// tab.update below could potentially change it.
let synced_pane_group_ids = SyncedInputState::as_ref(ctx);
if synced_pane_group_ids.should_sync_this_pane_group(tab.id(), ctx.window_id()) {
tab.update(ctx, |pane_group, pane_group_ctx| {
pane_group.send_sync_event_to_panes(&sync_event, pane_group_ctx);
});
} else {
// Note: we're sending StopSyncing to tabs that could already
// know they're not syncing. We can optimize this later.
tab.update(ctx, |pane_group, pane_group_ctx| {
pane_group.send_sync_event_to_panes(&stop_syncing_event, pane_group_ctx);
});
}
}
// Update tab indicators based on the new sync state.
ctx.notify();
}
fn all_pane_group_ids(&self) -> impl Iterator<Item = EntityId> + '_ {
self.tab_views().map(|tab| tab.id())
}
fn team_uid(&self, app: &AppContext) -> Option<ServerId> {
// TODO this is a stop gap for now - ideally a specific team uid should
// be passed into each event
UserWorkspaces::as_ref(app).current_team_uid()
}
fn initiate_user_signup(
&mut self,
entrypoint: AnonymousUserSignupEntrypoint,
ctx: &mut ViewContext<Self>,
) {
if self.auth_state.is_user_anonymous().unwrap_or_default() {
// User has a Firebase anonymous account — use the linking flow.
AuthManager::handle(ctx).update(ctx, |auth_manager, ctx| {
auth_manager.initiate_anonymous_user_linking(entrypoint, ctx);
});
} else {
// User is fully logged out (no Firebase user) — open the regular sign-up page.
AuthManager::handle(ctx).update(ctx, |auth_manager, ctx| {
let sign_up_url = auth_manager.sign_up_url();
ctx.open_url(&sign_up_url);
});
}
self.require_login_modal.update(ctx, |auth_modal, ctx| {
auth_modal.skip_to_browser_open_step(ctx);
});
self.open_require_login_modal(AuthViewVariant::RequireLoginCloseable, ctx);
}
fn redirect_to_sign_in(&mut self) {
#[cfg(target_family = "wasm")]
if let Some(current_url) = parse_current_url() {
update_browser_url(
Url::parse(&format!(
"{}/login?redirect_to={}",
ChannelState::server_root_url(),
current_url.path()
))
.ok(),
true,
);
} else {
update_browser_url(
Url::parse(&format!("{}/login", ChannelState::server_root_url())).ok(),
true,
);
}
}
/// Triggers the necessary cleanup for when a user logs out.
pub fn on_log_out(&mut self, ctx: &mut ViewContext<Self>) {
// Logging out should mimic the same behaviour as closing a window.
// This gives views a chance to clean up any state through on_view_detached before being dropped.
self.on_window_closed(ctx);
}
fn focus_openwarp_launch_modal(&mut self, ctx: &mut ViewContext<Self>) {
ctx.focus(&self.openwarp_launch_modal);
}
fn open_tab_and_focus_oz_launch_modal(&mut self, ctx: &mut ViewContext<Self>) {
// Create a new tab with one terminal session titled "Introducing Oz"
self.add_tab_with_pane_layout(
PanesLayout::SingleTerminal(Box::new(NewTerminalOptions {
shell: None,
initial_directory: None,
hide_homepage: false,
..Default::default()
})),
Arc::new(HashMap::new()),
Some("Introducing Oz".to_string()),
ctx,
);
self.oz_launch_modal.tab_pane_group_id = self
.tabs
.get(self.active_tab_index)
.map(|tab| tab.pane_group.id());
ctx.focus(&self.oz_launch_modal.view);
}
fn focus_build_plan_migration_modal(&mut self, ctx: &mut ViewContext<Self>) {
ctx.focus(&self.build_plan_migration_modal);
}
fn open_left_panel_view(&mut self, action: &LeftPanelAction, ctx: &mut ViewContext<Self>) {
if !self.active_tab_pane_group().as_ref(ctx).left_panel_open {
self.toggle_left_panel(ctx);
}
if self.active_tab_pane_group().as_ref(ctx).left_panel_open {
self.left_panel_view.update(ctx, |left_panel, ctx| {
left_panel.handle_action_with_force_open(action, false, ctx);
left_panel.focus_active_view_on_entry(ctx);
});
}
}
fn toggle_left_panel_view(
&mut self,
action: &LeftPanelAction,
is_showing_target_view: bool,
ctx: &mut ViewContext<Self>,
) {
let is_left_panel_open = self.active_tab_pane_group().as_ref(ctx).left_panel_open;
if is_left_panel_open && is_showing_target_view {
// If we're showing the target view for this action,
// toggle the left panel closed.
self.toggle_left_panel(ctx);
} else {
self.open_left_panel_view(action, ctx);
}
}
/// Computes the list of available left panel views based on current AI settings and feature flags.
fn compute_left_panel_views(ctx: &AppContext) -> Vec<ToolPanelView> {
let mut views = vec![];
if FeatureFlag::AgentViewConversationListView.is_enabled()
&& AISettings::as_ref(ctx).is_any_ai_enabled(ctx)
&& *AISettings::as_ref(ctx).show_conversation_history
{
views.push(ToolPanelView::ConversationListView);
}
if cfg!(feature = "local_fs") && *CodeSettings::as_ref(ctx).show_project_explorer.value() {
views.push(ToolPanelView::ProjectExplorer);
}
if cfg!(feature = "local_fs")
&& FeatureFlag::GlobalSearch.is_enabled()
&& *CodeSettings::as_ref(ctx).show_global_search.value()
{
views.push(ToolPanelView::GlobalSearch {
entry_focus: GlobalSearchEntryFocus::Results,
});
}
if WarpDriveSettings::is_warp_drive_enabled(ctx) {
views.push(ToolPanelView::WarpDrive);
}
views
}
/// Recomputes the available left panel views based on current AI settings and feature flags,
/// then updates both the workspace's left_panel_views and the LeftPanelView's toolbelt buttons.
fn update_left_panel_available_views(&mut self, ctx: &mut ViewContext<Self>) {
let views = Self::compute_left_panel_views(ctx);
self.left_panel_views = views.clone();
self.left_panel_view.update(ctx, |left_panel, ctx| {
left_panel.update_available_views(views, ctx);
});
}
/// Opens a given URL in the desktop Warp app if installed, or redirects to download page.
#[cfg(target_family = "wasm")]
fn open_link_on_desktop(&mut self, url: &Url, ctx: &mut ViewContext<Self>) {
use crate::settings::app_installation_detection::{
UserAppInstallDetectionSettings, UserAppInstallStatus,
};
// Check if the desktop app is installed
let is_app_installed = *UserAppInstallDetectionSettings::as_ref(ctx)
.user_app_installation_detected
.value()
== UserAppInstallStatus::Detected;
if !is_app_installed {
// App not installed - redirect to download page
ctx.open_url("https://warp.dev/download");
// In webapp code we cannot distinguish between
// the localhost:9277/install_detection endpoint not running (not installed) vs
// the browser blocking Local Network Access which results in CORS error;
// the browser intentionally obscures the error root cause for privacy reasons.
// Many users' browser settings will block Local Network Access so this will end up redirecting to download page,
// even if they have the app installed.
let toast_message = format!(
"Have Galaxy installed but redirecting to download page?\nEnable Local Network Access for {} in your browser.",
ChannelState::server_root_url()
);
self.toast_stack.update(ctx, |toast_stack, ctx| {
toast_stack.add_persistent_toast(DismissibleToast::default(toast_message), ctx)
});
// Still try to open the url on desktop below
}
// Open the URL on desktop. This does nothing if the app isn't installed.
crate::uri::web_intent_parser::open_url_on_desktop(url);
}
}
impl Entity for Workspace {
type Event = ();
}
impl TypedActionView for Workspace {
type Action = WorkspaceAction;
fn action_accessibility_contents(
&mut self,
action: &WorkspaceAction,
_: &mut ViewContext<Self>,
) -> ActionAccessibilityContent {
match action {
WorkspaceAction::SetA11yVerbosityLevel(verbosity) => {
ActionAccessibilityContent::Custom(AccessibilityContent::new_without_help(
format!("{verbosity:?} accessibility announcements set"),
WarpA11yRole::UserAction,
))
}
_ => ActionAccessibilityContent::from_debug(),
}
}
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
use WorkspaceAction::*;
let window_id = ctx.window_id();
if self.auth_state.is_anonymous_or_logged_out() && action.blocked_for_anonymous_user() {
AuthManager::handle(ctx).update(ctx, |auth_manager, ctx| {
auth_manager.attempt_login_gated_feature(
action.into(),
AuthViewVariant::RequireLoginCloseable,
ctx,
)
});
return;
}
match action {
ActivateTab(index) => self.activate_tab(*index, ctx),
ActivateTabByNumber(num) => self.activate_tab(num.saturating_sub(1), ctx),
ActivatePrevTab => self.activate_prev_tab(ctx),
OpenLaunchConfigSaveModal => self.open_launch_config_save_modal(ctx),
ActivateNextTab => self.activate_next_tab(ctx),
ActivateLastTab => self.activate_last_tab(ctx),
CyclePrevSession => self.cycle_prev_session(ctx),
CycleNextSession => self.cycle_next_session(ctx),
MoveActiveTabLeft => self.move_tab(self.active_tab_index, TabMovement::Left, ctx),
MoveActiveTabRight => self.move_tab(self.active_tab_index, TabMovement::Right, ctx),
MoveTabLeft(index) => self.move_tab(*index, TabMovement::Left, ctx),
MoveTabRight(index) => self.move_tab(*index, TabMovement::Right, ctx),
RenameTab(index) => self.rename_tab(*index, ctx),
ResetTabName(index) => self.clear_tab_name(*index, ctx),
RenamePane(locator) => self.rename_pane(*locator, ctx),
ResetPaneName(locator) => self.clear_pane_name(*locator, ctx),
RenameActiveTab => self.rename_tab(self.active_tab_index, ctx),
SetActiveTabName(name) => self.set_active_tab_name(name, ctx),
ToggleTabRightClickMenu { tab_index, anchor } => {
self.toggle_tab_right_click_menu(*tab_index, *anchor, ctx)
}
ToggleVerticalTabsPaneContextMenu {
tab_index,
target,
position,
} => self.toggle_vertical_tabs_pane_context_menu(*tab_index, *target, *position, ctx),
ToggleTabBarOverflowMenu => self.toggle_tab_bar_overflow_menu(ctx),
ToggleBlockSnackbar => self.toggle_block_snackbar(ctx),
ToggleWelcomeTips => self.toggle_welcome_tips_visiblity(ctx),
CloseTab(index) => self.close_tab(*index, false, true, ctx),
CloseActiveTab => self.close_tab(self.active_tab_index, false, true, ctx),
CloseOtherTabs(index) => self.close_other_tabs(*index, false, ctx),
CloseNonActiveTabs => self.close_other_tabs(self.active_tab_index, false, ctx),
CloseTabsRight(index) => {
self.close_tabs_direction(*index, TabMovement::Right, false, ctx)
}
CloseTabsRightActiveTab => {
self.close_tabs_direction(self.active_tab_index, TabMovement::Right, false, ctx)
}
AddDefaultTab => {
let effective_mode = AISettings::as_ref(ctx).default_session_mode(ctx);
match effective_mode {
DefaultSessionMode::TabConfig => {
let ai_settings = AISettings::as_ref(ctx);
if let Some(config) = ai_settings.resolved_default_tab_config(ctx) {
self.open_tab_config(config, ctx);
} else {
// Config missing or deleted — clear and fall through to Terminal.
AISettings::handle(ctx).update(ctx, |settings, ctx| {
report_if_error!(settings
.default_session_mode_internal
.set_value(DefaultSessionMode::Terminal, ctx));
report_if_error!(settings
.default_tab_config_path
.set_value(String::new(), ctx));
});
self.add_terminal_tab(false, ctx);
}
}
DefaultSessionMode::CloudAgent => {
self.add_ambient_agent_tab(ctx);
}
DefaultSessionMode::DockerSandbox => {
self.add_docker_sandbox_tab(ctx);
}
// Terminal and Agent are handled by the existing path
// (add_terminal_tab applies DefaultSessionMode::Agent internally).
DefaultSessionMode::Terminal | DefaultSessionMode::Agent => {
if FeatureFlag::WelcomeTab.is_enabled() {
self.add_welcome_tab(ctx);
} else {
self.add_terminal_tab(false, ctx);
}
}
}
}
AddTerminalTab { hide_homepage } => {
self.add_new_session_tab_internal_with_default_session_mode_behavior(
NewSessionSource::Tab,
Some(window_id),
None,
None,
*hide_homepage,
DefaultSessionModeBehavior::Ignore,
ctx,
);
ctx.notify();
}
AddTabWithShell { shell, source } => {
self.add_tab_with_shell(shell.clone(), *source, ctx)
}
AddGetStartedTab => self.add_get_started_tab(ctx),
AddAmbientAgentTab => self.add_ambient_agent_tab(ctx),
AddAgentTab => self.add_terminal_tab_with_new_agent_view(ctx),
AddDockerSandboxTab => self.add_docker_sandbox_tab(ctx),
StartAgentOnboardingTutorial(tutorial) => {
self.start_agent_onboarding_tutorial(tutorial.clone(), ctx)
}
OpenNewSessionMenu { position } => self.open_new_session_dropdown_menu(*position, ctx),
ToggleTabConfigsMenu => self.toggle_tab_configs_menu(ctx),
ShowSessionConfigModal => self.show_session_config_modal(ctx),
DismissSessionConfigTabConfigChip => {
self.dismiss_session_config_tab_config_chip(ctx);
}
#[cfg(debug_assertions)]
ShowHoaOnboardingFlow => self.show_hoa_onboarding_flow(ctx),
SaveCurrentTabAsNewConfig(tab_index) => {
self.save_current_tab_as_new_config(*tab_index, ctx)
}
ToggleNewSessionMenu {
position,
is_vertical_tabs,
} => self.toggle_new_session_dropdown_menu(*position, *is_vertical_tabs, ctx),
SelectNewSessionMenuItem(new_session_menu_item) => {
self.open_launch_config_from_menu(new_session_menu_item.clone(), ctx)
}
SelectTabConfig(tab_config) => {
self.open_tab_config(tab_config.clone(), ctx);
}
OpenNewWorktreeModal => {
let cwd = self
.active_session_view(ctx)
.and_then(|view| view.as_ref(ctx).pwd())
.map(PathBuf::from);
self.new_worktree_modal.view.update(ctx, |modal, ctx| {
modal.body().update(ctx, |body, ctx| {
body.on_open(cwd, ctx);
});
});
self.new_worktree_modal.open();
self.current_workspace_state.is_new_worktree_modal_open = true;
ctx.notify();
}
OpenNewWorktreeRepoPicker => {
self.open_repo_picker_for_new_worktree_modal(ctx);
}
OpenTabConfigErrorFile {
#[cfg_attr(not(feature = "local_fs"), allow(unused_variables))]
path,
toast_object_id,
} => {
#[cfg(feature = "local_fs")]
{
let settings = EditorSettings::as_ref(ctx);
let target = resolve_file_target_with_editor_choice(
path,
*settings.open_code_panels_file_editor,
*settings.prefer_markdown_viewer,
*settings.open_file_layout,
None,
);
self.open_file_with_target(
path.clone(),
target,
None,
CodeSource::Link {
path: path.clone(),
range_start: None,
range_end: None,
},
ctx,
);
}
self.dismiss_older_toasts(toast_object_id, ctx);
}
TabConfigSidecarMakeDefault {
mode,
tab_config_path,
#[cfg_attr(not(feature = "local_tty"), allow(unused_variables))]
shell,
} => {
AISettings::handle(ctx).update(ctx, |settings, ctx| {
report_if_error!(settings.default_session_mode_internal.set_value(*mode, ctx));
if let Some(path) = tab_config_path {
report_if_error!(settings
.default_tab_config_path
.set_value(path.to_string_lossy().into_owned(), ctx));
}
});
#[cfg(feature = "local_tty")]
if let Some(shell) = shell {
use crate::terminal::available_shells::AvailableShells;
AvailableShells::handle(ctx).update(ctx, |model, ctx| {
let _ = model.set_user_preferred_shell(shell.clone(), ctx);
});
}
self.close_new_session_dropdown_menu(ctx);
}
TabConfigSidecarEditConfig {
#[cfg_attr(not(feature = "local_fs"), allow(unused_variables))]
path,
} => {
#[cfg(feature = "local_fs")]
{
let settings = EditorSettings::as_ref(ctx);
let target = resolve_file_target_with_editor_choice(
path,
*settings.open_code_panels_file_editor,
*settings.prefer_markdown_viewer,
*settings.open_file_layout,
None,
);
self.open_file_with_target(
path.clone(),
target,
None,
CodeSource::Link {
path: path.clone(),
range_start: None,
range_end: None,
},
ctx,
);
}
self.close_new_session_dropdown_menu(ctx);
}
TabConfigSidecarRemoveConfig { name, path } => {
self.remove_tab_config_confirmation_dialog
.update(ctx, |dialog, ctx| {
dialog.set_config(name.clone(), path.clone());
ctx.notify();
});
self.close_new_session_dropdown_menu(ctx);
self.current_workspace_state
.is_remove_tab_config_dialog_open = true;
ctx.focus(&self.remove_tab_config_confirmation_dialog);
ctx.notify();
}
OpenSettingsFile => {
let path = crate::settings::user_preferences_toml_file_path();
self.add_tab_for_code_file(path, None, ctx);
}
OpenNetworkLogPane => {
self.open_network_log_pane(ctx);
}
FixSettingsWithOz { error_description } => {
use crate::ai::skills::SkillManager;
let modify_settings_skill = SkillManager::as_ref(ctx)
.active_bundled_skill("modify-settings", ctx)
.cloned();
let query = format!(
"My settings.toml file has an error: {error_description}. Please fix it."
);
self.active_tab_pane_group().update(ctx, |pane_group, ctx| {
pane_group.add_terminal_pane_in_agent_mode(None, None, ctx);
if let Some(terminal_view) = pane_group.focused_session_view(ctx) {
terminal_view.update(ctx, |terminal_view, terminal_view_ctx| {
// The modify-settings skill should always be available for
// production builds.
if let Some(skill) = modify_settings_skill {
terminal_view.ai_controller().update(
terminal_view_ctx,
|controller, ctx| {
controller.send_slash_command_request(
SlashCommandRequest::InvokeSkill {
skill,
user_query: Some(query),
},
ctx,
);
},
);
} else if let Some(conversation_id) =
terminal_view.active_conversation_id(terminal_view_ctx)
{
terminal_view.ai_controller().update(
terminal_view_ctx,
|controller, ctx| {
controller.send_user_query_in_conversation(
query,
conversation_id,
None,
ctx,
);
},
);
}
});
}
});
}
OpenWorktreeInRepo { repo_path } => {
self.open_worktree_in_repo(repo_path.clone(), ctx);
}
OpenWorktreeAddRepoPicker => {
self.close_new_session_dropdown_menu(ctx);
self.open_folder_picker_for_worktree_submenu(ctx);
}
AutoupdateFailureLink => self.open_autoupdate_failure_link(ctx),
ApplyUpdate => self.apply_update(ctx),
LogOut => {
// Need to dispatch global action, or else we will not be able to retrieve
// the currently active session in the log out modal.
ctx.dispatch_global_action("app:maybe_log_out", ());
}
ExportAllWarpDriveObjects => {
self.export_all_warp_drive_objects(ctx);
}
CopyVersion(version) => self.copy_version(version, ctx),
DownloadNewVersion => self.download_new_version(ctx),
ConfigureKeybindingSettings { keybinding_name } => {
self.show_keyboard_settings(keybinding_name.as_deref(), ctx)
}
ShowSettings => self.show_settings(ctx),
ShowSettingsPage(section) => self.show_settings_with_section(Some(*section), ctx),
ShowSettingsPageWithSearch {
search_query,
section,
} => self.show_settings_with_search(search_query, *section, ctx),
ShowThemeChooser(mode) => self.show_theme_chooser(Some(*mode), ctx),
ShowThemeChooserForActiveTheme => self.show_theme_chooser_for_active_theme(ctx),
IncreaseFontSize => self.increase_font_size(ctx),
DecreaseFontSize => self.decrease_font_size(ctx),
ResetFontSize => self.reset_font_size(ctx),
IncreaseZoom => self.increase_zoom(ctx),
DecreaseZoom => self.decrease_zoom(ctx),
ResetZoom => self.reset_zoom(ctx),
OpenPalette {
mode,
source,
query,
} => self.open_palette_action(*mode, *source, query.as_deref(), ctx),
TogglePalette {
mode: palette_mode,
source,
} => self.toggle_palette(*palette_mode, *source, ctx),
ShowUpgrade => {
send_telemetry_from_ctx!(TelemetryEvent::UserMenuUpgradeClicked, ctx);
let auth_state = AuthStateProvider::as_ref(ctx).get();
let user_workspaces = UserWorkspaces::as_ref(ctx);
let upgrade_url = if let Some(team) = user_workspaces.current_team() {
UserWorkspaces::upgrade_link_for_team(team.uid)
} else {
let user_id = auth_state.user_id().unwrap_or_default();
UserWorkspaces::upgrade_link(user_id)
};
ctx.open_url(&upgrade_url);
}
ShowReferralSettingsPage => {
self.show_settings_with_section(Some(SettingsSection::Referrals), ctx);
}
JoinSlack => self.join_slack(ctx),
ViewUserDocs => self.view_user_docs(ctx),
ViewLatestChangelog => self.view_latest_changelog(ctx),
ViewPrivacyPolicy => self.view_privacy_policy(ctx),
SendFeedback => self.send_feedback(ctx),
#[cfg(not(target_family = "wasm"))]
ViewLogs => self.view_logs(ctx),
ChangeCursor(cursor) => self.change_cursor(*cursor, ctx),
ToggleErrorUnderlining => self.toggle_error_underlining(ctx),
ToggleSyntaxHighlighting => self.toggle_syntax_highlighting(ctx),
CheckForUpdate => self.manual_check_for_update(ctx),
SetA11yVerbosityLevel(verbosity) => self.set_a11y_verbosity(*verbosity, ctx),
ToggleNotifications => self.toggle_notifications(ctx),
ToggleTabColor { color, tab_index } => self.toggle_tab_color(*tab_index, *color, ctx),
DispatchToSettingsTab(action) => {
let window_id = ctx.window_id();
ctx.dispatch_typed_action_for_view(window_id, self.settings_pane.id(), action)
}
OpenLink(link) => ctx.open_url(link),
#[cfg(target_family = "wasm")]
OpenLinkOnDesktop(url) => self.open_link_on_desktop(url, ctx),
DumpDebugInfo => self.dump_debug_info(ctx),
LogReviewCommentSendStatusForActiveTab => {
self.right_panel_view.update(ctx, |right_panel_view, ctx| {
right_panel_view.log_review_comment_send_status_for_active_tab(ctx);
});
}
#[cfg(target_os = "macos")]
InstallCLI => self.install_cli(ctx),
#[cfg(target_os = "macos")]
UninstallCLI => self.uninstall_cli(ctx),
UndoRevertInCodeReviewPane { window_id, view_id } => {
self.undo_revert_in_code_review_pane(*window_id, *view_id, ctx)
}
ToggleRecordingMode => self.toggle_recording_mode(ctx),
ToggleInBandGenerators => self.toggle_in_band_generators(ctx),
ToggleDebugNetworkStatus => self.toggle_debug_network_status(ctx),
ToggleShowMemoryStats => self.toggle_show_memory_stats(ctx),
ToggleResourceCenter => self.toggle_resource_center(ctx),
ToggleUserMenu => self.toggle_user_menu(ctx),
ToggleKeybindingsPage => self.toggle_keybindings_page(ctx),
ShowCommandSearch(CommandSearchOptions {
filter,
init_content,
}) => self.show_command_search(*filter, init_content, ctx),
ImportToPersonalDrive => {
if let Some(personal_drive) = UserWorkspaces::as_ref(ctx).personal_drive(ctx) {
self.open_import_modal(personal_drive, &None, ctx);
}
}
ImportToTeamDrive => {
let team_uid = self.team_uid(ctx);
if let Some(team_uid) = team_uid {
self.open_import_modal(Owner::Team { team_uid }, &None, ctx);
}
}
CreatePersonalNotebook => {
if let Some(personal_drive) = UserWorkspaces::as_ref(ctx).personal_drive(ctx) {
self.open_notebook(
&NotebookSource::New {
title: None,
owner: personal_drive,
initial_folder_id: None,
},
&OpenWarpDriveObjectSettings::default(),
ctx,
true,
);
}
}
CreateTeamNotebook => {
let team_uid = self.team_uid(ctx);
if let Some(team_uid) = team_uid {
self.update_warp_drive_view(ctx, |drive_panel, ctx| {
drive_panel.open_cloud_object_dialog(
DriveObjectType::Notebook {
is_ai_document: false,
},
Space::Team { team_uid },
None,
ctx,
);
});
self.current_workspace_state.is_warp_drive_open = true;
ctx.notify();
}
}
CreatePersonalEnvVarCollection => {
if let Some(personal_drive) = UserWorkspaces::as_ref(ctx).personal_drive(ctx) {
self.open_env_var_collection(
&EnvVarCollectionSource::New {
title: None,
owner: personal_drive,
initial_folder_id: None,
},
false,
ctx,
);
}
}
CreateTeamEnvVarCollection => {
let team_uid = self.team_uid(ctx);
if let Some(team_uid) = team_uid {
self.update_warp_drive_view(ctx, |drive_panel, ctx| {
drive_panel.open_cloud_object_dialog(
DriveObjectType::EnvVarCollection,
Space::Team { team_uid },
None,
ctx,
);
});
self.current_workspace_state.is_warp_drive_open = true;
ctx.notify();
}
}
CreatePersonalWorkflow => {
if let Some(personal_drive) = UserWorkspaces::as_ref(ctx).personal_drive(ctx) {
let source = WorkflowOpenSource::New {
title: None,
content: None,
owner: personal_drive,
initial_folder_id: None,
is_for_agent_mode: false,
};
self.open_workflow_in_pane(
&source,
&OpenWarpDriveObjectSettings::default(),
WorkflowViewMode::Create,
ctx,
);
}
}
CreateTeamWorkflow => {
let team_uid = self.team_uid(ctx);
if let Some(team_uid) = team_uid {
let source = WorkflowOpenSource::New {
title: None,
content: None,
owner: Owner::Team { team_uid },
initial_folder_id: None,
is_for_agent_mode: false,
};
self.open_workflow_in_pane(
&source,
&OpenWarpDriveObjectSettings::default(),
WorkflowViewMode::Create,
ctx,
);
}
}
CreatePersonalFolder => {
self.update_warp_drive_view(ctx, |drive_panel, ctx| {
drive_panel.open_cloud_object_dialog(
DriveObjectType::Folder,
Space::Personal,
None,
ctx,
);
});
self.current_workspace_state.is_warp_drive_open = true;
ctx.notify();
}
CreateTeamFolder => {
let team_uid = self.team_uid(ctx);
if let Some(team_uid) = team_uid {
self.update_warp_drive_view(ctx, |drive_panel, ctx| {
drive_panel.open_cloud_object_dialog(
DriveObjectType::Folder,
Space::Team { team_uid },
None,
ctx,
);
});
self.current_workspace_state.is_warp_drive_open = true;
ctx.notify();
}
}
ToggleMouseReporting => self.toggle_mouse_reporting(ctx),
ToggleScrollReporting => self.toggle_scroll_reporting(ctx),
ToggleFocusReporting => self.toggle_focus_reporting(ctx),
StartTabDrag => {
// If we are renaming a tab, finish the rename before dragging.
self.finish_tab_rename(ctx);
self.current_workspace_state.is_tab_being_dragged = true;
}
OpenWarpDrive => {
if WarpDriveSettings::is_warp_drive_enabled(ctx) {
self.open_left_panel_view(&LeftPanelAction::WarpDrive, ctx);
}
}
ToggleLeftPanel => {
let active_pane_group = self.active_tab_pane_group().clone();
let was_open = active_pane_group.read(ctx, |pg, _| pg.left_panel_open);
// Don't open the panel if no views are available.
if !was_open && self.left_panel_views.is_empty() {
return;
}
let file_tree_active = self
.left_panel_view
.read(ctx, |lp, _| lp.is_file_tree_active());
let warp_drive_active = self
.left_panel_view
.read(ctx, |lp, _| lp.is_warp_drive_active());
self.toggle_left_panel(ctx);
let is_open = active_pane_group.read(ctx, |pg, _| pg.left_panel_open);
if !was_open && is_open {
self.left_panel_view.update(ctx, |left_panel, ctx| {
left_panel.focus_active_view_on_entry(ctx);
});
if file_tree_active {
send_telemetry_from_ctx!(
TelemetryEvent::FileTreeToggled {
source: FileTreeSource::LeftPanelToolbelt,
is_code_mode_v2: true,
cli_agent: None,
},
ctx
);
} else if warp_drive_active {
// Tools panel opened with Warp Drive as the active view
send_telemetry_from_ctx!(
TelemetryEvent::WarpDriveOpened {
source: WarpDriveSource::LeftPanelToolbelt,
is_code_mode_v2: true
},
ctx
);
}
}
}
ToggleRightPanel => {
let pane_group_handle = self.active_tab_pane_group().clone();
self.toggle_right_panel(&pane_group_handle, ctx);
}
#[cfg(feature = "local_fs")]
OpenCodeReviewPanel(locator) => {
let pane_group_handle = self
.tabs
.iter()
.find(|tab| tab.pane_group.id() == locator.pane_group_id)
.map(|tab| tab.pane_group.clone());
if let Some(pane_group_handle) = pane_group_handle {
let read_result = pane_group_handle.read(ctx, |pane_group, ctx| {
pane_group
.terminal_view_from_pane_id(locator.pane_id, ctx)
.map(|terminal_view| {
let repo_path =
terminal_view.as_ref(ctx).current_repo_path().cloned();
(repo_path, terminal_view.downgrade())
})
});
if let Some((repo_path, terminal_view)) = read_result {
let diff_state_model = repo_path.as_ref().and_then(|rp| {
self.working_directories_model.update(ctx, |model, ctx| {
model.get_or_create_diff_state_model(rp.clone(), ctx)
})
});
if let Some(diff_state_model) = diff_state_model {
let context = CodeReviewPaneContext {
repo_path,
diff_state_model,
terminal_view,
};
self.open_right_panel(
&context,
&pane_group_handle,
CodeReviewPaneEntrypoint::GitDiffChip,
None,
ctx,
);
}
}
}
}
#[cfg(not(feature = "local_fs"))]
OpenCodeReviewPanel(_) => {}
ToggleVerticalTabsPanel => {
self.toggle_vertical_tabs_panel(ctx);
}
ToggleNotificationMailbox { select_first } => {
if FeatureFlag::HOANotifications.is_enabled()
&& *AISettings::as_ref(ctx).show_agent_notifications
{
let opening = !self.current_workspace_state.is_notification_mailbox_open;
self.current_workspace_state.is_notification_mailbox_open = opening;
if let Some(stack) = &self.notification_toast_stack {
stack.update(ctx, |stack, ctx| stack.set_mailbox_open(opening, ctx));
}
if opening {
if self.tab_bar_mode(ctx).has_tab_bar() {
self.tab_bar_pinned_by_popup = true;
}
if let Some(view) = &self.notification_mailbox_view {
view.update(ctx, |mailbox, ctx| {
mailbox.reset_for_open(*select_first, ctx);
});
ctx.focus(view);
}
} else {
self.tab_bar_pinned_by_popup = false;
self.sync_window_button_visibility(ctx);
self.focus_active_tab(ctx);
}
ctx.notify();
}
}
ToggleVerticalTabsSettingsPopup => {
if FeatureFlag::VerticalTabs.is_enabled()
&& *TabSettings::as_ref(ctx).use_vertical_tabs
&& self.vertical_tabs_panel_open
{
self.vertical_tabs_panel.show_settings_popup =
!self.vertical_tabs_panel.show_settings_popup;
ctx.notify();
}
}
SetVerticalTabsDisplayGranularity(granularity) => {
let granularity = *granularity;
TabSettings::handle(ctx).update(ctx, |settings, ctx| {
let _ = settings
.vertical_tabs_display_granularity
.set_value(granularity, ctx);
});
send_telemetry_from_ctx!(
VerticalTabsTelemetryEvent::DisplayOptionChanged(
VerticalTabsDisplayOption::DisplayGranularity(granularity),
),
ctx
);
ctx.notify();
}
SetVerticalTabsTabItemMode(mode) => {
let mode = *mode;
TabSettings::handle(ctx).update(ctx, |settings, ctx| {
let _ = settings.vertical_tabs_tab_item_mode.set_value(mode, ctx);
});
send_telemetry_from_ctx!(
VerticalTabsTelemetryEvent::DisplayOptionChanged(
VerticalTabsDisplayOption::TabItemMode(mode),
),
ctx
);
ctx.notify();
}
SetVerticalTabsViewMode(mode) => {
let mode = *mode;
TabSettings::handle(ctx).update(ctx, |settings, ctx| {
let _ = settings.vertical_tabs_view_mode.set_value(mode, ctx);
});
send_telemetry_from_ctx!(
VerticalTabsTelemetryEvent::DisplayOptionChanged(
VerticalTabsDisplayOption::ViewMode(mode),
),
ctx
);
ctx.notify();
}
SetVerticalTabsPrimaryInfo(primary_info) => {
let primary_info = *primary_info;
TabSettings::handle(ctx).update(ctx, |settings, ctx| {
let _ = settings
.vertical_tabs_primary_info
.set_value(primary_info, ctx);
});
send_telemetry_from_ctx!(
VerticalTabsTelemetryEvent::DisplayOptionChanged(
VerticalTabsDisplayOption::PrimaryInfo(primary_info),
),
ctx
);
ctx.notify();
}
SetVerticalTabsCompactSubtitle(subtitle) => {
let subtitle = *subtitle;
TabSettings::handle(ctx).update(ctx, |settings, ctx| {
let _ = settings
.vertical_tabs_compact_subtitle
.set_value(subtitle, ctx);
});
send_telemetry_from_ctx!(
VerticalTabsTelemetryEvent::DisplayOptionChanged(
VerticalTabsDisplayOption::CompactSubtitle(subtitle),
),
ctx
);
ctx.notify();
}
ToggleVerticalTabsShowPrLink => {
let new_value = TabSettings::handle(ctx).update(ctx, |settings, ctx| {
let new_value = !*settings.vertical_tabs_show_pr_link.value();
let _ = settings
.vertical_tabs_show_pr_link
.set_value(new_value, ctx);
new_value
});
send_telemetry_from_ctx!(
VerticalTabsTelemetryEvent::DisplayOptionChanged(
VerticalTabsDisplayOption::ShowPrLink(new_value),
),
ctx
);
ctx.notify();
}
ToggleVerticalTabsShowDiffStats => {
let new_value = TabSettings::handle(ctx).update(ctx, |settings, ctx| {
let new_value = !*settings.vertical_tabs_show_diff_stats.value();
let _ = settings
.vertical_tabs_show_diff_stats
.set_value(new_value, ctx);
new_value
});
send_telemetry_from_ctx!(
VerticalTabsTelemetryEvent::DisplayOptionChanged(
VerticalTabsDisplayOption::ShowDiffStats(new_value),
),
ctx
);
ctx.notify();
}
ToggleVerticalTabsShowDetailsOnHover => {
let new_value = TabSettings::handle(ctx).update(ctx, |settings, ctx| {
let new_value = !*settings.vertical_tabs_show_details_on_hover.value();
let _ = settings
.vertical_tabs_show_details_on_hover
.set_value(new_value, ctx);
new_value
});
send_telemetry_from_ctx!(
VerticalTabsTelemetryEvent::DisplayOptionChanged(
VerticalTabsDisplayOption::ShowDetailsOnHover(new_value),
),
ctx
);
ctx.notify();
}
ToggleAgentManagementView => {
if AISettings::as_ref(ctx).is_any_ai_enabled(ctx)
&& FeatureFlag::AgentManagementView.is_enabled()
{
let is_open = !self.current_workspace_state.is_agent_management_view_open;
self.set_is_agent_management_view_open(is_open, ctx);
send_telemetry_from_ctx!(
AgentManagementTelemetryEvent::ViewToggled { is_open },
ctx
);
if is_open {
ctx.focus(&self.agent_management_view);
} else {
self.focus_active_tab(ctx);
}
ctx.notify();
}
}
ViewAgentRunsForEnvironment { environment_id } => {
if AISettings::as_ref(ctx).is_any_ai_enabled(ctx)
&& FeatureFlag::AgentManagementView.is_enabled()
{
self.set_is_agent_management_view_open(true, ctx);
ctx.focus(&self.agent_management_view);
let environment_id = environment_id.clone();
self.agent_management_view.update(ctx, |view, ctx| {
view.apply_environment_filter_from_link(environment_id, ctx);
});
ctx.notify();
}
}
ClosePanel => {
if self.left_panel_view.is_self_or_child_focused(ctx) {
self.close_left_panel(ctx);
} else if self.right_panel_view.is_self_or_child_focused(ctx) {
let pane_group_handle = self.active_tab_pane_group().clone();
self.close_right_panel(&pane_group_handle, ctx);
}
}
OpenInExplorer { path } => {
ctx.open_file_path_in_explorer(path);
}
OpenFilePath { path } => {
ctx.open_file_path(path);
}
NewTabInAgentMode {
entrypoint,
zero_state_prompt_suggestion_type,
} => {
send_telemetry_from_ctx!(
TelemetryEvent::AgentModeClickedEntrypoint {
entrypoint: entrypoint.clone(),
},
ctx
);
self.add_terminal_tab_in_ai_mode(*zero_state_prompt_suggestion_type, ctx);
}
NewPaneInAgentMode {
entrypoint,
zero_state_prompt_suggestion_type,
} => {
send_telemetry_from_ctx!(
TelemetryEvent::AgentModeClickedEntrypoint {
entrypoint: entrypoint.clone(),
},
ctx
);
self.add_terminal_pane_in_ai_mode(*zero_state_prompt_suggestion_type, ctx);
}
OpenCloudAgentSetupGuide => {
if AISettings::as_ref(ctx).is_any_ai_enabled(ctx)
&& FeatureFlag::AgentManagementView.is_enabled()
{
self.set_is_agent_management_view_open(true, ctx);
ctx.focus(&self.agent_management_view);
self.agent_management_view.update(ctx, |view, ctx| {
view.show_setup_guide_from_link(ctx);
});
ctx.notify();
}
}
ToggleAIAssistant => {
self.toggle_ai_assistant_panel(ctx);
send_telemetry_from_ctx!(
TelemetryEvent::ToggleWarpAI {
opened: self.current_workspace_state.is_ai_assistant_panel_open
},
ctx
);
}
ClickedAIAssistantIcon => {
if !FeatureFlag::AgentMode.is_enabled() {
self.toggle_ai_assistant_panel(ctx);
if self.current_workspace_state.is_ai_assistant_panel_open {
send_telemetry_from_ctx!(
TelemetryEvent::OpenedWarpAI {
source: OpenedWarpAISource::GlobalEntryButton
},
ctx
);
}
}
}
ShowAIAssistantWarmWelcome => {
self.should_show_ai_assistant_warm_welcome = true;
ctx.notify();
}
ClickedAIAssistantWarmWelcome => {
self.toggle_ai_assistant_panel(ctx);
send_telemetry_from_ctx!(
TelemetryEvent::OpenedWarpAI {
source: OpenedWarpAISource::WarmWelcome
},
ctx
);
}
DragTab {
tab_index,
tab_position,
} => self.on_tab_drag(*tab_index, *tab_position, ctx),
DropTab => {
self.current_workspace_state.is_tab_being_dragged = false;
send_telemetry_from_ctx!(TelemetryEvent::DragAndDropTab, ctx);
}
CopyAccessTokenToClipboard => {
// Blocking is ok here only because this action is only registered in dev and local
// builds to aid in debugging and development.
let access_token =
galaxyui::r#async::block_on(self.server_api.get_or_refresh_access_token());
if let Ok(token) = access_token {
if let Some(bearer) = token.bearer_token() {
ctx.clipboard().write(ClipboardContent::plain_text(bearer));
}
}
}
CopyTextToClipboard(text) => {
ctx.clipboard()
.write(ClipboardContent::plain_text(text.to_string()));
}
DismissWorkspaceBanner(banner_type) => self.dismiss_workspace_banner(ctx, banner_type),
DismissAIAssistantWarmWelcome => {
self.dismiss_ai_assistant_warm_welcome(ctx);
}
Crash => {
#[cfg(feature = "crash_reporting")]
crate::crash_reporting::crash();
}
Panic => {
panic!("WorkspaceAction::Panic triggered from command palette");
}
DumpHeapProfile => {
#[cfg(feature = "dhat_heap_profiling")]
crate::profiling::dump_dhat_heap_profile();
}
OpenViewTreeDebugWindow => {
let window_id = ctx.window_id();
ctx.open_view_tree_debug_window(window_id);
}
ToggleSyncAllTerminalInputsInAllTabs => {
let enabled = SyncedInputState::handle(ctx).update(ctx, |status, _| {
status.toggle_sync_all_terminal_inputs_in_all_tabs(window_id);
status.is_syncing_all_inputs(window_id)
});
let verb = if enabled { "enabled" } else { "disabled" };
let mut message = format!("You {verb} synchronized inputs in all tabs.");
if let Some(keystroke) = keybinding_name_to_keystroke(
"workspace:toggle_sync_all_terminal_inputs_in_all_tabs",
ctx,
) {
let _ = write!(message, " Press {} to undo.", keystroke.displayed());
}
self.toast_stack.update(ctx, |view, ctx| {
let new_toast = DismissibleToast::default(message);
view.add_ephemeral_toast(new_toast, ctx);
});
send_telemetry_from_ctx!(
TelemetryEvent::ToggleSyncAllPanesInAllTabs { enabled },
ctx
);
self.process_updated_sync_state(ctx);
}
ToggleSyncTerminalInputsInTab => {
let enabled = SyncedInputState::handle(ctx).update(ctx, |status, _| {
let current_pane_group_id = self.active_tab_pane_group().id();
status.toggle_sync_terminal_inputs_in_tab(
current_pane_group_id,
self.all_pane_group_ids(),
self.tab_count(),
window_id,
);
status.should_sync_this_pane_group(current_pane_group_id, window_id)
});
let verb = if enabled { "enabled" } else { "disabled" };
let mut message = format!("You {verb} synchronized inputs in this tab.");
if let Some(keystroke) = keybinding_name_to_keystroke(
"workspace:toggle_sync_terminal_inputs_in_tab",
ctx,
) {
let _ = write!(message, " Press {} to undo.", keystroke.displayed());
}
self.toast_stack.update(ctx, |view, ctx| {
let new_toast = DismissibleToast::default(message);
view.add_ephemeral_toast(new_toast, ctx);
});
send_telemetry_from_ctx!(TelemetryEvent::ToggleSyncAllPanesInTab { enabled }, ctx);
self.process_updated_sync_state(ctx);
}
DisableTerminalInputSync => {
SyncedInputState::handle(ctx).update(ctx, |status, _| {
status.disable_sync_terminal_inputs(window_id);
});
self.process_updated_sync_state(ctx);
self.toast_stack.update(ctx, |view, ctx| {
let new_toast =
DismissibleToast::success("Disabled all synchronized inputs.".to_string());
view.add_ephemeral_toast(new_toast, ctx);
});
send_telemetry_from_ctx!(TelemetryEvent::DisableInputSync, ctx);
}
Reauth => {
AuthManager::handle(ctx).update(ctx, |auth_manager, ctx| {
let sign_in_url = auth_manager.sign_in_url();
ctx.open_url(&sign_in_url);
});
send_telemetry_from_ctx!(TelemetryEvent::InitiateReauth, ctx);
}
SignupAnonymousUser => {
self.initiate_user_signup(AnonymousUserSignupEntrypoint::SignUpButton, ctx);
}
SignInAnonymousWebUser => {
self.redirect_to_sign_in();
}
HandleConflictingWorkflow(workflow_id) => {
self.toast_stack.update(ctx, |view, ctx| {
view.dismiss_older_toasts(&workflow_id.uid(), ctx);
});
self.open_workflow_with_existing(
*workflow_id,
&OpenWarpDriveObjectSettings::default(),
ctx,
);
}
HandleConflictingEnvVarCollection(env_var_collection_id) => {
self.toast_stack.update(ctx, |view, ctx| {
view.dismiss_older_toasts(&env_var_collection_id.uid(), ctx);
});
self.open_env_var_collection(
&EnvVarCollectionSource::Existing(*env_var_collection_id),
true,
ctx,
);
}
OpenPromptEditor { open_source } => {
self.open_prompt_editor(*open_source, ctx);
}
OpenAgentToolbarEditor => {
self.open_agent_toolbar_editor(AgentToolbarEditorMode::AgentView, ctx);
}
OpenCLIAgentToolbarEditor => {
self.open_agent_toolbar_editor(AgentToolbarEditorMode::CLIAgent, ctx);
}
OpenHeaderToolbarEditor => {
self.open_header_toolbar_editor(ctx);
}
ShowHeaderToolbarContextMenu { position } => {
self.show_header_toolbar_context_menu(*position, ctx);
}
ReopenClosedSession => {
// While we could grab the UndoCloseStack singleton entity and
// directly call undo_close(), it would fail when attempting to
// restore a closed tab as we would attempt to update the
// workspace while we are currently updating the workspace.
// Instead, we use a global action to ensure we don't try to
// perform nested updates on the workspace.
ctx.dispatch_global_action("app:undo_close", ());
}
OpenShareSessionModal(index) => {
self.open_share_session_modal(*index, ctx);
}
StopSharingSessionFromTabMenu { terminal_view_id } => {
self.stop_sharing_session(terminal_view_id, SharedSessionActionSource::Tab, ctx)
}
StopSharingAllSessionsInTab { pane_group } => {
self.stop_sharing_all_panes_in_tab(pane_group, ctx)
}
CopySharedSessionLinkFromTab { tab_index } => {
self.copy_shared_session_link_from_tab(*tab_index, ctx)
}
AddWindow => {
ctx.dispatch_global_action("root_view:open_new", ());
}
AddWindowWithShell { shell } => {
ctx.dispatch_global_action("root_view:open_new_with_shell", Some(shell.clone()));
}
NavigatePrevPaneOrPanel => {
self.navigate_pane_or_panel(PanePanelDirection::Prev, ctx);
}
NavigateNextPaneOrPanel => {
self.navigate_pane_or_panel(PanePanelDirection::Next, ctx);
}
FocusLeftPanel => self.focus_left_panel(ctx),
FocusRightPanel => self.focus_right_panel(ctx),
ViewObjectInWarpDrive(item_id) => {
// Focus newly created object in WD
self.view_in_and_focus_warp_drive(*item_id, ctx);
}
OpenObjectSharingSettings { object_id, source } => {
self.open_object_sharing_settings(*object_id, None, *source, ctx);
}
UndoTrash(cloud_object_type_and_id) => {
self.update_warp_drive_view(ctx, |warp_drive, ctx| {
warp_drive.undo_trash(cloud_object_type_and_id, ctx);
});
}
TerminateApp => {
ctx.terminate_app(TerminationMode::Cancellable, None);
}
CloseWindow => {
if ContextFlag::CloseWindow.is_enabled() {
ctx.close_window();
}
}
RunAISuggestedCommand(code) => {
let command = code.trim().to_string();
let workflow = Workflow::new("Command from Oz", command);
self.run_workflow_in_active_input(
&WorkflowType::AIGenerated {
workflow,
origin: AIWorkflowOrigin::AgentMode,
},
WorkflowSource::WarpAI,
WorkflowSelectionSource::WarpAI,
None,
TerminalSessionFallbackBehavior::default(),
ctx,
);
ctx.notify();
}
RunCommand(code) => {
let command = code.trim().to_string();
self.insert_in_input(&command, true, true, false, ctx);
ctx.notify();
}
InsertInInput {
content,
replace_buffer,
ensure_agent_mode,
} => {
self.insert_in_input(content, *replace_buffer, false, *ensure_agent_mode, ctx);
ctx.notify();
}
AttemptLoginGatedAIUpgrade => {
AuthManager::handle(ctx).update(ctx, |auth_manager, ctx| {
auth_manager.attempt_login_gated_feature(
"Upgrade AI Usage",
AuthViewVariant::RequireLoginCloseable,
ctx,
)
});
}
#[cfg(all(enable_crash_recovery, target_os = "linux"))]
DismissWaylandCrashRecoveryBannerAndOpenLink => {
self.dismiss_workspace_banner(ctx, &WorkspaceBanner::WaylandCrashRecovery);
ctx.open_url("https://docs.warp.dev/terminal/more-features/linux#native-wayland");
}
FixInAgentMode { query } => {
self.active_tab_pane_group().update(ctx, |pane_group, ctx| {
pane_group.add_terminal_pane_in_agent_mode(None, None, ctx);
if let Some(terminal_view) = pane_group.focused_session_view(ctx) {
terminal_view.update(ctx, |terminal_view, terminal_view_ctx| {
terminal_view.ai_controller().update(
terminal_view_ctx,
|controller, ctx| {
controller.send_user_query_in_new_conversation(
query.to_owned(),
None,
EntrypointType::UserInitiated,
None,
ctx,
);
},
);
});
}
});
}
OpenAIFactCollection => {
self.open_ai_fact_collection_pane(None, None, ctx);
send_telemetry_from_ctx!(
TelemetryEvent::KnowledgePaneOpened {
entrypoint: KnowledgePaneEntrypoint::Global,
},
ctx
);
}
OpenMCPServerCollection => {
self.show_settings_with_section(Some(SettingsSection::MCPServers), ctx);
send_telemetry_from_ctx!(
TelemetryEvent::MCPServerCollectionPaneOpened {
entrypoint: MCPServerCollectionPaneEntrypoint::Global,
},
ctx
);
}
OpenEnvironmentManagementPane => {
self.open_environment_management_pane(None, EnvironmentsPage::Create, ctx);
}
ToggleAIDocumentPane {
document_id,
document_version,
} => {
let conversation_id =
AIDocumentModel::as_ref(ctx).get_conversation_id_for_document_id(document_id);
if let Some(conversation_id) = conversation_id {
self.active_tab_pane_group().update(ctx, |pane_group, ctx| {
pane_group.toggle_ai_document_pane(
conversation_id,
*document_id,
*document_version,
ctx,
);
});
}
}
HideAIDocumentPanes => {
self.active_tab_pane_group().update(ctx, |pane_group, ctx| {
pane_group.close_all_ai_document_panes(ctx);
});
}
OpenAIDocumentPane {
document_id,
document_version,
} => {
let conversation_id =
AIDocumentModel::as_ref(ctx).get_conversation_id_for_document_id(document_id);
if let Some(conversation_id) = conversation_id {
self.active_tab_pane_group().update(ctx, |pane_group, ctx| {
pane_group.open_ai_document_pane(
conversation_id,
*document_id,
*document_version,
ctx,
);
});
}
}
TabHoverWidthStart { width } => {
// Store the fixed width value for the tab to maintain consistent size during hover
self.tab_fixed_width = Some(*width);
ctx.notify();
}
TabHoverWidthEnd => {
// Clear the stored width when hover ends
self.tab_fixed_width = None;
ctx.notify();
}
FocusTerminalViewInWorkspace { terminal_view_id } => {
if !self.focus_terminal_view_locally(*terminal_view_id, ctx) {
self.focus_terminal_view_in_other_window(*terminal_view_id, ctx);
}
}
FocusPane(locator) => {
self.focus_pane(*locator, ctx);
}
StartNewConversation { terminal_view_id } => {
Self::set_pending_query_state_for_terminal_view(
*terminal_view_id,
PendingQueryState::default(),
ctx,
);
self.handle_action(
&WorkspaceAction::FocusTerminalViewInWorkspace {
terminal_view_id: *terminal_view_id,
},
ctx,
);
}
JumpToLatestToast => {
if FeatureFlag::HOANotifications.is_enabled() {
let newest = AgentNotificationsModel::as_ref(ctx)
.notifications()
.items_filtered(NotificationFilter::Unread)
.next()
.map(|item| (item.id, item.terminal_view_id));
if let Some((id, terminal_view_id)) = newest {
AgentNotificationsModel::handle(ctx).update(ctx, |model, ctx| {
model.mark_item_read(id, ctx);
});
self.handle_action(
&WorkspaceAction::FocusTerminalViewInWorkspace { terminal_view_id },
ctx,
);
}
} else if let Some((window_id, tab_index, terminal_view_id)) = self
.agent_toast_stack
.as_ref(ctx)
.get_latest_toast_navigation_data()
{
ctx.windows().show_window_and_focus_app(window_id);
self.activate_tab(tab_index, ctx);
// Focus the terminal view using the existing FocusTerminalViewInWorkspace logic
for (tab_idx, tab) in self.tabs.iter().enumerate() {
let pane_group_handle = &tab.pane_group;
let pane_group = pane_group_handle.as_ref(ctx);
if let Some(pane_id) =
pane_group.find_pane_id_for_terminal_view(terminal_view_id, ctx)
{
let locator = PaneViewLocator {
pane_group_id: pane_group_handle.id(),
pane_id,
};
if tab_idx == tab_index {
self.focus_pane(locator, ctx);
break;
}
}
}
// Dismiss any currently visible toasts for this conversation
if let Some(latest_uuid) =
self.agent_toast_stack.as_ref(ctx).latest_toast_uuid()
{
self.agent_toast_stack.update(ctx, |stack, ctx| {
stack.dismiss_toast_by_uuid(&latest_uuid, ctx);
});
}
}
}
ScrollToSettingsWidget { page, widget_id } => {
self.open_settings_pane(Some(*page), None, ctx);
self.settings_pane.update(ctx, |settings, ctx| {
settings.scroll_to_settings_widget(*page, widget_id, ctx);
});
ctx.notify();
}
OpenFileInNewTab {
full_path,
line_and_column,
} => {
self.add_tab_for_code_file(full_path.clone(), *line_and_column, ctx);
}
OpenRepository { path } => {
self.open_repository(path.as_deref(), ctx);
}
OpenTabConfigRepoPicker { param_index } => {
self.open_repo_picker_for_tab_config_modal(*param_index, ctx);
}
NewCodeFile => {
self.add_tab_for_new_code_file(ctx);
}
OpenNotebook { id } => self.open_notebook(
&NotebookSource::Existing(*id),
&OpenWarpDriveObjectSettings::default(),
ctx,
true,
),
RunWorkflow {
workflow,
workflow_source,
workflow_selection_source,
argument_override,
} => self.run_workflow_in_active_input(
workflow,
*workflow_source,
*workflow_selection_source,
argument_override.clone(),
TerminalSessionFallbackBehavior::default(),
ctx,
),
RestoreOrNavigateToConversation {
pane_view_locator,
window_id,
conversation_id,
terminal_view_id,
restore_layout,
} => {
self.restore_or_navigate_to_conversation(
*conversation_id,
*window_id,
*pane_view_locator,
*terminal_view_id,
*restore_layout,
ctx,
);
}
OpenAmbientAgentSession {
session_id,
task_id,
} => {
// Mark task as manually opened so it appears in the conversation list
// even if its source is not user-initiated.
AgentConversationsModel::handle(ctx).update(ctx, |model, ctx| {
model.mark_task_as_manually_opened(*task_id, ctx);
});
// Check if there's already a terminal viewing this task.
if let Some(tab_index) =
self.find_tab_with_ambient_agent_conversation(*task_id, ctx)
{
self.activate_tab(tab_index, ctx);
} else {
self.add_tab_for_joining_shared_session(*session_id, ctx);
}
}
OpenConversationTranscriptViewer {
conversation_id,
ambient_agent_task_id,
} => {
// Mark task as manually opened so it appears in the conversation list
// even if its source is not user-initiated.
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);
});
}
// Check if there's already a terminal viewing this conversation's task.
if let Some(task_id) = ambient_agent_task_id {
if let Some(tab_index) =
self.find_tab_with_ambient_agent_conversation(*task_id, ctx)
{
self.activate_tab(tab_index, ctx);
return;
}
}
self.load_cloud_conversation_into_new_transcript_viewer(
conversation_id.clone(),
ctx,
);
}
ForkAIConversation {
conversation_id,
fork_from_exchange,
summarize_after_fork,
summarization_prompt,
initial_prompt,
destination,
} => {
self.fork_ai_conversation(
*conversation_id,
*fork_from_exchange,
*summarize_after_fork,
summarization_prompt.clone(),
initial_prompt.clone(),
*destination,
ctx,
);
}
#[cfg(not(target_family = "wasm"))]
ContinueConversationLocally { conversation_id } => {
self.fork_ai_conversation(
*conversation_id,
None,
false,
None,
None,
ForkedConversationDestination::SplitPane,
ctx,
);
}
SummarizeAIConversation {
prompt,
initial_prompt,
} => {
self.summarize_active_ai_conversation(prompt.clone(), initial_prompt.clone(), ctx);
}
QueuePromptForConversation { prompt } => {
let Some(terminal_view) = self
.active_tab_pane_group()
.as_ref(ctx)
.active_session_view(ctx)
else {
return;
};
terminal_view.update(ctx, |terminal, ctx| {
terminal.send_user_query_after_next_conversation_finished(
prompt.clone(),
/* show_close_button */ true,
/* show_send_now_button */ true,
ctx,
);
});
}
InsertForkSlashCommand => {
self.active_tab_pane_group().update(ctx, |pane_group, ctx| {
if let Some(terminal_view) = pane_group.active_session_view(ctx) {
terminal_view.update(ctx, |terminal, ctx| {
terminal.input().update(ctx, |input, ctx| {
input.replace_buffer_content(
&format!("{} ", commands::FORK.name),
ctx,
);
ctx.focus_self();
});
});
}
});
}
CreatePersonalAIPrompt => {
if let Some(personal_drive) = UserWorkspaces::as_ref(ctx).personal_drive(ctx) {
let source = WorkflowOpenSource::New {
title: None,
content: None,
owner: personal_drive,
initial_folder_id: None,
is_for_agent_mode: true,
};
self.open_workflow_in_pane(
&source,
&OpenWarpDriveObjectSettings::default(),
WorkflowViewMode::Create,
ctx,
);
}
}
CreateTeamAIPrompt => {
let team_uid = self.team_uid(ctx);
if let Some(team_uid) = team_uid {
let source = WorkflowOpenSource::New {
title: None,
content: None,
owner: Owner::Team { team_uid },
initial_folder_id: None,
is_for_agent_mode: true,
};
self.open_workflow_in_pane(
&source,
&OpenWarpDriveObjectSettings::default(),
WorkflowViewMode::Create,
ctx,
);
}
}
#[cfg(feature = "local_fs")]
FileRenamed { old_path, new_path } => {
self.rename_tabs_with_file_path(old_path, new_path, ctx);
}
#[cfg(feature = "local_fs")]
FileDeleted { path } => {
self.close_tabs_with_file_path(path, ctx);
}
#[cfg(debug_assertions)]
OpenBuildPlanMigrationModal => {
// Force open the modal for debugging
OneTimeModalModel::handle(ctx).update(ctx, |model, ctx| {
model.force_open_build_plan_migration_modal(ctx);
});
ctx.notify();
}
#[cfg(debug_assertions)]
ResetBuildPlanMigrationModalState => {
// Reset the dismissed state for debugging
let general_settings = GeneralSettings::handle(ctx);
general_settings.update(ctx, |settings, ctx| {
if let Err(e) = settings
.build_plan_migration_modal_dismissed
.set_value(false, ctx)
{
log::warn!(
"Failed to reset build plan migration modal dismissed setting: {e}"
);
}
});
log::info!("Build plan migration modal dismissed state has been reset");
}
#[cfg(debug_assertions)]
DebugResetBedrockLoginBannerDismissed => {
// Reset the AWS Bedrock login banner dismissed state for debugging
AISettings::handle(ctx).update(ctx, |ai_settings, ctx| {
if let Err(e) = ai_settings
.bedrock_login_banner_dismissed
.set_value(false, ctx)
{
log::warn!(
"Failed to reset AWS Bedrock login banner dismissed setting: {e}"
);
}
});
log::info!("AWS Bedrock login banner dismissed state has been reset");
}
#[cfg(debug_assertions)]
OpenOzLaunchModal => {
// Force open the Oz launch modal for debugging
OneTimeModalModel::handle(ctx).update(ctx, |model, ctx| {
model.force_open_oz_launch_modal(ctx);
});
ctx.notify();
}
#[cfg(debug_assertions)]
ResetOzLaunchModalState => {
// Reset the Oz launch modal dismissed state for debugging
let old_value = *AISettings::as_ref(ctx).did_check_to_trigger_oz_launch_modal;
AISettings::handle(ctx).update(ctx, |ai_settings, ctx| {
if let Err(e) = ai_settings
.did_check_to_trigger_oz_launch_modal
.set_value(false, ctx)
{
log::warn!("Failed to reset Oz launch modal dismissed setting: {e}");
}
});
let new_value = *AISettings::as_ref(ctx).did_check_to_trigger_oz_launch_modal;
log::info!(
"Oz launch modal state: old={}, new={}, feature_flag_enabled={}",
old_value,
new_value,
FeatureFlag::OzLaunchModal.is_enabled()
);
}
#[cfg(debug_assertions)]
OpenOpenWarpLaunchModal => {
// Force open the OpenWarp launch modal for debugging
OneTimeModalModel::handle(ctx).update(ctx, |model, ctx| {
model.force_open_openwarp_launch_modal(ctx);
});
ctx.notify();
}
#[cfg(debug_assertions)]
ResetOpenWarpLaunchModalState => {
// Reset the OpenWarp launch modal dismissed state for debugging
let old_value = *GeneralSettings::as_ref(ctx)
.did_check_to_trigger_openwarp_launch_modal
.value();
GeneralSettings::handle(ctx).update(ctx, |settings, ctx| {
if let Err(e) = settings
.did_check_to_trigger_openwarp_launch_modal
.set_value(false, ctx)
{
log::warn!("Failed to reset OpenWarp launch modal dismissed setting: {e}");
}
});
let new_value = *GeneralSettings::as_ref(ctx)
.did_check_to_trigger_openwarp_launch_modal
.value();
log::info!(
"OpenWarp launch modal state: old={}, new={}, feature_flag_enabled={}",
old_value,
new_value,
FeatureFlag::OpenWarpLaunchModal.is_enabled()
);
}
#[cfg(debug_assertions)]
InstallOpenCodeWarpPlugin => {
let message = set_opencode_warp_plugin("github:warpdotdev/opencode-warp-internal");
self.toast_stack.update(ctx, |view, ctx| {
view.add_ephemeral_toast(DismissibleToast::default(message), ctx);
});
}
#[cfg(debug_assertions)]
UseLocalOpenCodeWarpPlugin => {
let message = match dirs::home_dir() {
Some(home) => {
let plugin_path = home.join("opencode-warp/src/index.ts");
let entry = format!("file://{}", plugin_path.display());
set_opencode_warp_plugin(&entry)
}
None => "Failed to determine home directory".to_string(),
};
self.toast_stack.update(ctx, |view, ctx| {
view.add_ephemeral_toast(DismissibleToast::default(message), ctx);
});
}
#[cfg(target_os = "macos")]
SampleProcess => {
let pid = process::id();
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let output_path = env::temp_dir()
.join(format!("warp_sample_{timestamp}.txt"))
.display()
.to_string();
self.toast_stack.update(ctx, |view, ctx| {
view.add_ephemeral_toast(
DismissibleToast::default("Sampling process for 3 seconds...".to_string()),
ctx,
);
});
let output_path_clone = output_path.clone();
ctx.spawn(
async move {
tokio::task::spawn_blocking(move || {
// `sample` is the macOS CLI that Activity Monitor uses for "Sample Process".
Command::new("sample")
.args([
// process ID
&pid.to_string(),
// duration in seconds
"3",
// sampling interval in milliseconds
"1",
// write output to file
"-file",
&output_path_clone,
])
.output()
})
.await
},
move |me, result, ctx| {
let message = match result {
Ok(Ok(output)) if output.status.success() => {
ctx.open_file_path_in_explorer(Path::new(&output_path));
#[cfg(feature = "crash_reporting")]
if ChannelState::channel().is_dogfood() {
// For dogfood process samples, we raise a sentry warning with the sample attatched.
// We do this so that our performance bot can then read through the performance logs
// in sentry and write up a report of findings/possible optimizations.
if let Ok(sample_data) = fs::read(&output_path) {
let filename = Path::new(&output_path)
.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_else(|| "process_sample.txt".to_string());
let attachment = Attachment {
buffer: sample_data,
filename,
ty: Some(AttachmentType::Attachment),
..Default::default()
};
sentry::with_scope(
|scope| {
scope.add_attachment(attachment);
},
|| {
sentry::capture_message(
"[FOR PERFORMANCE BOT] Dev took performance sample with results: ",
sentry::Level::Warning,
)
},
);
}
}
format!("Process sample saved to {output_path}")
}
Ok(Ok(output)) => {
let stderr = String::from_utf8_lossy(&output.stderr);
log::error!("sample command failed ({}): {stderr}", output.status);
"Failed to sample process (check logs)".to_string()
}
Ok(Err(io_err)) => {
log::error!("Failed to run sample command: {io_err}");
"Failed to sample process (check logs)".to_string()
}
Err(join_err) => {
log::error!("Sample task panicked: {join_err}");
"Failed to sample process (check logs)".to_string()
}
};
me.toast_stack.update(ctx, |view, ctx| {
view.add_ephemeral_toast(DismissibleToast::default(message), ctx);
});
},
);
}
ToggleProjectExplorer => {
if *CodeSettings::as_ref(ctx).show_project_explorer {
let is_showing = self.left_panel_view.as_ref(ctx).active_view()
== ToolPanelView::ProjectExplorer;
self.toggle_left_panel_view(&LeftPanelAction::ProjectExplorer, is_showing, ctx);
}
}
ToggleWarpDrive => {
if WarpDriveSettings::is_warp_drive_enabled(ctx) {
let is_showing =
self.left_panel_view.as_ref(ctx).active_view() == ToolPanelView::WarpDrive;
self.toggle_left_panel_view(&LeftPanelAction::WarpDrive, is_showing, ctx);
}
}
ToggleGlobalSearch => {
if FeatureFlag::GlobalSearch.is_enabled()
&& *CodeSettings::as_ref(ctx).show_global_search
{
let is_showing = matches!(
self.left_panel_view.as_ref(ctx).active_view(),
ToolPanelView::GlobalSearch { .. }
);
self.toggle_left_panel_view(
&LeftPanelAction::GlobalSearch {
entry_focus: GlobalSearchEntryFocus::QueryEditor,
},
is_showing,
ctx,
);
}
}
OpenGlobalSearch => {
if FeatureFlag::GlobalSearch.is_enabled()
&& *CodeSettings::as_ref(ctx).show_global_search
{
if let Some(selected_text) = self.get_selected_text_from_focused_view(ctx) {
if let Some(global_search_view) = self
.left_panel_view
.as_ref(ctx)
.active_global_search_view(ctx)
{
// If we detect selected text in the active pane, pre-populate the global search input
global_search_view.update(ctx, |view, ctx| {
view.set_initial_query(selected_text, ctx);
});
}
}
self.open_left_panel_view(
&LeftPanelAction::GlobalSearch {
entry_focus: GlobalSearchEntryFocus::QueryEditor,
},
ctx,
);
}
}
ToggleConversationListView => {
if FeatureFlag::AgentViewConversationListView.is_enabled() {
let is_showing = self.left_panel_view.as_ref(ctx).active_view()
== ToolPanelView::ConversationListView;
self.toggle_left_panel_view(
&LeftPanelAction::ConversationListView,
is_showing,
ctx,
);
}
}
ShowRewindConfirmationDialog {
ai_block_view_id,
exchange_id,
conversation_id,
} => {
self.show_rewind_confirmation_dialog(
RewindDialogSource {
ai_block_view_id: *ai_block_view_id,
exchange_id: *exchange_id,
conversation_id: *conversation_id,
},
ctx,
);
}
ExecuteRewindAIConversation {
ai_block_view_id,
exchange_id,
conversation_id,
} => {
// Extract the user query before the rewind to prefill the input
let user_query = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(conversation_id)
.and_then(|c| c.root_task_exchanges().find(|e| e.id == *exchange_id))
.and_then(|e| {
e.input
.iter()
.find(|i| i.is_user_query())
.and_then(|i| i.user_query())
});
// Dispatch to the active terminal to execute the rewind
if let Some(terminal_view) = self
.active_tab_pane_group()
.as_ref(ctx)
.focused_session_view(ctx)
{
terminal_view.update(ctx, |terminal, ctx| {
terminal.handle_action(
&TerminalAction::ExecuteRewindAIConversation {
ai_block_view_id: *ai_block_view_id,
exchange_id: *exchange_id,
conversation_id: *conversation_id,
},
ctx,
);
});
}
// Prefill the input after the rewind
if let Some(query) = user_query {
self.insert_in_input(&query, true, false, true, ctx);
}
}
ExecuteDeleteConversation {
conversation_id,
terminal_view_id,
} => {
// Exit agent view first if this conversation is currently expanded.
// This must happen before updating BlocklistAIHistoryModel to avoid
// circular model references.
if let Some(controller) = ActiveAgentViewsModel::as_ref(ctx)
.get_controller_for_conversation(*conversation_id, ctx)
{
let succesfully_exited_agent_view =
controller.update(ctx, |controller, ctx| {
controller.exit_agent_view(ctx);
!controller.is_active()
});
if !succesfully_exited_agent_view {
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
toast_stack.add_ephemeral_toast(
DismissibleToast::error(
"Failed to delete conversation. Please exit the agent view and try again.".to_string(),
),
window_id,
ctx,
);
});
return;
}
}
conversation_utils::delete_conversation(*conversation_id, *terminal_view_id, ctx);
send_telemetry_from_ctx!(TelemetryEvent::ConversationListItemDeleted, ctx);
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
toast_stack.add_ephemeral_toast(
DismissibleToast::success("Conversation deleted".to_string()),
window_id,
ctx,
);
});
}
#[cfg(target_family = "wasm")]
ToggleConversationTranscriptDetailsPanel => {
let is_open = !self
.current_workspace_state
.is_transcript_details_panel_open;
self.current_workspace_state
.is_transcript_details_panel_open = is_open;
self.transcript_info_button.update(ctx, |button, ctx| {
button.set_active(is_open, ctx);
});
if is_open {
self.update_transcript_details_panel_data(ctx);
}
ctx.notify();
}
OpenLightbox {
images,
initial_index,
} => {
let params = LightboxParams {
images: images.clone(),
initial_index: *initial_index,
};
if let Some(handle) = &self.lightbox_view {
handle.update(ctx, |view, ctx| view.update_params(params, ctx));
} else {
let handle = ctx.add_typed_action_view(|ctx| LightboxView::new(params, ctx));
ctx.subscribe_to_view(&handle, |me, _, event, ctx| match event {
LightboxViewEvent::Close => {
me.lightbox_view = None;
me.focus_active_tab(ctx);
ctx.notify();
}
LightboxViewEvent::FocusLost => {
// Focus already moved elsewhere; just tear down the view.
me.lightbox_view = None;
ctx.notify();
}
});
ctx.focus(&handle);
self.lightbox_view = Some(handle);
}
ctx.notify();
}
UpdateLightboxImage { index, image } => {
if let Some(handle) = &self.lightbox_view {
handle.update(ctx, |view, ctx| {
view.update_image_at(*index, image.clone(), ctx);
});
ctx.notify();
}
}
HandoffPendingTransfer { .. } => {}
ReverseHandoff { .. } => {}
FinalizeDropTab => {}
SyncTrafficLights => {
self.sync_window_button_visibility(ctx);
}
};
if action.should_save_app_state_on_action() {
ctx.dispatch_global_action("workspace:save_app", ());
}
}
}
impl View for Workspace {
fn ui_name() -> &'static str {
"Workspace"
}
fn self_or_child_interacted_with(&self, ctx: &mut ViewContext<Self>) {
self.sync_window_button_visibility(ctx);
}
fn keymap_context(&self, app: &AppContext) -> galaxyui::keymap::Context {
let mut context = Self::default_keymap_context();
if NetworkStatus::as_ref(app).is_online() {
context.set.insert("IsOnline");
}
if AISettings::as_ref(app).is_any_ai_enabled(app) {
context.set.insert(flags::IS_ANY_AI_ENABLED);
}
if AISettings::as_ref(app).is_active_ai_enabled(app) {
context.set.insert(flags::IS_ACTIVE_AI_ENABLED);
}
if AISettings::as_ref(app).is_voice_input_enabled(app)
&& UserWorkspaces::as_ref(app).is_voice_enabled()
{
context.set.insert(flags::IS_VOICE_INPUT_ENABLED);
}
if self
.active_tab_pane_group()
.as_ref(app)
.any_pane_being_dragged(app)
{
context.set.insert("Workspace_PaneDragging");
}
// TODO: This is temporary. We currently check if any code pane is open where it should
// really be whether the code pane is opened and focused.
if self
.active_tab_pane_group()
.as_ref(app)
.pane_ids()
.any(|id| id.is_code_pane())
{
context.set.insert("Workspace_TextOpen");
}
if matches!(
autoupdate::get_update_state(app),
AutoupdateStage::UpdateReady { .. } | AutoupdateStage::UpdatedPendingRestart { .. }
) {
context.set.insert("AutoupdateState_UpdateReady");
}
if matches!(
*AccessibilitySettings::as_ref(app).a11y_verbosity,
AccessibilityVerbosity::Verbose
) {
context.set.insert("AccessibilityVerbosity_Verbose");
}
if ContextFlag::CloseWindow.is_enabled() {
context.set.insert("Workspace_CloseWindow");
}
match self.tab_count() {
0 => {
debug_assert!(false, "Should always be at least one tab");
}
1 => {
context.set.insert("Workspace_SingleTab");
}
n => {
context.set.insert("Workspace_MultipleTabs");
if self.active_tab_index == 0 {
context.set.insert("Workspace_LeftmostTabActive");
} else if self.active_tab_index == n - 1 {
context.set.insert("Workspace_RightmostTabActive");
}
}
};
if WarpDriveSettings::is_warp_drive_enabled(app) {
context.set.insert(flags::ENABLE_WARP_DRIVE);
}
if AISettings::as_ref(app).is_any_ai_enabled(app)
&& *AISettings::as_ref(app).show_conversation_history
{
context.set.insert(flags::SHOW_CONVERSATION_HISTORY);
}
if *CodeSettings::as_ref(app).show_project_explorer {
context.set.insert(flags::SHOW_PROJECT_EXPLORER);
}
if *CodeSettings::as_ref(app).show_global_search {
context.set.insert(flags::SHOW_GLOBAL_SEARCH);
}
if self.team_uid(app).is_some() {
context.set.insert("WarpDrive_BelongsToTeam");
}
if self.auth_state.is_anonymous_or_logged_out() {
context.set.insert("IsAnonymousUser");
}
self.add_toggle_setting_context_flags(app, &mut context);
let sync_state = SyncedInputState::as_ref(app);
if sync_state.is_syncing_all_inputs(self.window_id) {
context.set.insert(flags::SYNC_ALL_TABS_FLAG);
} else if sync_state
.is_syncing_all_panes_in_pane_group(self.window_id, self.active_tab_pane_group().id())
{
context.set.insert(flags::SYNC_ALL_PANES_IN_CURRENT_TAB);
}
let is_universal_developer_input_enabled =
InputSettings::as_ref(app).is_universal_developer_input_enabled(app);
if is_universal_developer_input_enabled {
context.set.insert(flags::UNIVERSAL_DEVELOPER_INPUT_ENABLED);
}
let default_terminal = DefaultTerminal::as_ref(app);
if default_terminal.is_warp_default() {
context.set.insert(flags::WARP_IS_DEFAULT_TERMINAL);
}
if FeatureFlag::DebugMode.is_enabled() {
let debug_settings = DebugSettings::as_ref(app);
if *debug_settings.recording_mode.value() {
context.set.insert(flags::RECORDING_MODE_FLAG);
}
if *debug_settings
.are_in_band_generators_for_all_sessions_enabled
.value()
{
context.set.insert(flags::IN_BAND_GENERATORS_FLAG);
}
let network_status = NetworkStatus::as_ref(app);
if network_status.is_online() {
context.set.insert(flags::DEBUG_NETWORK_ONLINE_FLAG);
}
if debug_settings.should_show_memory_stats() {
context.set.insert(flags::DEBUG_SHOW_MEMORY_STATS_FLAG);
}
}
// We only want to register the temporary changelog shortcut if the changelog toast is
// visible.
// There is a collision between the default shortcut and `/open-repo`, so durable changelog
// access lives in the command palette and slash-command menu instead.
if self.update_toast_stack.as_ref(app).has_toasts() {
context.set.insert("UpdateToastVisible");
}
if self.is_shared_session_viewer_focused(app) {
context.set.insert("Workspace_ViewOnlySharedSession");
}
if let Some(terminal_view) = self
.active_tab_pane_group()
.as_ref(app)
.focused_session_view(app)
{
let terminal_view = terminal_view.as_ref(app);
if terminal_view.is_long_running() {
context.set.insert("LongRunningCommand");
}
if FeatureFlag::AgentView.is_enabled() {
let agent_view_state = terminal_view
.agent_view_controller()
.as_ref(app)
.agent_view_state();
if agent_view_state.is_fullscreen() {
context.set.insert(flags::ACTIVE_AGENT_VIEW);
} else if agent_view_state.is_inline() {
context.set.insert(flags::ACTIVE_INLINE_AGENT_VIEW);
}
}
}
#[cfg(target_family = "wasm")]
if self.is_conversation_transcript_viewer_focused(app) {
context.set.insert("Workspace_CloudConversationWebViewer");
}
context
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let tab_bar_mode = self.tab_bar_mode(app);
// For WASM simplified tab bar views (Warp Drive objects, shared sessions, conversation transcripts),
// we render the tab bar outside of panels so that the details panel only affects content below the tab bar.
cfg_if::cfg_if! {
if #[cfg(target_family = "wasm")] {
let use_simplified_wasm_tab_bar = self.get_simplified_wasm_tab_bar_content(app).is_some();
} else {
let use_simplified_wasm_tab_bar = false;
}
}
let panels = if use_simplified_wasm_tab_bar {
// For the simplified WASM tab bar, we want to render the tab bar on top of all other content
// so that content being added/moved around in the workspace (for example the details panel being toggled)
// does not affect the tab.
let mut outer_column = Flex::column();
if tab_bar_mode == ShowTabBar::Stacked {
outer_column.add_child(self.render_tab_bar(self.tab_fixed_width, appearance, app));
}
let content = self.render_banner_and_active_tab(app, appearance);
// Hide the vertical tab rail for simplified WASM views (notebooks, shared sessions, etc.)
let panels_row = self.render_panels(app, Shrinkable::new(1.0, content).finish(), true);
outer_column.add_child(Shrinkable::new(1.0, panels_row).finish());
outer_column.finish()
} else {
let mut outer_column = Flex::column();
if tab_bar_mode == ShowTabBar::Stacked {
outer_column.add_child(self.render_tab_bar(self.tab_fixed_width, appearance, app));
}
let content = self.render_banner_and_active_tab(app, appearance);
let panels_row = self.render_panels(app, Shrinkable::new(1.0, content).finish(), false);
outer_column.add_child(Shrinkable::new(1.0, panels_row).finish());
Container::new(outer_column.finish())
.with_background(util::get_terminal_background_fill(self.window_id, app))
.finish()
};
let mut stack = Stack::new();
#[cfg(target_family = "wasm")]
{
let pane_group = self.active_tab_pane_group().as_ref(app);
if galaxyui::platform::wasm::is_mobile_device() && pane_group.left_panel_open {
let scrim = Rect::new()
.with_background(Fill::Solid(ColorU::new(
0,
0,
0,
MOBILE_OVERLAY_SCRIM_ALPHA,
)))
.finish();
let clickable_scrim = EventHandler::new(scrim)
.on_left_mouse_down(|ctx, _, _| {
ctx.dispatch_typed_action(WorkspaceAction::ToggleLeftPanel);
DispatchEventResult::StopPropagation
})
.finish();
stack.add_positioned_overlay_child(
Percentage::width(1.0 - MOBILE_OVERLAY_PANEL_WIDTH_RATIO, clickable_scrim)
.finish(),
OffsetPositioning::offset_from_save_position_element(
TAB_BAR_POSITION_ID,
vec2f(0., 0.),
PositionedElementOffsetBounds::WindowBySize,
PositionedElementAnchor::BottomRight,
ChildAnchor::TopRight,
),
);
let panel_content = Container::new(ChildView::new(&self.left_panel_view).finish())
.with_background(appearance.theme().surface_1())
.finish();
stack.add_positioned_overlay_child(
Percentage::width(MOBILE_OVERLAY_PANEL_WIDTH_RATIO, panel_content).finish(),
OffsetPositioning::offset_from_save_position_element(
TAB_BAR_POSITION_ID,
vec2f(0., 0.),
PositionedElementOffsetBounds::WindowBySize,
PositionedElementAnchor::BottomLeft,
ChildAnchor::TopLeft,
),
);
}
}
stack.add_child(
Container::new(panels)
.with_uniform_padding(WORKSPACE_PADDING)
.finish(),
);
if !use_simplified_wasm_tab_bar
&& FeatureFlag::VerticalTabs.is_enabled()
&& *TabSettings::as_ref(app).use_vertical_tabs
&& self.vertical_tabs_panel_open
&& self.vertical_tabs_panel.show_settings_popup
{
stack.add_positioned_overlay_child(
Dismiss::new(render_settings_popup(&self.vertical_tabs_panel, app))
.prevent_interaction_with_other_elements()
.on_dismiss(|ctx, _| {
ctx.dispatch_typed_action(WorkspaceAction::ToggleVerticalTabsSettingsPopup);
})
.finish(),
OffsetPositioning::offset_from_save_position_element(
VERTICAL_TABS_SETTINGS_BUTTON_POSITION_ID,
vec2f(0., 4.),
PositionedElementOffsetBounds::WindowByPosition,
PositionedElementAnchor::BottomLeft,
ChildAnchor::TopLeft,
),
);
}
if FeatureFlag::VerticalTabs.is_enabled()
&& *TabSettings::as_ref(app).use_vertical_tabs
&& self.vertical_tabs_panel_open
{
if let Some(vertical_tabs::DetailSidecarOverlay {
anchor_position_id,
offset,
bounds,
parent_anchor,
child_anchor,
sidecar,
}) = render_detail_sidecar(
&self.vertical_tabs_panel,
self,
Self::tabs_panel_side(&TabSettings::as_ref(app).header_toolbar_chip_selection),
app,
) {
stack.add_positioned_overlay_child(
sidecar,
OffsetPositioning::offset_from_save_position_element(
&anchor_position_id,
offset,
bounds,
parent_anchor,
child_anchor,
),
);
}
}
// Transcript details panel overlay (right side, mobile only)
#[cfg(target_family = "wasm")]
if galaxyui::platform::wasm::is_mobile_device()
&& self
.current_workspace_state
.is_transcript_details_panel_open
{
// Dimming scrim on the left (10% width); tapping closes the panel
let scrim = Rect::new()
.with_background(Fill::Solid(ColorU::new(
0,
0,
0,
MOBILE_OVERLAY_SCRIM_ALPHA,
)))
.finish();
let clickable_scrim = EventHandler::new(scrim)
.on_left_mouse_down(|ctx, _, _| {
ctx.dispatch_typed_action(
WorkspaceAction::ToggleConversationTranscriptDetailsPanel,
);
DispatchEventResult::StopPropagation
})
.finish();
stack.add_positioned_overlay_child(
Percentage::width(1.0 - MOBILE_OVERLAY_PANEL_WIDTH_RATIO, clickable_scrim).finish(),
OffsetPositioning::offset_from_save_position_element(
TAB_BAR_POSITION_ID,
vec2f(0., 0.),
PositionedElementOffsetBounds::WindowBySize,
PositionedElementAnchor::BottomLeft,
ChildAnchor::TopLeft,
),
);
// Details panel overlay (90% width, positioned on the right)
let panel_content = ChildView::new(&self.transcript_details_panel).finish();
stack.add_positioned_overlay_child(
Percentage::width(MOBILE_OVERLAY_PANEL_WIDTH_RATIO, panel_content).finish(),
OffsetPositioning::offset_from_save_position_element(
TAB_BAR_POSITION_ID,
vec2f(0., 0.),
PositionedElementOffsetBounds::WindowBySize,
PositionedElementAnchor::BottomRight,
ChildAnchor::TopRight,
),
);
}
// Conditionally render tab bar menus.
if tab_bar_mode.has_tab_bar() && self.show_tab_bar_overflow_menu {
stack.add_positioned_overlay_child(
ChildView::new(&self.tab_bar_overflow_menu).finish(),
OffsetPositioning::offset_from_save_position_element(
"tab_bar_overflow_button",
vec2f(0., 10.),
PositionedElementOffsetBounds::Unbounded,
PositionedElementAnchor::BottomRight,
ChildAnchor::TopRight,
),
);
}
if let Some((tab_idx, right_click_menu_anchor)) = self.show_tab_right_click_menu {
let is_vertical = FeatureFlag::VerticalTabs.is_enabled()
&& *TabSettings::as_ref(app).use_vertical_tabs
&& self.vertical_tabs_panel_open;
if tab_bar_mode.has_tab_bar() || is_vertical {
let positioning = match (is_vertical, right_click_menu_anchor) {
(true, TabContextMenuAnchor::VerticalTabsKebab) => {
// Anchor depends on which side the tabs panel is configured on.
let tabs_side = Self::tabs_panel_side(
&TabSettings::as_ref(app).header_toolbar_chip_selection,
);
let (anchor, child_anchor) = if tabs_side == PanelPosition::Left {
(PositionedElementAnchor::BottomLeft, ChildAnchor::TopLeft)
} else {
(PositionedElementAnchor::BottomRight, ChildAnchor::TopRight)
};
Some(OffsetPositioning::offset_from_save_position_element(
vertical_tabs::vtab_action_buttons_position_id(tab_idx),
vec2f(0., 4.),
PositionedElementOffsetBounds::WindowByPosition,
anchor,
child_anchor,
))
}
(true, TabContextMenuAnchor::Pointer(position)) => {
Some(OffsetPositioning::offset_from_parent(
position,
ParentOffsetBounds::WindowByPosition,
ParentAnchor::TopLeft,
ChildAnchor::TopLeft,
))
}
(false, TabContextMenuAnchor::Pointer(position)) => {
Some(OffsetPositioning::offset_from_parent(
position,
ParentOffsetBounds::Unbounded,
ParentAnchor::TopLeft,
ChildAnchor::TopLeft,
))
}
(false, TabContextMenuAnchor::VerticalTabsKebab) => None,
};
if let Some(positioning) = positioning {
stack.add_positioned_overlay_child(
ChildView::new(&self.tab_right_click_menu).finish(),
positioning,
);
}
}
}
if let Some(position) = self.show_header_toolbar_context_menu {
stack.add_positioned_overlay_child(
ChildView::new(&self.header_toolbar_context_menu).finish(),
OffsetPositioning::offset_from_parent(
position,
ParentOffsetBounds::WindowByPosition,
ParentAnchor::TopLeft,
ChildAnchor::TopLeft,
),
);
}
// Render the new session dropdown menu. This is outside the tab bar visibility
// gate because it can also be opened from the vertical tabs panel.
if self.show_new_session_dropdown_menu.is_some() {
let is_vertical = FeatureFlag::VerticalTabs.is_enabled()
&& *TabSettings::as_ref(app).use_vertical_tabs
&& self.vertical_tabs_panel_open;
if is_vertical {
// Anchor the menu below the vertical-tabs + button.
stack.add_positioned_overlay_child(
ChildView::new(&self.new_session_dropdown_menu).finish(),
OffsetPositioning::offset_from_save_position_element(
vertical_tabs::VERTICAL_TABS_ADD_TAB_POSITION_ID,
vec2f(0., 4.),
PositionedElementOffsetBounds::WindowBySize,
PositionedElementAnchor::BottomLeft,
ChildAnchor::TopLeft,
),
);
} else {
// TODO(CORE-2300): In the new version of the shell selector, this is not a
// context menu but a dropdown. Since it is quite wide, we need to reposition
// it so it does not render outside the bounds of the window.
let new_session_menu_position = self.show_new_session_dropdown_menu.unwrap();
let bounds = if FeatureFlag::ShellSelector.is_enabled() {
ParentOffsetBounds::WindowByPosition
} else {
ParentOffsetBounds::Unbounded
};
stack.add_positioned_overlay_child(
ChildView::new(&self.new_session_dropdown_menu).finish(),
OffsetPositioning::offset_from_parent(
new_session_menu_position,
bounds,
ParentAnchor::TopLeft,
ChildAnchor::TopLeft,
),
);
}
// Sidecar menu for submenu parents (New worktree config).
if self.show_new_session_sidecar {
let anchor_label = self.new_session_dropdown_menu.read(app, |menu, _| {
menu.hovered_index().and_then(|idx| {
menu.items().get(idx).and_then(|item| match item {
MenuItem::Item(fields) => Some(fields.label().to_string()),
_ => None,
})
})
});
if let Some(anchor_label) = anchor_label {
let sidecar_element = SavePosition::new(
ChildView::new(&self.new_session_sidecar_menu).finish(),
NEW_SESSION_SIDECAR_POSITION_ID,
)
.finish();
let render_left = self.should_render_sidecar_left(
&anchor_label,
NEW_SESSION_SIDECAR_WIDTH,
app,
);
let (offset, parent_anchor, child_anchor) = if render_left {
(
vec2f(-4., 0.),
PositionedElementAnchor::TopLeft,
ChildAnchor::TopRight,
)
} else {
(
vec2f(4., 0.),
PositionedElementAnchor::TopRight,
ChildAnchor::TopLeft,
)
};
stack.add_positioned_overlay_child(
sidecar_element,
OffsetPositioning::offset_from_save_position_element(
anchor_label,
offset,
PositionedElementOffsetBounds::WindowByPosition,
parent_anchor,
child_anchor,
),
);
}
}
// Action sidecar for actionable items (Terminal, Agent, Cloud Oz, tab configs).
if let Some(sidecar_item) = &self.tab_config_action_sidecar_item {
let anchor_label = self.new_session_dropdown_menu.read(app, |menu, _| {
menu.hovered_index().and_then(|idx| {
menu.items().get(idx).and_then(|item| match item {
MenuItem::Item(fields) => Some(fields.label().to_string()),
_ => None,
})
})
});
if let Some(anchor_label) = anchor_label {
let is_already_default = {
let ai_settings = AISettings::as_ref(app);
let current_mode = ai_settings.default_session_mode(app);
let current_path = ai_settings.default_tab_config_path();
match sidecar_item {
SidecarItemKind::BuiltIn {
default_mode,
shell,
..
} => {
current_mode == *default_mode
&& *default_mode != DefaultSessionMode::TabConfig
&& shell.is_none()
}
SidecarItemKind::UserTabConfig { config } => {
current_mode == DefaultSessionMode::TabConfig
&& config
.source_path
.as_ref()
.is_some_and(|p| p.to_string_lossy() == current_path)
}
}
};
let sidecar_content = crate::tab_configs::action_sidecar::render_action_sidecar(
sidecar_item,
&self.tab_config_action_sidecar_mouse_states,
is_already_default,
app,
);
let sidecar_element =
SavePosition::new(sidecar_content, NEW_SESSION_SIDECAR_POSITION_ID)
.finish();
let render_left = self.should_render_sidecar_left(
&anchor_label,
crate::tab_configs::action_sidecar::SIDECAR_WIDTH,
app,
);
let (offset, parent_anchor, child_anchor) = if render_left {
(
vec2f(-4., 0.),
PositionedElementAnchor::TopLeft,
ChildAnchor::TopRight,
)
} else {
(
vec2f(4., 0.),
PositionedElementAnchor::TopRight,
ChildAnchor::TopLeft,
)
};
stack.add_positioned_overlay_child(
sidecar_element,
OffsetPositioning::offset_from_save_position_element(
anchor_label,
offset,
PositionedElementOffsetBounds::WindowByPosition,
parent_anchor,
child_anchor,
),
);
}
}
}
match tab_bar_mode {
ShowTabBar::Stacked => (), // The tab bar was rendered in the content column.
ShowTabBar::Hidden => {
// Hide the tab bar, but include a hover area.
stack.add_positioned_child(
self.render_tab_bar_hover_area(),
OffsetPositioning::offset_from_parent(
Vector2F::zero(),
ParentOffsetBounds::WindowByPosition,
ParentAnchor::TopLeft,
ChildAnchor::TopLeft,
),
);
}
}
// If the tab bar is being shown in "stacked" mode, we want to render
// the traffic lights relative to the full workspace, so they appear
// in the top-right corner even if a right-side panel is open.
if tab_bar_mode == ShowTabBar::Stacked {
self.maybe_render_traffic_lights(&mut stack, app);
}
if self.current_workspace_state.is_command_search_open {
if let Some(active_input_handle) = self.get_active_input_view_handle(app) {
let input_position = app.view(&active_input_handle).save_position_id();
let menu_positioning = app.view(&self.command_search_view).menu_positioning();
// Position the CommandSearchView over the active pane's input.
let search_panel_margin = 4.;
let positioning = match menu_positioning {
MenuPositioning::AboveInputBox => {
OffsetPositioning::offset_from_save_position_element(
input_position,
vec2f(search_panel_margin, -search_panel_margin),
PositionedElementOffsetBounds::WindowBySize,
PositionedElementAnchor::BottomLeft,
ChildAnchor::BottomLeft,
)
}
MenuPositioning::BelowInputBox => {
OffsetPositioning::offset_from_save_position_element(
input_position,
vec2f(search_panel_margin, 0.),
PositionedElementOffsetBounds::WindowBySize,
PositionedElementAnchor::TopLeft,
ChildAnchor::TopLeft,
)
}
};
stack.add_positioned_child(
Container::new(ChildView::new(&self.command_search_view).finish())
.with_margin_right(search_panel_margin)
.finish(),
positioning,
);
}
}
if self.welcome_tips_view_state.is_popup_open() {
stack.add_child(ChildView::new(&self.welcome_tips_view).finish());
}
if self.current_workspace_state.is_palette_open {
stack.add_overlay_child(ChildView::new(&self.palette).finish());
}
if self.current_workspace_state.is_ctrl_tab_palette_open {
stack.add_child(ChildView::new(&self.ctrl_tab_palette).finish());
}
if self.current_workspace_state.is_require_login_modal_open {
stack.add_child(ChildView::new(&self.require_login_modal).finish());
}
if self.current_workspace_state.is_auth_override_modal_open {
stack.add_child(ChildView::new(&self.auth_override_warning_modal).finish());
}
if self.current_workspace_state.is_theme_creator_modal_open {
stack.add_child(ChildView::new(&self.theme_creator_modal).finish());
}
if self.current_workspace_state.is_import_modal_open {
stack.add_child(ChildView::new(&self.import_modal).finish());
}
if self.current_workspace_state.is_theme_deletion_modal_open {
stack.add_child(ChildView::new(&self.theme_deletion_modal).finish());
}
if self
.current_workspace_state
.is_shared_objects_creation_denied_modal_open
{
stack.add_child(ChildView::new(&self.shared_objects_creation_denied_modal).finish());
}
if self.current_workspace_state.is_reward_modal_open {
stack.add_child(Clipped::new(ChildView::new(&self.reward_modal).finish()).finish());
}
if self.launch_config_save_modal.is_open() {
stack.add_child(self.launch_config_save_modal.render());
}
if self.tab_config_params_modal.is_open() {
stack.add_child(self.tab_config_params_modal.render());
}
if self.session_config_modal.is_open() {
stack.add_child(self.session_config_modal.render());
}
if self.should_show_session_config_tab_config_chip() {
let use_vertical = FeatureFlag::VerticalTabs.is_enabled()
&& *TabSettings::as_ref(app).use_vertical_tabs
&& self.vertical_tabs_panel_open;
let chip =
self.render_session_config_tab_config_chip(use_vertical, Appearance::as_ref(app));
if use_vertical {
stack.add_positioned_overlay_child(
chip,
OffsetPositioning::offset_from_save_position_element(
vertical_tabs::VERTICAL_TABS_ADD_TAB_POSITION_ID,
vec2f(8., -20.),
PositionedElementOffsetBounds::WindowByPosition,
PositionedElementAnchor::MiddleRight,
ChildAnchor::TopLeft,
),
);
} else {
let anchor_id = if FeatureFlag::ShellSelector.is_enabled() {
NEW_SESSION_MENU_BUTTON_POSITION_ID
} else {
NEW_TAB_BUTTON_POSITION_ID
};
stack.add_positioned_overlay_child(
chip,
OffsetPositioning::offset_from_save_position_element(
anchor_id,
vec2f(0., 8.),
PositionedElementOffsetBounds::WindowByPosition,
PositionedElementAnchor::BottomMiddle,
ChildAnchor::TopMiddle,
),
);
}
}
if self.new_worktree_modal.is_open() {
stack.add_child(self.new_worktree_modal.render());
}
if self.workflow_modal.as_ref(app).is_open() {
stack.add_child(ChildView::new(&self.workflow_modal).finish());
}
if self.current_workspace_state.is_prompt_editor_open {
stack.add_child(ChildView::new(&self.prompt_editor_modal).finish());
}
if FeatureFlag::AgentToolbarEditor.is_enabled()
&& self.current_workspace_state.is_agent_toolbar_editor_open
{
stack.add_child(ChildView::new(&self.agent_toolbar_editor_modal).finish());
}
if self.current_workspace_state.is_header_toolbar_editor_open {
stack.add_child(ChildView::new(&self.header_toolbar_editor_modal).finish());
}
if self
.current_workspace_state
.is_suggested_agent_mode_workflow_modal_open
{
stack.add_child(ChildView::new(&self.suggested_agent_mode_workflow_modal).finish());
}
if self.current_workspace_state.is_suggested_rule_modal_open {
stack.add_child(ChildView::new(&self.suggested_rule_modal).finish());
}
let one_time_modal_model = OneTimeModalModel::as_ref(app);
let should_show_modal = one_time_modal_model.target_window_id() == Some(self.window_id);
if should_show_modal && one_time_modal_model.is_oz_launch_modal_open() {
stack.add_child(ChildView::new(&self.oz_launch_modal.view).finish());
}
if should_show_modal && one_time_modal_model.is_openwarp_launch_modal_open() {
stack.add_child(ChildView::new(&self.openwarp_launch_modal).finish());
}
if let Some(hoa_flow) = &self.hoa_onboarding_flow {
let step = hoa_flow.as_ref(app).step();
// Block all mouse events from reaching the workspace underneath.
// The onboarding flow elements are rendered on top and receive events normally.
stack.add_child(
Dismiss::new(Empty::new().finish())
.prevent_interaction_with_other_elements()
.finish(),
);
match step {
HoaOnboardingStep::WelcomeBanner => {
stack.add_child(ChildView::new(hoa_flow).finish());
}
HoaOnboardingStep::VerticalTabsCallout => {
if let Some(pinned) = self.hoa_vtabs_callout_pinned_position {
let use_vertical = *TabSettings::as_ref(app).use_vertical_tabs;
// The pinned position is the bubble body's top-left when
// using a Left arrow. With Left arrow, the element origin
// is at (0, 0) and the body starts at (~21, 0) due to the
// arrow width. With Up arrow, the body starts at (0, ~21).
// To keep the body in the same window position:
// - Left arrow: element origin = pinned (arrow is to the left)
// - Up arrow: shift left by ~21 (no arrow width) and up by ~21 (arrow height)
let offset = if use_vertical {
pinned
} else {
// Left arrow: body at (21, 0) from origin.
// Up arrow: body at (0, 21) from origin.
// To keep body fixed: origin.x += 21, origin.y -= 21.
vec2f(pinned.x() + 21., pinned.y() - 21.)
};
stack.add_positioned_child(
ChildView::new(hoa_flow).finish(),
OffsetPositioning::offset_from_parent(
offset,
ParentOffsetBounds::WindowByPosition,
ParentAnchor::TopLeft,
ChildAnchor::TopLeft,
),
);
} else {
stack.add_positioned_child(
ChildView::new(hoa_flow).finish(),
OffsetPositioning::offset_from_save_position_element(
VERTICAL_TABS_PANEL_POSITION_ID,
vec2f(0., 8.),
PositionedElementOffsetBounds::WindowByPosition,
PositionedElementAnchor::TopRight,
ChildAnchor::TopLeft,
),
);
}
}
HoaOnboardingStep::AgentInboxCallout => {
// Up arrow with End(24.) = arrow center ~36px from right edge.
// The save position wraps the icon + left margin. The icon is
// ~14px wide near the right edge. Shift right by ~22px so the
// arrow center lands on the icon center.
stack.add_positioned_child(
ChildView::new(hoa_flow).finish(),
OffsetPositioning::offset_from_save_position_element(
NOTIFICATIONS_MAILBOX_POSITION_ID,
vec2f(24., 4.),
PositionedElementOffsetBounds::WindowByPosition,
PositionedElementAnchor::BottomRight,
ChildAnchor::TopRight,
),
);
}
HoaOnboardingStep::TabConfig => {
let use_vertical = *TabSettings::as_ref(app).use_vertical_tabs;
if use_vertical {
// Left arrow: anchor to the vertical tabs panel.
if let Some(pinned) = self.hoa_vtabs_callout_pinned_position {
stack.add_positioned_child(
ChildView::new(hoa_flow).finish(),
OffsetPositioning::offset_from_parent(
pinned,
ParentOffsetBounds::WindowByPosition,
ParentAnchor::TopLeft,
ChildAnchor::TopLeft,
),
);
} else {
stack.add_positioned_child(
ChildView::new(hoa_flow).finish(),
OffsetPositioning::offset_from_save_position_element(
VERTICAL_TABS_PANEL_POSITION_ID,
vec2f(0., 8.),
PositionedElementOffsetBounds::WindowByPosition,
PositionedElementAnchor::TopRight,
ChildAnchor::TopLeft,
),
);
}
} else {
// Up arrow centered: anchor the callout's top-center
// to the + button's bottom-center so the arrow points
// at the button.
stack.add_positioned_child(
ChildView::new(hoa_flow).finish(),
OffsetPositioning::offset_from_save_position_element(
NEW_SESSION_MENU_BUTTON_POSITION_ID,
vec2f(0., 8.),
PositionedElementOffsetBounds::WindowByPosition,
PositionedElementAnchor::BottomMiddle,
ChildAnchor::TopMiddle,
),
);
}
}
}
}
if self
.current_workspace_state
.is_enable_auto_reload_modal_open
{
stack.add_child(ChildView::new(&self.enable_auto_reload_modal).finish());
}
if should_show_modal && one_time_modal_model.is_build_plan_migration_modal_open() {
stack.add_child(ChildView::new(&self.build_plan_migration_modal).finish());
}
if self.current_workspace_state.is_codex_modal_open {
stack.add_child(ChildView::new(&self.codex_modal).finish());
}
if FeatureFlag::CloudMode.is_enabled()
&& self
.current_workspace_state
.is_cloud_agent_capacity_modal_open
{
stack.add_child(ChildView::new(&self.cloud_agent_capacity_modal).finish());
}
if self
.current_workspace_state
.is_free_tier_limit_hit_modal_open
{
stack.add_child(ChildView::new(&self.free_tier_limit_hit_modal).finish());
}
if let Some(lightbox_view) = &self.lightbox_view {
stack.add_child(ChildView::new(lightbox_view).finish());
}
if FeatureFlag::CreatingSharedSessions.is_enabled()
&& ContextFlag::CreateSharedSession.is_enabled()
&& self
.current_workspace_state
.is_close_session_confirmation_dialog_open
{
stack.add_positioned_overlay_child(
ChildView::new(&self.close_session_confirmation_dialog).finish(),
OffsetPositioning::offset_from_parent(
Vector2F::zero(),
ParentOffsetBounds::WindowByPosition,
ParentAnchor::Center,
ChildAnchor::Center,
),
);
}
if self
.current_workspace_state
.is_rewind_confirmation_dialog_open
{
stack.add_positioned_overlay_child(
ChildView::new(&self.rewind_confirmation_dialog).finish(),
OffsetPositioning::offset_from_parent(
Vector2F::zero(),
ParentOffsetBounds::WindowByPosition,
ParentAnchor::Center,
ChildAnchor::Center,
),
);
}
if self
.current_workspace_state
.is_delete_conversation_confirmation_dialog_open
{
stack.add_positioned_overlay_child(
ChildView::new(&self.delete_conversation_confirmation_dialog).finish(),
OffsetPositioning::offset_from_parent(
Vector2F::zero(),
ParentOffsetBounds::WindowByPosition,
ParentAnchor::Center,
ChildAnchor::Center,
),
);
}
if self.current_workspace_state.is_native_quit_modal_open {
stack.add_positioned_overlay_child(
ChildView::new(&self.native_modal).finish(),
OffsetPositioning::offset_from_parent(
Vector2F::zero(),
ParentOffsetBounds::WindowByPosition,
ParentAnchor::Center,
ChildAnchor::Center,
),
);
}
if self
.current_workspace_state
.is_remove_tab_config_dialog_open
{
stack.add_positioned_overlay_child(
ChildView::new(&self.remove_tab_config_confirmation_dialog).finish(),
OffsetPositioning::offset_from_parent(
Vector2F::zero(),
ParentOffsetBounds::WindowByPosition,
ParentAnchor::Center,
ChildAnchor::Center,
),
);
}
if FeatureFlag::AvatarInTabBar.is_enabled() && self.is_user_menu_open {
stack.add_positioned_overlay_child(
ChildView::new(&self.user_menu).finish(),
OffsetPositioning::offset_from_save_position_element(
USER_AVATAR_BUTTON_POSITION_ID,
Vector2F::zero(),
PositionedElementOffsetBounds::WindowByPosition,
PositionedElementAnchor::BottomRight,
ChildAnchor::TopRight,
),
);
}
if self.current_workspace_state.is_notification_mailbox_open {
if let Some(view) = &self.notification_mailbox_view {
let mailbox_on_left = Self::is_mailbox_on_left(
&TabSettings::as_ref(app).header_toolbar_chip_selection,
);
let (anchor, child_anchor) = if mailbox_on_left {
(PositionedElementAnchor::BottomLeft, ChildAnchor::TopLeft)
} else {
(PositionedElementAnchor::BottomRight, ChildAnchor::TopRight)
};
stack.add_positioned_overlay_child(
ChildView::new(view).finish(),
OffsetPositioning::offset_from_save_position_element(
NOTIFICATIONS_MAILBOX_POSITION_ID,
Vector2F::zero(),
PositionedElementOffsetBounds::WindowByPosition,
anchor,
child_anchor,
),
);
}
}
if !FeatureFlag::AgentMode.is_enabled()
&& AISettings::as_ref(app).is_any_ai_enabled(app)
&& self.should_show_ai_assistant_warm_welcome
&& !self.current_workspace_state.is_changelog_modal_open
&& !self.current_workspace_state.is_resource_center_open
&& tab_bar_mode.has_tab_bar()
{
stack.add_positioned_child(
self.render_ai_assistant_warm_welcome(appearance),
OffsetPositioning::offset_from_save_position_element(
AI_ASSISTANT_BUTTON_ID,
vec2f(0., 10.),
PositionedElementOffsetBounds::Unbounded,
PositionedElementAnchor::BottomRight,
ChildAnchor::TopRight,
),
);
}
let window_corner_radius = app.windows().window_corner_radius();
let workspace = Container::new(stack.finish()).with_corner_radius(window_corner_radius);
let mut stack = Stack::new();
let theme = appearance.theme();
let window_settings = WindowSettings::as_ref(app);
let background_opacity = window_settings
.background_opacity
.effective_opacity(self.window_id, app);
if let Some(img) = theme.background_image() {
let opacity_ratio = background_opacity as f32 / 100.;
stack.add_child(
Shrinkable::new(
1.,
Image::new(img.source(), CacheOption::Original)
.cover()
.with_opacity(opacity_ratio)
.with_corner_radius(window_corner_radius)
.finish(),
)
.finish(),
);
stack.add_child(workspace.finish());
} else {
stack.add_child(
workspace
.with_background(theme.surface_2().with_opacity(background_opacity))
.finish(),
);
}
let input_position_id = self
.get_active_input_view_handle(app)
.map(|input| app.view(&input).save_position_id());
stack.add_positioned_overlay_child(
ChildView::new(&self.toast_stack).finish(),
self.global_toast_positioning(),
);
// Render agent toast stack (for agent-related notifications) if popup is not open
if FeatureFlag::HOANotifications.is_enabled()
&& *AISettings::as_ref(app).show_agent_notifications
{
if !self.current_workspace_state.is_notification_mailbox_open {
if let Some(stack_view) = &self.notification_toast_stack {
let mailbox_on_left = Self::is_mailbox_on_left(
&TabSettings::as_ref(app).header_toolbar_chip_selection,
);
let (anchor, child_anchor, offset_x) = if mailbox_on_left {
(
PositionedElementAnchor::BottomLeft,
ChildAnchor::TopLeft,
WORKSPACE_PADDING,
)
} else {
(
PositionedElementAnchor::BottomRight,
ChildAnchor::TopRight,
-WORKSPACE_PADDING,
)
};
stack.add_positioned_overlay_child(
ChildView::new(stack_view).finish(),
OffsetPositioning::offset_from_save_position_element(
TAB_BAR_POSITION_ID,
vec2f(offset_x, 4.),
PositionedElementOffsetBounds::WindowByPosition,
anchor,
child_anchor,
),
);
}
}
} else if !self.current_workspace_state.is_agent_management_popup_open {
stack.add_positioned_overlay_child(
ChildView::new(&self.agent_toast_stack).finish(),
self.agent_toast_positioning(),
);
}
if let Some(input_position_id) = input_position_id {
if FeatureFlag::AvatarInTabBar.is_enabled() && self.is_input_box_visible(app) {
stack.add_positioned_overlay_child(
ChildView::new(&self.update_toast_stack).finish(),
self.update_toast_positioning(input_position_id, app),
);
}
}
#[cfg(target_family = "wasm")]
if self.show_wasm_nux_dialog {
stack.add_positioned_overlay_child(
ChildView::new(&self.wasm_nux_dialog).finish(),
OffsetPositioning::offset_from_parent(
vec2f(-10., 67.),
ParentOffsetBounds::WindowByPosition,
ParentAnchor::TopRight,
ChildAnchor::TopRight,
),
);
}
// Add workspace-wide UI event handling.
let stack = if FeatureFlag::VerticalTabs.is_enabled()
&& *TabSettings::as_ref(app).use_vertical_tabs
&& self.vertical_tabs_panel_open
// The vertical-tabs detail sidecar can become stale if the pointer moves through a
// covered region (for example, its scrollbar gutter) and the row/sidecar hoverables
// do not observe the expected hover-out transition. Install a workspace-root
// mouse-move observer only while a detail sidecar is active so we can clear that
// stale visibility without paying this cost during ordinary vertical-tabs usage.
&& self.vertical_tabs_panel.has_active_detail_target()
{
// The workspace root uses this handle bundle to compare the live mouse position
// against the source row rect, sidecar rect, and safe triangle, then hide the
// sidecar when the pointer has genuinely left all valid keep-open regions.
let detail_hover_state = self.vertical_tabs_panel.detail_hover_state(self.window_id);
EventHandler::new(stack.finish())
.with_always_handle()
.on_mouse_in(
move |ctx, app, position| {
if detail_hover_state.reconcile_visibility_for_mouse_position(position, app)
{
ctx.notify();
}
DispatchEventResult::PropagateToParent
},
Some(MouseInBehavior {
fire_on_synthetic_events: false,
fire_when_covered: true,
}),
)
.finish()
} else {
stack.finish()
};
#[cfg_attr(not(any(windows, target_os = "linux")), allow(unused_mut))]
let mut event_handler = EventHandler::new(stack);
#[cfg(any(windows, target_os = "linux"))]
{
event_handler =
event_handler.on_scroll_wheel(move |ctx, _app, delta, modifiers_state| {
if !modifiers_state.ctrl {
return DispatchEventResult::PropagateToParent;
}
// If the control key is being held, scrolling should scale the zoom level or font size
if FeatureFlag::UIZoom.is_enabled() {
if delta.y() > 0.0 {
ctx.dispatch_typed_action(WorkspaceAction::IncreaseZoom);
} else if delta.y() < 0.0 {
ctx.dispatch_typed_action(WorkspaceAction::DecreaseZoom);
}
} else if delta.y() > 0.0 {
ctx.dispatch_typed_action(WorkspaceAction::IncreaseFontSize);
} else if delta.y() < 0.0 {
ctx.dispatch_typed_action(WorkspaceAction::DecreaseFontSize);
}
DispatchEventResult::StopPropagation
});
}
event_handler.finish()
}
fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext<Self>) {
if focus_ctx.is_self_focused() {
self.focus_active_tab(ctx);
}
}
/// Update this workspace when it has been closed, but may still be restored.
fn on_window_closed(&mut self, ctx: &mut ViewContext<Self>) {
for pane_group in self.tab_views() {
pane_group.update(ctx, |pane_group, ctx| {
pane_group.detach_panes(ctx);
});
}
let window_id = ctx.window_id();
WorkspaceRegistry::handle(ctx).update(ctx, |registry, _| {
registry.unregister(window_id);
});
ActiveSession::handle(ctx).update(ctx, |active_session, _| {
active_session.close_workspace(window_id);
})
}
}
fn compute_default_panel_widths(
app: &AppContext,
window_id: WindowId,
has_horizontal_split: bool,
) -> (f32, f32) {
if let Some(bounds) = app.window_bounds(&window_id) {
let window_width = bounds.width();
let left_ratio = 0.15;
let right_ratio = if has_horizontal_split { 0.3 } else { 0.5 };
let left = window_width * left_ratio;
let right = window_width * right_ratio;
(left, right)
} else {
(DEFAULT_LEFT_PANEL_WIDTH, DEFAULT_RIGHT_PANEL_WIDTH)
}
}
/// Idempotently sets the opencode-warp plugin entry in `~/.config/opencode/opencode.json`.
/// Removes any existing opencode-warp plugin entries (both local file:// and github:) and adds
/// the given `new_entry`. Creates the config file with a default structure if it doesn't exist.
#[cfg(debug_assertions)]
fn set_opencode_warp_plugin(new_entry: &str) -> String {
let Some(home) = dirs::home_dir() else {
return "Failed to determine home directory".to_string();
};
let config_dir = home.join(".config/opencode");
let config_path = config_dir.join("opencode.json");
let mut config: serde_json::Value = if config_path.exists() {
match std::fs::read_to_string(&config_path) {
Ok(contents) => match serde_json::from_str(&contents) {
Ok(val) => val,
Err(e) => return format!("Failed to parse opencode.json: {e}"),
},
Err(e) => return format!("Failed to read opencode.json: {e}"),
}
} else {
serde_json::json!({
"$schema": "https://opencode.ai/config.json"
})
};
let plugins = config.as_object_mut().and_then(|obj| {
obj.entry("plugin")
.or_insert_with(|| serde_json::json!([]))
.as_array_mut()
});
let Some(plugins) = plugins else {
return "opencode.json has unexpected structure (plugin is not an array)".to_string();
};
// Remove any existing opencode-warp entries
plugins.retain(|entry| {
let s = entry.as_str().unwrap_or("");
!s.contains("opencode-warp")
});
plugins.push(serde_json::Value::String(new_entry.to_string()));
if let Err(e) = std::fs::create_dir_all(&config_dir) {
return format!("Failed to create config directory: {e}");
}
match serde_json::to_string_pretty(&config) {
Ok(json_str) => match std::fs::write(&config_path, format!("{json_str}\n")) {
Ok(()) => format!("OpenCode plugin set to: {new_entry}"),
Err(e) => format!("Failed to write opencode.json: {e}"),
},
Err(e) => format!("Failed to serialize opencode.json: {e}"),
}
}