Bring over selected Warp UI improvements: selectable Thought text, message timestamps, animated theme backgrounds, editable New Window shortcuts, safer tab renaming and restoration, and text/font/discard crash fixes. Match upstream orchestration pill geometry and tooltips, and replace expanded subagent previews with compact links that retain live status and conversation navigation for launches and follow-up messages. Include the pending Cargo.lock dependency refresh. Adapted upstream PRs: #15948, #15884, #15691, #14618, #15772, #15705, #15605, #14895, #15996, #15771, #14893, and #14603.
4314 lines
160 KiB
Rust
4314 lines
160 KiB
Rust
use std::collections::HashMap;
|
|
|
|
use ai::index::full_source_code_embedding::manager::CodebaseIndexManager;
|
|
use ai::project_context::model::ProjectContextModel;
|
|
#[cfg(feature = "local_fs")]
|
|
use galaxy_files::FileModel;
|
|
use pane_group::{NotebookPane, PaneState, SplitPaneState, TerminalPaneId};
|
|
use repo_metadata::repositories::DetectedRepositories;
|
|
use repo_metadata::watcher::DirectoryWatcher;
|
|
#[cfg(feature = "local_fs")]
|
|
use repo_metadata::CanonicalizedPath;
|
|
#[cfg(feature = "local_fs")]
|
|
use repo_metadata::RepoMetadataModel;
|
|
use session_sharing_protocol::common::SessionId;
|
|
#[cfg(feature = "local_fs")]
|
|
use tempfile::TempDir;
|
|
use terminal::shared_session::permissions_manager::SessionPermissionsManager;
|
|
use terminal::view::ActiveSessionState;
|
|
use warp_editor::editor::NavigationKey;
|
|
use warpui::platform::WindowStyle;
|
|
use warpui::{AddSingletonModel, App, ViewHandle};
|
|
use watcher::HomeDirectoryWatcher;
|
|
|
|
use super::*;
|
|
use crate::ai::active_agent_views_model::ActiveAgentViewsModel;
|
|
use crate::ai::agent_conversations_model::AgentConversationsModel;
|
|
use crate::ai::agent_tips::AITipModel;
|
|
use crate::ai::ambient_agents::github_auth_notifier::GitHubAuthNotifier;
|
|
use crate::ai::blocklist::agent_view::orchestration_pill_bar_model::OrchestrationPillBarModel;
|
|
use crate::ai::blocklist::{BlocklistAIHistoryModel, BlocklistAIPermissions};
|
|
#[cfg(not(target_family = "wasm"))]
|
|
use crate::ai::chatgpt_auth::ChatGPTAuthModel;
|
|
use crate::ai::document::ai_document_model::AIDocumentModel;
|
|
use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel;
|
|
use crate::ai::facts::manager::AIFactManager;
|
|
use crate::ai::harness_availability::HarnessAvailabilityModel;
|
|
use crate::ai::llms::LLMPreferences;
|
|
use crate::ai::mcp::gallery::MCPGalleryManager;
|
|
use crate::ai::mcp::templatable_manager::TemplatableMCPServerManager;
|
|
use crate::ai::mcp::{FileBasedMCPManager, FileMCPWatcher};
|
|
use crate::ai::outline::RepoOutlines;
|
|
use crate::ai::persisted_workspace::PersistedWorkspace;
|
|
use crate::ai::restored_conversations::RestoredAgentConversations;
|
|
use crate::ai::skills::SkillManager;
|
|
use crate::ai::AIRequestUsageModel;
|
|
use crate::cloud_object::model::persistence::CloudModel;
|
|
use crate::cloud_object::model::view::CloudViewModel;
|
|
use crate::context_chips::prompt::Prompt;
|
|
use crate::editor::Event;
|
|
use crate::gpu_state::GPUState;
|
|
use crate::network::NetworkStatus;
|
|
use crate::notebooks::editor::keys::NotebookKeybindings;
|
|
use crate::notebooks::notebook::NotebookView;
|
|
use crate::pane_group::{Direction, PaneGroupAction, PaneId};
|
|
use crate::pricing::PricingInfoModel;
|
|
#[cfg(not(target_family = "wasm"))]
|
|
use crate::remote_server::codebase_index_model::RemoteCodebaseIndexModel;
|
|
use crate::resource_center::Tip;
|
|
use crate::server::cloud_objects::listener::Listener;
|
|
use crate::server::cloud_objects::update_manager::UpdateManager;
|
|
use crate::server::experiments::ServerExperiments;
|
|
use crate::server::server_api::ServerApiProvider;
|
|
use crate::server::sync_queue::SyncQueue;
|
|
use crate::server::telemetry::context_provider::AppTelemetryContextProvider;
|
|
use crate::settings::cloud_preferences_syncer::CloudPreferencesSyncer;
|
|
use crate::settings::PrivacySettings;
|
|
use crate::settings_view::keybindings::KeybindingChangedNotifier;
|
|
use crate::settings_view::DisplayCount;
|
|
use crate::suggestions::ignored_suggestions_model::IgnoredSuggestionsModel;
|
|
use crate::system::SystemStats;
|
|
use crate::tab_configs::tab_config::{TabConfigPaneNode, TabConfigPaneType};
|
|
use crate::terminal::cli_agent_sessions::CLIAgentSessionsModel;
|
|
use crate::terminal::history::History;
|
|
use crate::terminal::keys::TerminalKeybindings;
|
|
use crate::terminal::local_tty::spawner::PtySpawner;
|
|
use crate::terminal::shared_session::{
|
|
SharedSessionScrollbackType, SharedSessionSource, SharedSessionStatus,
|
|
};
|
|
use crate::test_util::settings::initialize_settings_for_tests;
|
|
use crate::undo_close::UndoCloseSettings;
|
|
#[cfg(feature = "local_fs")]
|
|
use crate::user_config::tab_configs_dir;
|
|
#[cfg(windows)]
|
|
use crate::util::traffic_lights::windows::RendererState;
|
|
use crate::warp_managed_paths_watcher::WarpManagedPathsWatcher;
|
|
use crate::workflows::local_workflows::LocalWorkflows;
|
|
use crate::workspaces::team_tester::TeamTesterStatus;
|
|
use crate::workspaces::update_manager::TeamUpdateManager;
|
|
use crate::workspaces::user_profiles::UserProfiles;
|
|
use crate::workspaces::user_workspaces::UserWorkspaces;
|
|
use crate::{
|
|
experiments, workspace, AgentNotificationsModel, GlobalResourceHandlesProvider, ObjectActions,
|
|
};
|
|
#[test]
|
|
fn query_for_rewind_prefill_uses_custom_display_query_inputs() {
|
|
let context: std::sync::Arc<[crate::ai::agent::AIAgentContext]> = Vec::new().into();
|
|
let input = crate::ai::agent::AIAgentInput::FetchReviewComments {
|
|
repo_path: "/repo".to_string(),
|
|
context,
|
|
};
|
|
|
|
assert_eq!(
|
|
query_for_rewind_prefill(&[input]),
|
|
Some(
|
|
crate::search::slash_command_menu::static_commands::commands::PR_COMMENTS
|
|
.name
|
|
.to_string()
|
|
)
|
|
);
|
|
}
|
|
|
|
pub(crate) fn initialize_app(app: &mut App) {
|
|
initialize_settings_for_tests(app);
|
|
|
|
// Add the necessary singleton models to the App
|
|
app.add_singleton_model(|_ctx| ServerApiProvider::new_for_test());
|
|
app.add_singleton_model(|_| AuthStateProvider::new_for_test());
|
|
app.add_singleton_model(AppTelemetryContextProvider::new_context_provider);
|
|
app.add_singleton_model(AuthManager::new_for_test);
|
|
app.add_singleton_model(|_ctx| PtySpawner::new_for_test());
|
|
app.add_singleton_model(|_| Prompt::mock());
|
|
app.add_singleton_model(|ctx| AutoupdateState::new(ServerApiProvider::as_ref(ctx).get()));
|
|
app.add_singleton_model(|_| NetworkStatus::new());
|
|
app.add_singleton_model(|_| SystemStats::new());
|
|
app.add_singleton_model(SyncQueue::mock);
|
|
app.add_singleton_model(CloudModel::mock);
|
|
app.add_singleton_model(|ctx| {
|
|
crate::local_object_repository::LocalObjectRepository::new(None, None, ctx)
|
|
});
|
|
app.add_singleton_model(UserWorkspaces::default_mock);
|
|
app.add_singleton_model(|_ctx| UserProfiles::new(Vec::new()));
|
|
app.add_singleton_model(TeamTesterStatus::mock);
|
|
app.add_singleton_model(TeamUpdateManager::mock);
|
|
app.add_singleton_model(UpdateManager::mock);
|
|
app.add_singleton_model(MCPGalleryManager::new);
|
|
app.add_singleton_model(CloudViewModel::mock);
|
|
app.add_singleton_model(Listener::mock);
|
|
app.add_singleton_model(|_| Appearance::mock());
|
|
app.add_singleton_model(AppearanceManager::new);
|
|
app.add_singleton_model(|_| DisplayCount::mock());
|
|
app.add_singleton_model(PrivacySettings::mock);
|
|
app.add_singleton_model(|_| KeybindingChangedNotifier::new());
|
|
app.add_singleton_model(|_ctx| RelaunchModel::new());
|
|
app.add_singleton_model(|ctx| ChangelogModel::new(ServerApiProvider::as_ref(ctx).get()));
|
|
app.add_singleton_model(|_| GitHubAuthNotifier::new());
|
|
app.add_singleton_model(|_ctx| SyncedInputState::mock());
|
|
app.add_singleton_model(|_| ResizableData::default());
|
|
app.add_singleton_model(LocalWorkflows::new);
|
|
app.add_singleton_model(UndoCloseStack::new);
|
|
app.add_singleton_model(terminal::shared_session::manager::Manager::new);
|
|
app.add_singleton_model(|_| ActiveSession::default());
|
|
app.add_singleton_model(|_| WorkspaceToastStack);
|
|
app.add_singleton_model(|_| ObjectActions::new(Vec::new()));
|
|
app.add_singleton_model(NotebookKeybindings::new);
|
|
app.add_singleton_model(TerminalKeybindings::new);
|
|
app.add_singleton_model(NotebookManager::mock);
|
|
app.add_singleton_model(|ctx| {
|
|
CloudPreferencesSyncer::new(
|
|
false, // force_local_wins_on_startup
|
|
std::path::PathBuf::new(), // unused in tests that don't exercise the hash path
|
|
ctx,
|
|
)
|
|
});
|
|
app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
|
|
// QueuedQueryModel subscribes to history events; register after the
|
|
// history model is in place.
|
|
app.add_singleton_model(crate::ai::blocklist::QueuedQueryModel::new);
|
|
app.add_singleton_model(|ctx| OrchestrationPillBarModel::new(Default::default(), ctx));
|
|
app.add_singleton_model(|_| CLIAgentSessionsModel::new());
|
|
// The blocklist controller created during terminal bootstrap subscribes to
|
|
// OrchestrationEventService and OrchestrationEventStreamer unconditionally,
|
|
// so both singletons must be registered before bootstrap.
|
|
app.add_singleton_model(
|
|
crate::ai::blocklist::orchestration_events::OrchestrationEventService::new,
|
|
);
|
|
app.add_singleton_model(
|
|
crate::ai::blocklist::orchestration_event_streamer::OrchestrationEventStreamer::new,
|
|
);
|
|
app.add_singleton_model(|_| ActiveAgentViewsModel::new());
|
|
app.add_singleton_model(AgentNotificationsModel::new);
|
|
app.add_singleton_model(AgentConversationsModel::new);
|
|
app.add_singleton_model(SessionPermissionsManager::new);
|
|
#[cfg(not(target_family = "wasm"))]
|
|
app.add_singleton_model(|_| ChatGPTAuthModel::new_for_test());
|
|
app.add_singleton_model(LLMPreferences::new);
|
|
app.add_singleton_model(HarnessAvailabilityModel::new);
|
|
app.add_singleton_model(|ctx| AITipModel::new_for_agent_tips(ctx));
|
|
app.add_singleton_model(|_| SettingsPaneManager::new());
|
|
app.add_singleton_model(|_| AIFactManager::new());
|
|
|
|
// Initialize file-based MCP dependencies.
|
|
app.add_singleton_model(|_| DetectedRepositories::default());
|
|
app.add_singleton_model(HomeDirectoryWatcher::new_for_test);
|
|
app.add_singleton_model(DirectoryWatcher::new);
|
|
app.add_singleton_model(WarpManagedPathsWatcher::new_for_testing);
|
|
app.add_singleton_model(FileMCPWatcher::new);
|
|
app.add_singleton_model(|_| FileBasedMCPManager::default());
|
|
|
|
app.add_singleton_model(|_| TemplatableMCPServerManager::default());
|
|
#[cfg(feature = "local_fs")]
|
|
app.add_singleton_model(FileModel::new);
|
|
app.add_singleton_model(|ctx| {
|
|
AIExecutionProfilesModel::new(&crate::LaunchMode::new_for_unit_test(), ctx)
|
|
});
|
|
app.add_singleton_model(RepoOutlines::new_for_test);
|
|
#[cfg(feature = "voice_input")]
|
|
app.add_singleton_model(voice_input::VoiceInput::new);
|
|
app.add_singleton_model(BlocklistAIPermissions::new);
|
|
app.add_singleton_model(|_| GPUState::new());
|
|
// Register IapManager in a disabled state (no IapState). The settings
|
|
// page's `IapManager::as_ref(ctx).is_enabled()` check panics if the
|
|
// singleton isn't registered, even though it's a no-op on production.
|
|
app.add_singleton_model(|ctx| {
|
|
warp_server_client::iap::IapManager::new(
|
|
None,
|
|
Box::new(|_| futures::FutureExt::boxed(futures::future::ready(None::<String>))),
|
|
ctx,
|
|
)
|
|
});
|
|
app.add_singleton_model(|_| RestoredAgentConversations::new(vec![]));
|
|
app.add_singleton_model(|ctx| {
|
|
AIRequestUsageModel::new_for_test(ServerApiProvider::as_ref(ctx).get_ai_client(), ctx)
|
|
});
|
|
app.add_singleton_model(OneTimeModalModel::new);
|
|
// Register GlobalResourceHandlesProvider before ServerExperiments which depends on it
|
|
let global_resource_handles = GlobalResourceHandles::mock(app);
|
|
app.add_singleton_model(|_| GlobalResourceHandlesProvider::new(global_resource_handles));
|
|
app.add_singleton_model(|ctx| ServerExperiments::new_from_cache(vec![], ctx));
|
|
app.add_singleton_model(DefaultTerminal::new);
|
|
app.add_singleton_model(|_| IgnoredSuggestionsModel::new(vec![]));
|
|
app.add_singleton_model(|_| crate::code_review::git_repo_model::GitRepoModels::new());
|
|
app.add_singleton_model(remote_server::manager::RemoteServerManager::new);
|
|
#[cfg(not(target_family = "wasm"))]
|
|
app.add_singleton_model(RemoteCodebaseIndexModel::new);
|
|
|
|
#[cfg(feature = "local_fs")]
|
|
app.add_singleton_model(RepoMetadataModel::new);
|
|
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
|
|
app.add_singleton_model(ai::index::local_project_index::LocalProjectIndexManager::new);
|
|
app.add_singleton_model(search::files::model::FileSearchModel::new);
|
|
|
|
#[cfg(windows)]
|
|
{
|
|
app.add_singleton_model(RendererState::new);
|
|
}
|
|
|
|
#[cfg(feature = "local_tty")]
|
|
terminal::available_shells::register(app);
|
|
AltScreenReporting::register(app);
|
|
|
|
#[cfg(enable_crash_recovery)]
|
|
crate::crash_recovery::CrashRecovery::register_for_test(app);
|
|
|
|
app.update(experiments::init);
|
|
|
|
app.add_singleton_model(
|
|
crate::workspace::bonus_grant_notification_model::BonusGrantNotificationModel::new,
|
|
);
|
|
app.add_singleton_model(|ctx| {
|
|
CodebaseIndexManager::new_for_test(ServerApiProvider::as_ref(ctx).get(), ctx)
|
|
});
|
|
// PersistedWorkspace subscribes to project-context updates during construction.
|
|
app.add_singleton_model(|_| ProjectContextModel::default());
|
|
app.add_singleton_model(|ctx| PersistedWorkspace::new(vec![], HashMap::new(), None, ctx));
|
|
app.add_singleton_model(|_| PricingInfoModel::new());
|
|
app.add_singleton_model(AIDocumentModel::new);
|
|
app.add_singleton_model(|_| History::new(vec![]));
|
|
|
|
// SkillManager is registered after `HomeDirectoryWatcher`, `DirectoryWatcher`,
|
|
// `WarpManagedPathsWatcher`, `DetectedRepositories`, and `RepoMetadataModel`
|
|
// because `SkillWatcher::new` subscribes to all of them.
|
|
app.add_singleton_model(SkillManager::new);
|
|
|
|
// Make sure to initialize the keybindings so that they are available for subviews
|
|
app.update(workspace::init);
|
|
}
|
|
|
|
pub(crate) fn mock_workspace(app: &mut App) -> ViewHandle<Workspace> {
|
|
let global_resource_handles = GlobalResourceHandles::mock(app);
|
|
let active_window_id = app.read(|ctx| ctx.windows().active_window());
|
|
let (_, workspace) = app.add_window(WindowStyle::NotStealFocus, |ctx| {
|
|
Workspace::new(
|
|
global_resource_handles,
|
|
None,
|
|
NewWorkspaceSource::Empty {
|
|
previous_active_window: active_window_id,
|
|
shell: None,
|
|
},
|
|
ctx,
|
|
)
|
|
});
|
|
workspace
|
|
}
|
|
|
|
fn restored_workspace(
|
|
app: &mut App,
|
|
window_snapshot: crate::app_state::WindowSnapshot,
|
|
) -> ViewHandle<Workspace> {
|
|
let global_resource_handles = GlobalResourceHandles::mock(app);
|
|
let (_, workspace) = app.add_window(WindowStyle::NotStealFocus, |ctx| {
|
|
Workspace::new(
|
|
global_resource_handles,
|
|
None,
|
|
NewWorkspaceSource::Restored {
|
|
window_snapshot,
|
|
block_lists: Arc::new(HashMap::new()),
|
|
},
|
|
ctx,
|
|
)
|
|
});
|
|
workspace
|
|
}
|
|
|
|
fn transferred_tab_workspace(
|
|
app: &mut App,
|
|
vertical_tabs_panel_open: bool,
|
|
) -> ViewHandle<Workspace> {
|
|
let global_resource_handles = GlobalResourceHandles::mock(app);
|
|
let (_, workspace) = app.add_window(WindowStyle::NotStealFocus, |ctx| {
|
|
Workspace::new(
|
|
global_resource_handles,
|
|
None,
|
|
NewWorkspaceSource::TransferredTab {
|
|
tab_color: None,
|
|
custom_title: None,
|
|
left_panel_open: false,
|
|
vertical_tabs_panel_open,
|
|
right_panel_open: false,
|
|
is_right_panel_maximized: false,
|
|
is_tab_drag_preview: false,
|
|
},
|
|
ctx,
|
|
)
|
|
});
|
|
workspace
|
|
}
|
|
|
|
#[test]
|
|
fn test_tab_bar_traffic_light_space_regression_for_resource_center_overlap() {
|
|
// Regression for #10139: the Resource Center/right panel can be open on
|
|
// Windows/Linux, but vertical-tabs and right-panel state should not decide
|
|
// whether the tab bar reserves space for titlebar controls.
|
|
let cases = [
|
|
(TrafficLightSide::Left, false),
|
|
(TrafficLightSide::Right, true),
|
|
];
|
|
|
|
for (side, should_reserve_space) in cases {
|
|
assert_eq!(
|
|
should_reserve_traffic_light_space_in_tab_bar(side),
|
|
should_reserve_space
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_theme_chooser_does_not_suppress_tab_bar_traffic_light_padding() {
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
let workspace = mock_workspace(&mut app);
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
let closed_padding = workspace.compute_tab_bar_left_padding(ctx);
|
|
assert!(
|
|
closed_padding > 0.,
|
|
"Tab bar should reserve left padding when no left panel is open"
|
|
);
|
|
|
|
workspace.current_workspace_state.is_theme_chooser_open = true;
|
|
assert_eq!(
|
|
workspace.compute_tab_bar_left_padding(ctx),
|
|
closed_padding,
|
|
"Theme chooser should not be treated as a left panel for tab bar padding"
|
|
);
|
|
|
|
workspace.open_left_panel(ctx);
|
|
assert_eq!(
|
|
workspace.compute_tab_bar_left_padding(ctx),
|
|
closed_padding,
|
|
"Open tools panel should still reserve tab bar traffic light padding"
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
fn assert_vertical_tabs_tools_panel_preserves_padding(config: HeaderToolbarChipSelection) {
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
app.update(|ctx| {
|
|
TabSettings::handle(ctx).update(ctx, |settings, ctx| {
|
|
report_if_error!(settings.use_vertical_tabs.set_value(true, ctx));
|
|
report_if_error!(settings
|
|
.header_toolbar_chip_selection
|
|
.set_value(config, ctx));
|
|
});
|
|
});
|
|
|
|
let workspace = mock_workspace(&mut app);
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
let closed_padding = workspace.compute_tab_bar_left_padding(ctx);
|
|
assert!(
|
|
closed_padding > 0.,
|
|
"Vertical tabs should reserve traffic light padding"
|
|
);
|
|
|
|
workspace.open_left_panel(ctx);
|
|
assert_eq!(
|
|
workspace.compute_tab_bar_left_padding(ctx),
|
|
closed_padding,
|
|
"An open tools panel should still reserve traffic light padding in vertical tabs"
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_tools_panel_does_not_suppress_vertical_tab_bar_traffic_light_padding() {
|
|
let _vertical_tabs_guard = FeatureFlag::VerticalTabs.override_enabled(true);
|
|
for config in [
|
|
HeaderToolbarChipSelection::Custom {
|
|
left: vec![HeaderToolbarItemKind::AgentManagement],
|
|
right: vec![
|
|
HeaderToolbarItemKind::TabsPanel,
|
|
HeaderToolbarItemKind::ToolsPanel,
|
|
HeaderToolbarItemKind::CodeReview,
|
|
HeaderToolbarItemKind::NotificationsMailbox,
|
|
],
|
|
},
|
|
HeaderToolbarChipSelection::Custom {
|
|
left: vec![
|
|
HeaderToolbarItemKind::TabsPanel,
|
|
HeaderToolbarItemKind::ToolsPanel,
|
|
HeaderToolbarItemKind::AgentManagement,
|
|
],
|
|
right: vec![
|
|
HeaderToolbarItemKind::CodeReview,
|
|
HeaderToolbarItemKind::NotificationsMailbox,
|
|
],
|
|
},
|
|
] {
|
|
assert_vertical_tabs_tools_panel_preserves_padding(config);
|
|
}
|
|
}
|
|
#[cfg(feature = "local_fs")]
|
|
fn open_worktree_sidecar(workspace: &ViewHandle<Workspace>, app: &mut App) {
|
|
workspace.update(app, |workspace, ctx| {
|
|
workspace.open_new_session_dropdown_menu(
|
|
crate::workspace::action::NewSessionMenuAnchor::AddTabButton(Vector2F::zero()),
|
|
ctx,
|
|
);
|
|
|
|
let worktree_index = workspace
|
|
.new_session_dropdown_menu
|
|
.read(ctx, |menu, _| {
|
|
menu.items().iter().position(|item| {
|
|
matches!(
|
|
item,
|
|
MenuItem::Item(fields) if fields.label() == "New worktree config"
|
|
)
|
|
})
|
|
})
|
|
.expect("expected new worktree config item in new-session menu");
|
|
|
|
workspace
|
|
.new_session_dropdown_menu
|
|
.update(ctx, |menu, view_ctx| {
|
|
menu.set_selected_by_index(worktree_index, view_ctx);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[cfg(feature = "local_fs")]
|
|
#[test]
|
|
fn test_worktree_sidecar_hover_takes_precedence_over_selection() {
|
|
let _tab_configs_guard = FeatureFlag::TabConfigs.override_enabled(true);
|
|
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
let workspace = mock_workspace(&mut app);
|
|
let temp_root = TempDir::new().expect("failed to create temp dir");
|
|
let alpha_repo = temp_root.path().join("alpha-repo");
|
|
let beta_repo = temp_root.path().join("beta-repo");
|
|
std::fs::create_dir_all(&alpha_repo).expect("failed to create alpha repo dir");
|
|
std::fs::create_dir_all(&beta_repo).expect("failed to create beta repo dir");
|
|
|
|
workspace.update(&mut app, |_, ctx| {
|
|
PersistedWorkspace::handle(ctx).update(ctx, |persisted, ctx| {
|
|
persisted.user_added_workspace(alpha_repo.clone(), ctx);
|
|
persisted.user_added_workspace(beta_repo.clone(), ctx);
|
|
});
|
|
});
|
|
|
|
open_worktree_sidecar(&workspace, &mut app);
|
|
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
workspace
|
|
.new_session_sidecar_menu
|
|
.update(ctx, |menu, view_ctx| {
|
|
menu.set_selected_by_index(1, view_ctx);
|
|
menu.handle_action(
|
|
&crate::menu::MenuAction::HoverSubmenuLeafNode {
|
|
depth: 0,
|
|
row_index: 2,
|
|
position: Vector2F::zero(),
|
|
},
|
|
view_ctx,
|
|
);
|
|
});
|
|
|
|
workspace.handle_new_session_sidecar_event(&MenuEvent::ItemHovered, ctx);
|
|
});
|
|
|
|
workspace.read(&app, |workspace, ctx| {
|
|
assert_eq!(
|
|
workspace
|
|
.new_session_sidecar_menu
|
|
.read(ctx, |menu, _| menu.selected_index()),
|
|
Some(2)
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[cfg(feature = "local_fs")]
|
|
#[test]
|
|
fn test_worktree_sidecar_pointer_entry_does_not_select_top_repo() {
|
|
let _tab_configs_guard = FeatureFlag::TabConfigs.override_enabled(true);
|
|
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
let workspace = mock_workspace(&mut app);
|
|
let temp_root = TempDir::new().expect("failed to create temp dir");
|
|
let alpha_repo = temp_root.path().join("alpha-repo");
|
|
let beta_repo = temp_root.path().join("beta-repo");
|
|
std::fs::create_dir_all(&alpha_repo).expect("failed to create alpha repo dir");
|
|
std::fs::create_dir_all(&beta_repo).expect("failed to create beta repo dir");
|
|
|
|
workspace.update(&mut app, |_, ctx| {
|
|
PersistedWorkspace::handle(ctx).update(ctx, |persisted, ctx| {
|
|
persisted.user_added_workspace(alpha_repo.clone(), ctx);
|
|
persisted.user_added_workspace(beta_repo.clone(), ctx);
|
|
});
|
|
});
|
|
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
workspace.open_new_session_dropdown_menu(
|
|
crate::workspace::action::NewSessionMenuAnchor::AddTabButton(Vector2F::zero()),
|
|
ctx,
|
|
);
|
|
|
|
let worktree_index = workspace
|
|
.new_session_dropdown_menu
|
|
.read(ctx, |menu, _| {
|
|
menu.items().iter().position(|item| {
|
|
matches!(
|
|
item,
|
|
MenuItem::Item(fields) if fields.label() == "New worktree config"
|
|
)
|
|
})
|
|
})
|
|
.expect("expected new worktree config item in new-session menu");
|
|
|
|
workspace
|
|
.new_session_dropdown_menu
|
|
.update(ctx, |menu, view_ctx| {
|
|
menu.handle_action(
|
|
&crate::menu::MenuAction::HoverSubmenuWithChildren(
|
|
0,
|
|
crate::menu::SelectAction::Index {
|
|
row: worktree_index,
|
|
item: 0,
|
|
},
|
|
),
|
|
view_ctx,
|
|
);
|
|
});
|
|
workspace.update_new_session_sidecar(ctx);
|
|
});
|
|
|
|
workspace.read(&app, |workspace, ctx| {
|
|
assert!(workspace.show_new_session_sidecar);
|
|
assert_eq!(
|
|
workspace
|
|
.new_session_sidecar_menu
|
|
.read(ctx, |menu, _| menu.selected_index()),
|
|
None
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[cfg(feature = "local_fs")]
|
|
#[test]
|
|
fn test_worktree_sidecar_close_via_select_item_executes_from_workspace() {
|
|
let _tab_configs_guard = FeatureFlag::TabConfigs.override_enabled(true);
|
|
|
|
App::test((), |mut app| async move {
|
|
let _cleanup = TabConfigCleanupGuard::new("alpha-repo");
|
|
|
|
initialize_app(&mut app);
|
|
|
|
let workspace = mock_workspace(&mut app);
|
|
let temp_root = TempDir::new().expect("failed to create temp dir");
|
|
let alpha_repo = temp_root.path().join("alpha-repo");
|
|
std::fs::create_dir_all(&alpha_repo).expect("failed to create alpha repo dir");
|
|
|
|
workspace.update(&mut app, |_, ctx| {
|
|
PersistedWorkspace::handle(ctx).update(ctx, |persisted, ctx| {
|
|
persisted.user_added_workspace(alpha_repo.clone(), ctx);
|
|
});
|
|
});
|
|
|
|
open_worktree_sidecar(&workspace, &mut app);
|
|
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
workspace
|
|
.new_session_sidecar_menu
|
|
.update(ctx, |menu, view_ctx| {
|
|
menu.set_selected_by_index(1, view_ctx);
|
|
});
|
|
workspace.handle_new_session_sidecar_event(
|
|
&MenuEvent::Close {
|
|
via_select_item: true,
|
|
},
|
|
ctx,
|
|
);
|
|
workspace.handle_new_session_sidecar_event(&MenuEvent::ItemSelected, ctx);
|
|
});
|
|
|
|
workspace.read(&app, |workspace, _| {
|
|
assert_eq!(workspace.tab_count(), 2);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[cfg(feature = "local_fs")]
|
|
#[test]
|
|
fn test_open_file_notebook_focuses_existing_markdown_pane() {
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
let workspace = mock_workspace(&mut app);
|
|
let temp_dir = TempDir::new().expect("failed to create temp dir");
|
|
let markdown_path = temp_dir.path().join("README.md");
|
|
std::fs::write(&markdown_path, "# Test\n").expect("failed to write markdown file");
|
|
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
workspace.open_file_with_target(
|
|
markdown_path.clone(),
|
|
FileTarget::MarkdownViewer(EditorLayout::SplitPane),
|
|
None,
|
|
CodeSource::Link {
|
|
path: markdown_path.clone(),
|
|
range_start: None,
|
|
range_end: None,
|
|
},
|
|
ctx,
|
|
);
|
|
});
|
|
|
|
let markdown_pane_id = workspace.update(&mut app, |workspace, ctx| {
|
|
let pane_group = workspace.active_tab_pane_group();
|
|
pane_group.update(ctx, |pane_group, ctx| {
|
|
let markdown_panes = pane_group.file_notebook_panes(ctx).collect_vec();
|
|
assert_eq!(markdown_panes.len(), 1);
|
|
let pane_id = markdown_panes[0].0;
|
|
|
|
pane_group.add_terminal_pane(Direction::Right, None, ctx);
|
|
assert_ne!(pane_group.focused_pane_id(ctx), pane_id);
|
|
|
|
pane_id
|
|
})
|
|
});
|
|
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
workspace.open_file_with_target(
|
|
markdown_path.clone(),
|
|
FileTarget::MarkdownViewer(EditorLayout::SplitPane),
|
|
None,
|
|
CodeSource::Link {
|
|
path: markdown_path,
|
|
range_start: None,
|
|
range_end: None,
|
|
},
|
|
ctx,
|
|
);
|
|
});
|
|
|
|
workspace.read(&app, |workspace, ctx| {
|
|
let pane_group = workspace.active_tab_pane_group().as_ref(ctx);
|
|
assert_eq!(pane_group.file_notebook_panes(ctx).count(), 1);
|
|
assert_eq!(pane_group.focused_pane_id(ctx), markdown_pane_id);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[cfg(feature = "local_fs")]
|
|
#[test]
|
|
fn test_worktree_sidecar_search_editor_enter_executes_selection() {
|
|
let _tab_configs_guard = FeatureFlag::TabConfigs.override_enabled(true);
|
|
|
|
App::test((), |mut app| async move {
|
|
let _cleanup = TabConfigCleanupGuard::new("alpha-repo");
|
|
|
|
initialize_app(&mut app);
|
|
|
|
let workspace = mock_workspace(&mut app);
|
|
let temp_root = TempDir::new().expect("failed to create temp dir");
|
|
let alpha_repo = temp_root.path().join("alpha-repo");
|
|
std::fs::create_dir_all(&alpha_repo).expect("failed to create alpha repo dir");
|
|
|
|
workspace.update(&mut app, |_, ctx| {
|
|
PersistedWorkspace::handle(ctx).update(ctx, |persisted, ctx| {
|
|
persisted.user_added_workspace(alpha_repo.clone(), ctx);
|
|
});
|
|
});
|
|
|
|
open_worktree_sidecar(&workspace, &mut app);
|
|
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
workspace
|
|
.worktree_sidecar_search_editor
|
|
.update(ctx, |_, ctx| {
|
|
ctx.emit(Event::Enter);
|
|
});
|
|
});
|
|
|
|
workspace.read(&app, |workspace, _| {
|
|
assert_eq!(workspace.tab_count(), 2);
|
|
assert!(workspace.show_new_session_dropdown_menu.is_none());
|
|
});
|
|
});
|
|
}
|
|
|
|
/// RAII guard that removes tab config TOML files whose name starts with
|
|
/// `prefix` from `~/.warp/tab_configs/` on drop. Because `Drop` runs even
|
|
/// when a test panics, this prevents stale worktree configs from leaking
|
|
/// into Warp dev.
|
|
#[cfg(feature = "local_fs")]
|
|
struct TabConfigCleanupGuard {
|
|
prefix: &'static str,
|
|
}
|
|
|
|
#[cfg(feature = "local_fs")]
|
|
impl TabConfigCleanupGuard {
|
|
fn new(prefix: &'static str) -> Self {
|
|
// Eagerly clean up leftovers from any previously-crashed run.
|
|
Self::clean(prefix);
|
|
Self { prefix }
|
|
}
|
|
|
|
fn clean(prefix: &str) {
|
|
let dir = tab_configs_dir();
|
|
if let Ok(entries) = std::fs::read_dir(&dir) {
|
|
for entry in entries.flatten() {
|
|
let name = entry.file_name();
|
|
let name = name.to_string_lossy();
|
|
if name.starts_with(prefix) && name.ends_with(".toml") {
|
|
let _ = std::fs::remove_file(entry.path());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "local_fs")]
|
|
impl Drop for TabConfigCleanupGuard {
|
|
fn drop(&mut self) {
|
|
Self::clean(self.prefix);
|
|
}
|
|
}
|
|
|
|
/// Creates a workspace with a single, shared session.
|
|
fn mock_workspace_with_shared_session(app: &mut App) -> ViewHandle<Workspace> {
|
|
use crate::terminal::shared_session::manager::Manager;
|
|
|
|
// Create the workspace as a session-sharing sharer.
|
|
let global_resource_handles = GlobalResourceHandles::mock(app);
|
|
let (_, workspace) = app.add_window(WindowStyle::NotStealFocus, |ctx| {
|
|
Workspace::new(
|
|
global_resource_handles,
|
|
None,
|
|
NewWorkspaceSource::Empty {
|
|
previous_active_window: None,
|
|
shell: None,
|
|
},
|
|
ctx,
|
|
)
|
|
});
|
|
|
|
// Get the single terminal view in the workspace.
|
|
let terminal_view = workspace.read(app, |workspace, ctx| {
|
|
assert_eq!(workspace.tabs.len(), 1);
|
|
workspace
|
|
.active_tab_pane_group()
|
|
.as_ref(ctx)
|
|
.focused_session_view(ctx)
|
|
.unwrap()
|
|
});
|
|
|
|
terminal_view.update(app, |view, ctx| {
|
|
view.model.lock().block_list_mut().set_bootstrapped();
|
|
view.attempt_to_share_session(
|
|
SharedSessionScrollbackType::All,
|
|
None,
|
|
SharedSessionSource::user(None),
|
|
false,
|
|
ctx,
|
|
);
|
|
});
|
|
|
|
// Make sure the view is registered with the shared session manager.
|
|
app.read(|ctx| {
|
|
let manager = Manager::as_ref(ctx);
|
|
let shared_sessions = manager.shared_views(ctx).collect_vec();
|
|
assert_eq!(shared_sessions.len(), 1);
|
|
assert_eq!(shared_sessions[0].id(), terminal_view.id());
|
|
});
|
|
|
|
workspace
|
|
}
|
|
|
|
// Creates a workspace as a viewer of a shared session.
|
|
fn mock_workspace_viewing_shared_session(app: &mut App) -> ViewHandle<Workspace> {
|
|
// Create the workspace as a session-sharing sharer.
|
|
let global_resource_handles = GlobalResourceHandles::mock(app);
|
|
|
|
let session_id = SessionId::new();
|
|
|
|
let (_, workspace) = app.add_window(WindowStyle::NotStealFocus, |ctx| {
|
|
Workspace::new(
|
|
global_resource_handles,
|
|
None,
|
|
NewWorkspaceSource::SharedSessionAsViewer { session_id },
|
|
ctx,
|
|
)
|
|
});
|
|
|
|
// Get the single terminal view in the workspace.
|
|
let terminal_view = workspace.read(app, |workspace, ctx| {
|
|
assert_eq!(workspace.tabs.len(), 1);
|
|
workspace
|
|
.active_tab_pane_group()
|
|
.as_ref(ctx)
|
|
.focused_session_view(ctx)
|
|
.unwrap()
|
|
});
|
|
|
|
// Ensure session is opened as a viewer.
|
|
terminal_view.read(app, |terminal, _ctx| {
|
|
let model = terminal.model.clone();
|
|
assert!(model.lock().shared_session_status().is_viewer());
|
|
});
|
|
|
|
workspace
|
|
}
|
|
|
|
/// Disable the warn-before-quit setting. Because we don't fully bootstrap the shell in tests, this
|
|
/// is generally needed in tests that close tabs.
|
|
fn disable_quit_warning(app: &mut AppContext) {
|
|
GeneralSettings::handle(app).update(app, |settings, ctx| {
|
|
settings
|
|
.show_warning_before_quitting
|
|
.set_value(false, ctx)
|
|
.expect("Failed to disable quit warning");
|
|
});
|
|
}
|
|
|
|
fn get_newly_created_pane_id(panes: &PaneGroup, existing_ids: &[PaneId]) -> PaneId {
|
|
panes
|
|
.pane_ids()
|
|
.find(|id| !existing_ids.contains(id))
|
|
.unwrap()
|
|
}
|
|
|
|
fn split_pane_state(
|
|
panes: &PaneGroup,
|
|
pane_id: impl Into<PaneId>,
|
|
ctx: &AppContext,
|
|
) -> SplitPaneState {
|
|
// Split pane state is now inferred from the pane group's focus state
|
|
panes
|
|
.focus_state_handle()
|
|
.as_ref(ctx)
|
|
.split_pane_state_for(pane_id.into())
|
|
}
|
|
|
|
fn active_session_state(
|
|
panes: &PaneGroup,
|
|
pane_id: TerminalPaneId,
|
|
ctx: &AppContext,
|
|
) -> ActiveSessionState {
|
|
if panes
|
|
.terminal_view_from_pane_id(pane_id, ctx)
|
|
.expect("Not a terminal pane")
|
|
.as_ref(ctx)
|
|
.is_active_session(ctx)
|
|
{
|
|
ActiveSessionState::Active
|
|
} else {
|
|
ActiveSessionState::Inactive
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn restore_conversation_in_active_pane_enters_existing_live_conversation_without_loading() {
|
|
let _agent_view = FeatureFlag::AgentView.override_enabled(true);
|
|
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
let workspace = mock_workspace(&mut app);
|
|
let terminal_view = workspace.read(&app, |workspace, ctx| {
|
|
workspace
|
|
.active_tab_pane_group()
|
|
.as_ref(ctx)
|
|
.focused_session_view(ctx)
|
|
.expect("workspace should start with a terminal view")
|
|
});
|
|
let terminal_view_id = terminal_view.read(&app, |view, _| view.view_id());
|
|
let conversation_id =
|
|
BlocklistAIHistoryModel::handle(&app).update(&mut app, |history, ctx| {
|
|
history.start_new_conversation(terminal_view_id, false, false, false, ctx)
|
|
});
|
|
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
assert_eq!(workspace.tab_count(), 1);
|
|
|
|
workspace.restore_conversation_in_active_pane(conversation_id, ctx);
|
|
|
|
assert_eq!(workspace.tab_count(), 1);
|
|
});
|
|
|
|
terminal_view.read(&app, |view, ctx| {
|
|
assert_eq!(view.active_conversation_id(ctx), Some(conversation_id));
|
|
assert_eq!(
|
|
view.model.lock().conversation_transcript_viewer_status(),
|
|
None
|
|
);
|
|
});
|
|
});
|
|
}
|
|
fn new_session_menu_label(item: &MenuItem<WorkspaceAction>) -> String {
|
|
match item {
|
|
MenuItem::Item(fields) => fields.label().to_string(),
|
|
MenuItem::Separator => "---".to_string(),
|
|
MenuItem::ItemsRow { items } => items
|
|
.iter()
|
|
.map(|fields| fields.label().to_string())
|
|
.collect::<Vec<_>>()
|
|
.join(" | "),
|
|
MenuItem::Submenu { fields, .. } => fields.label().to_string(),
|
|
MenuItem::Header { fields, .. } => fields.label().to_string(),
|
|
}
|
|
}
|
|
|
|
fn reopen_closed_session_menu_item(
|
|
menu_items: &[MenuItem<WorkspaceAction>],
|
|
) -> &MenuItemFields<WorkspaceAction> {
|
|
match menu_items.last() {
|
|
Some(MenuItem::Item(fields)) if fields.label() == "Reopen closed session" => fields,
|
|
_ => panic!("expected Reopen closed session to be the last new-session menu item"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_reward_modal_no_overlap() {
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
let workspace = mock_workspace(&mut app);
|
|
|
|
// Trigger the referral reward response
|
|
workspace.update(&mut app, |view, ctx| {
|
|
view.handle_referral_theme_status_event(
|
|
&ReferralThemeEvent::SentReferralThemeActivated,
|
|
ctx,
|
|
);
|
|
|
|
// This _should_ show the reward modal, since the changelog modal is _not_ active
|
|
assert!(view.current_workspace_state.is_reward_modal_open);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_reward_modal_shows_for_received_referral() {
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
let workspace = mock_workspace(&mut app);
|
|
|
|
workspace.update(&mut app, |view, ctx| {
|
|
view.handle_referral_theme_status_event(
|
|
&ReferralThemeEvent::ReceivedReferralThemeActivated,
|
|
ctx,
|
|
);
|
|
|
|
assert!(view.current_workspace_state.is_reward_modal_open);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_tab_renaming_editor_selections() {
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
let workspace = mock_workspace(&mut app);
|
|
|
|
// Add second tab and rename both of them to prepare for the test
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
workspace.add_terminal_tab(false, ctx);
|
|
workspace.rename_tab_internal(0, "short_title", ctx);
|
|
let selected_text = workspace
|
|
.tab_rename_editor
|
|
.read(ctx, |editor, ctx| editor.selected_text(ctx));
|
|
assert_eq!("short_title", selected_text);
|
|
|
|
// Ensure that whatever is selected, is the full title and not the leftover from
|
|
// the previous, shorter one.
|
|
workspace.rename_tab_internal(1, "very_long_title_this_is", ctx);
|
|
let selected_text = workspace
|
|
.tab_rename_editor
|
|
.read(ctx, |editor, ctx| editor.selected_text(ctx));
|
|
assert_eq!("very_long_title_this_is", selected_text);
|
|
|
|
// Ensure that if we escape, the current editor's contents is going to be cleared
|
|
// as well.
|
|
workspace.handle_tab_rename_editor_event(&Event::Escape, ctx);
|
|
let selected_text = workspace
|
|
.tab_rename_editor
|
|
.read(ctx, |editor, ctx| editor.selected_text(ctx));
|
|
assert_eq!("", selected_text);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_tab_renaming_editor_reset() {
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
let workspace = mock_workspace(&mut app);
|
|
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
workspace.add_terminal_tab(false, ctx);
|
|
workspace.rename_tab_internal(0, "short_title", ctx);
|
|
workspace.rename_tab_internal(1, "very_long_title_this_is", ctx);
|
|
|
|
// Ensure that when the editor is initially not empty, it will be cleared before a user renames a tab
|
|
workspace.tab_rename_editor.update(ctx, |editor, ctx| {
|
|
editor.insert_selected_text("some-text", ctx);
|
|
});
|
|
workspace.rename_tab_internal(1, "new_very_long_title", ctx);
|
|
let selected_text: String = workspace
|
|
.tab_rename_editor
|
|
.read(ctx, |editor, ctx| editor.selected_text(ctx));
|
|
assert_eq!("new_very_long_title", selected_text);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_set_active_tab_name() {
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
let workspace = mock_workspace(&mut app);
|
|
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
workspace.add_terminal_tab(false, ctx);
|
|
|
|
workspace.handle_action(
|
|
&WorkspaceAction::SetActiveTabName(" Backend API ".to_string()),
|
|
ctx,
|
|
);
|
|
assert_eq!(
|
|
workspace
|
|
.active_tab_pane_group()
|
|
.as_ref(ctx)
|
|
.display_title(ctx),
|
|
"Backend API"
|
|
);
|
|
assert_eq!(
|
|
workspace
|
|
.active_tab_pane_group()
|
|
.as_ref(ctx)
|
|
.custom_title(ctx)
|
|
.as_deref(),
|
|
Some("Backend API")
|
|
);
|
|
|
|
workspace.handle_action(&WorkspaceAction::ActivateTab(0), ctx);
|
|
assert_ne!(
|
|
workspace
|
|
.active_tab_pane_group()
|
|
.as_ref(ctx)
|
|
.custom_title(ctx)
|
|
.as_deref(),
|
|
Some("Backend API")
|
|
);
|
|
|
|
workspace.handle_action(&WorkspaceAction::ActivateTab(1), ctx);
|
|
workspace.handle_action(&WorkspaceAction::SetActiveTabName(" ".to_string()), ctx);
|
|
assert_eq!(
|
|
workspace
|
|
.active_tab_pane_group()
|
|
.as_ref(ctx)
|
|
.custom_title(ctx)
|
|
.as_deref(),
|
|
Some("Backend API")
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_set_active_tab_name_clears_active_rename_editor_state() {
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
let workspace = mock_workspace(&mut app);
|
|
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
workspace.rename_tab_internal(0, "old title", ctx);
|
|
assert!(workspace.current_workspace_state.is_tab_being_renamed());
|
|
|
|
workspace.handle_action(
|
|
&WorkspaceAction::SetActiveTabName("new title".to_string()),
|
|
ctx,
|
|
);
|
|
|
|
assert!(!workspace.current_workspace_state.is_tab_being_renamed());
|
|
assert_eq!(
|
|
workspace
|
|
.active_tab_pane_group()
|
|
.as_ref(ctx)
|
|
.display_title(ctx),
|
|
"new title"
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_set_active_tab_color() {
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
let workspace = mock_workspace(&mut app);
|
|
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
workspace.add_terminal_tab(false, ctx);
|
|
let active = workspace.active_tab_index;
|
|
|
|
// Setting a color stores it as the manual selection and resolves to it.
|
|
workspace.handle_action(
|
|
&WorkspaceAction::SetActiveTabColor(SelectedTabColor::Color(
|
|
AnsiColorIdentifier::Magenta,
|
|
)),
|
|
ctx,
|
|
);
|
|
assert_eq!(
|
|
workspace.tabs[active].selected_color,
|
|
SelectedTabColor::Color(AnsiColorIdentifier::Magenta),
|
|
);
|
|
assert_eq!(
|
|
workspace.tabs[active].color(),
|
|
Some(AnsiColorIdentifier::Magenta),
|
|
);
|
|
|
|
// Replacing with a different color overwrites the previous selection.
|
|
workspace.handle_action(
|
|
&WorkspaceAction::SetActiveTabColor(SelectedTabColor::Color(
|
|
AnsiColorIdentifier::Green,
|
|
)),
|
|
ctx,
|
|
);
|
|
assert_eq!(
|
|
workspace.tabs[active].selected_color,
|
|
SelectedTabColor::Color(AnsiColorIdentifier::Green),
|
|
);
|
|
|
|
// `Cleared` explicitly suppresses any color (including a directory default).
|
|
workspace.handle_action(
|
|
&WorkspaceAction::SetActiveTabColor(SelectedTabColor::Cleared),
|
|
ctx,
|
|
);
|
|
assert_eq!(
|
|
workspace.tabs[active].selected_color,
|
|
SelectedTabColor::Cleared,
|
|
);
|
|
assert_eq!(workspace.tabs[active].color(), None);
|
|
|
|
// `Unset` removes the manual override so a directory default could apply.
|
|
// With no directory default configured, the resolved color is still `None`.
|
|
workspace.handle_action(
|
|
&WorkspaceAction::SetActiveTabColor(SelectedTabColor::Unset),
|
|
ctx,
|
|
);
|
|
assert_eq!(
|
|
workspace.tabs[active].selected_color,
|
|
SelectedTabColor::Unset,
|
|
);
|
|
assert_eq!(workspace.tabs[active].color(), None);
|
|
|
|
// Action targets the active tab — switching to tab 0 leaves the second tab
|
|
// unaffected.
|
|
workspace.handle_action(&WorkspaceAction::ActivateTab(0), ctx);
|
|
workspace.handle_action(
|
|
&WorkspaceAction::SetActiveTabColor(SelectedTabColor::Color(
|
|
AnsiColorIdentifier::Blue,
|
|
)),
|
|
ctx,
|
|
);
|
|
assert_eq!(
|
|
workspace.tabs[0].selected_color,
|
|
SelectedTabColor::Color(AnsiColorIdentifier::Blue),
|
|
);
|
|
assert_eq!(
|
|
workspace.tabs[active].selected_color,
|
|
SelectedTabColor::Unset,
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_workspace_sessions_retrieves_tabs() {
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
let workspace = mock_workspace(&mut app);
|
|
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
let pane_id = workspace
|
|
.get_pane_group_view(0)
|
|
.map(|tab| tab.read(ctx, |tab, _ctx| tab.pane_id_by_index(0).unwrap()))
|
|
.expect("WindowId was not retrieved.");
|
|
|
|
assert!(workspace
|
|
.workspace_sessions(ctx.window_id(), ctx)
|
|
.any(|x| { x.pane_view_locator().pane_id == pane_id }));
|
|
|
|
// Add a tab and check if workspace_sessions finds the second session from the new tab.
|
|
workspace.add_terminal_tab(false, ctx);
|
|
let new_pane_id = workspace
|
|
.get_pane_group_view(1)
|
|
.map(|tab| tab.read(ctx, |tab, _ctx| tab.pane_id_by_index(0).unwrap()))
|
|
.expect("WindowId was not retrieved.");
|
|
|
|
assert!(workspace
|
|
.workspace_sessions(ctx.window_id(), ctx)
|
|
.any(|x| { x.pane_view_locator().pane_id == new_pane_id }));
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_workspace_sessions_retrieves_panes() {
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
let workspace = mock_workspace(&mut app);
|
|
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
// Add a new split pane to the right.
|
|
if let Some(tab_view) = workspace.get_pane_group_view(0) {
|
|
tab_view.update(ctx, |view, ctx| {
|
|
view.handle_action(&PaneGroupAction::Add(Direction::Right), ctx);
|
|
})
|
|
}
|
|
|
|
// Get the EntityId of the new pane added to the current tab.
|
|
let new_pane_id = workspace
|
|
.get_pane_group_view(0)
|
|
.map(|tab| tab.read(ctx, |tab, _ctx| tab.pane_id_by_index(1).unwrap()))
|
|
.expect("WindowId was not retrieved.");
|
|
assert!(workspace
|
|
.workspace_sessions(ctx.window_id(), ctx)
|
|
.any(|x| { x.pane_view_locator().pane_id == new_pane_id }));
|
|
});
|
|
});
|
|
}
|
|
|
|
fn number_of_shared_sessions_in_tab(
|
|
workspace: &Workspace,
|
|
index: usize,
|
|
ctx: &AppContext,
|
|
) -> usize {
|
|
workspace
|
|
.get_pane_group_view(index)
|
|
.map_or(0, |view| view.as_ref(ctx).number_of_shared_sessions(ctx))
|
|
}
|
|
|
|
/// Sets up the workspace with three tabs. The middle tab has two panes, where one is shared.
|
|
fn setup_session_sharing_test(workspace: &ViewHandle<Workspace>, app: &mut App) -> PaneId {
|
|
let shared_pane_id = workspace.update(app, |workspace, ctx| {
|
|
workspace.add_terminal_tab(false, ctx);
|
|
workspace.add_terminal_tab(false, ctx);
|
|
|
|
let tab_view = workspace.get_pane_group_view(1).unwrap();
|
|
|
|
tab_view.update(ctx, |view, ctx| {
|
|
assert_eq!(view.pane_count(), 1);
|
|
view.focused_session_view(ctx)
|
|
.unwrap()
|
|
.update(ctx, |terminal, ctx| {
|
|
terminal.attempt_to_share_session(
|
|
SharedSessionScrollbackType::None,
|
|
None,
|
|
SharedSessionSource::user(None),
|
|
false,
|
|
ctx,
|
|
);
|
|
});
|
|
|
|
view.handle_action(&PaneGroupAction::Add(Direction::Right), ctx);
|
|
assert_eq!(view.pane_count(), 2);
|
|
|
|
view.pane_id_by_index(0).unwrap()
|
|
})
|
|
});
|
|
|
|
workspace.read(app, |workspace, ctx| {
|
|
assert_eq!(number_of_shared_sessions_in_tab(workspace, 1, ctx), 1);
|
|
|
|
// Confirmation dialog starts not open.
|
|
assert!(
|
|
!workspace
|
|
.current_workspace_state
|
|
.is_close_session_confirmation_dialog_open
|
|
);
|
|
});
|
|
|
|
shared_pane_id
|
|
}
|
|
|
|
#[test]
|
|
fn test_close_tab_confirmation_dialog() {
|
|
let _guard = FeatureFlag::CreatingSharedSessions.override_enabled(true);
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
app.update(disable_quit_warning);
|
|
|
|
let workspace = mock_workspace(&mut app);
|
|
setup_session_sharing_test(&workspace, &mut app);
|
|
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
let first_tab_id = workspace.get_pane_group_view(0).unwrap().id();
|
|
|
|
// Trying to close tab with a shared pane opens dialog.
|
|
workspace.handle_action(&WorkspaceAction::CloseTab(1), ctx);
|
|
assert!(
|
|
workspace
|
|
.current_workspace_state
|
|
.is_close_session_confirmation_dialog_open
|
|
);
|
|
|
|
// User clicking cancel closes dialog.
|
|
workspace.handle_close_session_confirmation_dialog_event(
|
|
&CloseSessionConfirmationEvent::Cancel,
|
|
ctx,
|
|
);
|
|
assert!(
|
|
!workspace
|
|
.current_workspace_state
|
|
.is_close_session_confirmation_dialog_open
|
|
);
|
|
|
|
// Trying to close tab without a shared pane goes through without dialog.
|
|
workspace.handle_action(&WorkspaceAction::CloseTab(2), ctx);
|
|
assert_eq!(workspace.tab_count(), 2);
|
|
assert!(
|
|
!workspace
|
|
.current_workspace_state
|
|
.is_close_session_confirmation_dialog_open
|
|
);
|
|
|
|
// Close the tab with the shared pane.
|
|
workspace.handle_action(&WorkspaceAction::CloseTab(1), ctx);
|
|
assert!(
|
|
workspace
|
|
.current_workspace_state
|
|
.is_close_session_confirmation_dialog_open
|
|
);
|
|
workspace.handle_close_session_confirmation_dialog_event(
|
|
&CloseSessionConfirmationEvent::CloseSession {
|
|
dont_show_again: false,
|
|
open_confirmation_source: OpenDialogSource::CloseTab { tab_index: 1 },
|
|
},
|
|
ctx,
|
|
);
|
|
assert!(
|
|
!workspace
|
|
.current_workspace_state
|
|
.is_close_session_confirmation_dialog_open
|
|
);
|
|
assert_eq!(workspace.tab_count(), 1);
|
|
assert_eq!(workspace.get_pane_group_view(0).unwrap().id(), first_tab_id);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_close_active_horizontal_tab_activates_tab_to_right() {
|
|
let _vertical_tabs_guard = FeatureFlag::VerticalTabs.override_enabled(true);
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
app.update(|ctx| {
|
|
TabSettings::handle(ctx).update(ctx, |settings, ctx| {
|
|
report_if_error!(settings.use_vertical_tabs.set_value(false, ctx));
|
|
});
|
|
});
|
|
|
|
let workspace = mock_workspace(&mut app);
|
|
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
workspace.add_terminal_tab(false, ctx);
|
|
workspace.add_terminal_tab(false, ctx);
|
|
let tab_to_right_id = workspace.get_pane_group_view(2).unwrap().id();
|
|
|
|
workspace.activate_tab(1, ctx);
|
|
workspace.close_tab(1, true, true, ctx);
|
|
|
|
assert_eq!(workspace.tab_count(), 2);
|
|
assert_eq!(workspace.active_tab_index(), 1);
|
|
assert_eq!(workspace.active_tab_pane_group().id(), tab_to_right_id);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_close_last_horizontal_tab_activates_tab_to_left() {
|
|
let _vertical_tabs_guard = FeatureFlag::VerticalTabs.override_enabled(true);
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
app.update(|ctx| {
|
|
TabSettings::handle(ctx).update(ctx, |settings, ctx| {
|
|
report_if_error!(settings.use_vertical_tabs.set_value(false, ctx));
|
|
});
|
|
});
|
|
|
|
let workspace = mock_workspace(&mut app);
|
|
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
workspace.add_terminal_tab(false, ctx);
|
|
workspace.add_terminal_tab(false, ctx);
|
|
let tab_to_left_id = workspace.get_pane_group_view(1).unwrap().id();
|
|
|
|
workspace.activate_tab(2, ctx);
|
|
workspace.close_tab(2, true, true, ctx);
|
|
|
|
assert_eq!(workspace.tab_count(), 2);
|
|
assert_eq!(workspace.active_tab_index(), 1);
|
|
assert_eq!(workspace.active_tab_pane_group().id(), tab_to_left_id);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_close_active_vertical_tab_activates_tab_below() {
|
|
let _vertical_tabs_guard = FeatureFlag::VerticalTabs.override_enabled(true);
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
app.update(|ctx| {
|
|
TabSettings::handle(ctx).update(ctx, |settings, ctx| {
|
|
report_if_error!(settings.use_vertical_tabs.set_value(true, ctx));
|
|
});
|
|
});
|
|
|
|
let workspace = mock_workspace(&mut app);
|
|
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
workspace.add_terminal_tab(false, ctx);
|
|
workspace.add_terminal_tab(false, ctx);
|
|
let tab_below_id = workspace.get_pane_group_view(2).unwrap().id();
|
|
|
|
workspace.activate_tab(1, ctx);
|
|
workspace.close_tab(1, true, true, ctx);
|
|
|
|
assert_eq!(workspace.tab_count(), 2);
|
|
assert_eq!(workspace.active_tab_index(), 1);
|
|
assert_eq!(workspace.active_tab_pane_group().id(), tab_below_id);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_close_last_vertical_tab_activates_tab_above() {
|
|
let _vertical_tabs_guard = FeatureFlag::VerticalTabs.override_enabled(true);
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
app.update(|ctx| {
|
|
TabSettings::handle(ctx).update(ctx, |settings, ctx| {
|
|
report_if_error!(settings.use_vertical_tabs.set_value(true, ctx));
|
|
});
|
|
});
|
|
|
|
let workspace = mock_workspace(&mut app);
|
|
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
workspace.add_terminal_tab(false, ctx);
|
|
workspace.add_terminal_tab(false, ctx);
|
|
let tab_above_id = workspace.get_pane_group_view(1).unwrap().id();
|
|
|
|
workspace.activate_tab(2, ctx);
|
|
workspace.close_tab(2, true, true, ctx);
|
|
|
|
assert_eq!(workspace.tab_count(), 2);
|
|
assert_eq!(workspace.active_tab_index(), 1);
|
|
assert_eq!(workspace.active_tab_pane_group().id(), tab_above_id);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_close_pane_confirmation_dialog() {
|
|
let _guard = FeatureFlag::CreatingSharedSessions.override_enabled(true);
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
let workspace = mock_workspace(&mut app);
|
|
let shared_pane_id = setup_session_sharing_test(&workspace, &mut app);
|
|
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
let shared_pane_group_id = workspace.get_pane_group_view(1).unwrap().id();
|
|
|
|
// User tries to close shared pane, dialog comes up.
|
|
workspace.handle_file_tree_event(
|
|
workspace.get_pane_group_view(1).unwrap().clone(),
|
|
&pane_group::Event::CloseSharedSessionPaneRequested {
|
|
pane_id: shared_pane_id,
|
|
},
|
|
ctx,
|
|
);
|
|
assert!(
|
|
workspace
|
|
.current_workspace_state
|
|
.is_close_session_confirmation_dialog_open
|
|
);
|
|
|
|
// User confirms.
|
|
workspace.handle_close_session_confirmation_dialog_event(
|
|
&CloseSessionConfirmationEvent::CloseSession {
|
|
dont_show_again: false,
|
|
open_confirmation_source: OpenDialogSource::ClosePane {
|
|
pane_group_id: shared_pane_group_id,
|
|
pane_id: shared_pane_id,
|
|
},
|
|
},
|
|
ctx,
|
|
);
|
|
assert!(
|
|
!workspace
|
|
.current_workspace_state
|
|
.is_close_session_confirmation_dialog_open
|
|
);
|
|
assert_eq!(number_of_shared_sessions_in_tab(workspace, 1, ctx), 0);
|
|
let remaining_pane_id = workspace
|
|
.get_pane_group_view_with_id(shared_pane_group_id)
|
|
.unwrap()
|
|
.as_ref(ctx)
|
|
.pane_id_by_index(0)
|
|
.unwrap();
|
|
assert_ne!(remaining_pane_id, shared_pane_id);
|
|
assert_eq!(workspace.tab_count(), 3);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_reopen_closed_shared_tab() {
|
|
let _guard = FeatureFlag::CreatingSharedSessions.override_enabled(true);
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
let workspace = mock_workspace(&mut app);
|
|
setup_session_sharing_test(&workspace, &mut app);
|
|
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
let shared_pane_group = workspace.get_pane_group_view(1).unwrap().clone();
|
|
|
|
// Close the tab with the shared pane.
|
|
workspace.close_tab(1, true, true, ctx);
|
|
assert_eq!(workspace.tab_count(), 2);
|
|
|
|
// Restore the shared tab.
|
|
workspace.restore_closed_tab(1, TabData::new(shared_pane_group.to_owned()), ctx);
|
|
});
|
|
// Restored tab should no longer be shared.
|
|
workspace.read(&app, |workspace, ctx| {
|
|
let pane_group = workspace.get_pane_group_view(1).unwrap();
|
|
assert!(!pane_group.as_ref(ctx).is_terminal_pane_being_shared(ctx));
|
|
assert_eq!(workspace.tab_count(), 3);
|
|
})
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_close_other_tabs_confirmation_dialog() {
|
|
let _guard = FeatureFlag::CreatingSharedSessions.override_enabled(true);
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
let workspace = mock_workspace(&mut app);
|
|
setup_session_sharing_test(&workspace, &mut app);
|
|
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
let last_tab_id = workspace.get_pane_group_view(2).unwrap().id();
|
|
|
|
// User tries to close other tabs choosing non-shared tab, dialog comes up.
|
|
workspace.handle_action(&WorkspaceAction::CloseOtherTabs(2), ctx);
|
|
assert!(
|
|
workspace
|
|
.current_workspace_state
|
|
.is_close_session_confirmation_dialog_open
|
|
);
|
|
|
|
// User confirms.
|
|
workspace.handle_close_session_confirmation_dialog_event(
|
|
&CloseSessionConfirmationEvent::CloseSession {
|
|
dont_show_again: false,
|
|
open_confirmation_source: OpenDialogSource::CloseOtherTabs { tab_index: 2 },
|
|
},
|
|
ctx,
|
|
);
|
|
assert!(
|
|
!workspace
|
|
.current_workspace_state
|
|
.is_close_session_confirmation_dialog_open
|
|
);
|
|
assert_eq!(workspace.tab_count(), 1);
|
|
assert_eq!(workspace.get_pane_group_view(0).unwrap().id(), last_tab_id);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_close_tabs_right_confirmation_dialog() {
|
|
let _guard = FeatureFlag::CreatingSharedSessions.override_enabled(true);
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
let workspace = mock_workspace(&mut app);
|
|
setup_session_sharing_test(&workspace, &mut app);
|
|
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
let first_tab_id = workspace.get_pane_group_view(0).unwrap().id();
|
|
|
|
// User tries to close all tabs right of the left-most tab, dialog comes up.
|
|
workspace.handle_action(&WorkspaceAction::CloseTabsRight(0), ctx);
|
|
assert!(
|
|
workspace
|
|
.current_workspace_state
|
|
.is_close_session_confirmation_dialog_open
|
|
);
|
|
|
|
// User confirms.
|
|
workspace.handle_close_session_confirmation_dialog_event(
|
|
&CloseSessionConfirmationEvent::CloseSession {
|
|
dont_show_again: false,
|
|
open_confirmation_source: OpenDialogSource::CloseTabsDirection {
|
|
tab_index: 0,
|
|
direction: TabMovement::Right,
|
|
},
|
|
},
|
|
ctx,
|
|
);
|
|
assert!(
|
|
!workspace
|
|
.current_workspace_state
|
|
.is_close_session_confirmation_dialog_open
|
|
);
|
|
assert_eq!(workspace.tab_count(), 1);
|
|
assert_eq!(workspace.get_pane_group_view(0).unwrap().id(), first_tab_id);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_confirmation_dialog_dont_show_again() {
|
|
let _guard = FeatureFlag::CreatingSharedSessions.override_enabled(true);
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
app.update(disable_quit_warning);
|
|
|
|
let workspace = mock_workspace(&mut app);
|
|
setup_session_sharing_test(&workspace, &mut app);
|
|
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
// Close the tab with the shared pane, dialog comes up
|
|
workspace.handle_action(&WorkspaceAction::CloseTab(1), ctx);
|
|
assert!(
|
|
workspace
|
|
.current_workspace_state
|
|
.is_close_session_confirmation_dialog_open
|
|
);
|
|
|
|
// User confirms, checking "Don't show again".
|
|
workspace.handle_close_session_confirmation_dialog_event(
|
|
&CloseSessionConfirmationEvent::CloseSession {
|
|
dont_show_again: true,
|
|
open_confirmation_source: OpenDialogSource::CloseTab { tab_index: 1 },
|
|
},
|
|
ctx,
|
|
);
|
|
assert!(
|
|
!workspace
|
|
.current_workspace_state
|
|
.is_close_session_confirmation_dialog_open
|
|
);
|
|
assert_eq!(workspace.tab_count(), 2);
|
|
|
|
// Share the first tab
|
|
let tab_view = workspace.get_pane_group_view(0).unwrap();
|
|
tab_view.update(ctx, |view, ctx| {
|
|
view.terminal_manager(0, ctx)
|
|
.unwrap()
|
|
.as_ref(ctx)
|
|
.model()
|
|
.lock()
|
|
.set_shared_session_status(SharedSessionStatus::ActiveSharer);
|
|
});
|
|
|
|
// Close the shared tab. No dialog should come up and action should go through.
|
|
workspace.handle_action(&WorkspaceAction::CloseActiveTab, ctx);
|
|
assert!(
|
|
!workspace
|
|
.current_workspace_state
|
|
.is_close_session_confirmation_dialog_open
|
|
);
|
|
assert_eq!(workspace.tab_count(), 1);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_close_last_tab_skip_confirmation() {
|
|
let _guard = FeatureFlag::CreatingSharedSessions.override_enabled(true);
|
|
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
app.update(disable_quit_warning);
|
|
|
|
let workspace = mock_workspace(&mut app);
|
|
setup_session_sharing_test(&workspace, &mut app);
|
|
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
// Close the non-shared tabs so there's just one shared tab left.
|
|
workspace.handle_action(&WorkspaceAction::CloseTab(2), ctx);
|
|
workspace.handle_action(&WorkspaceAction::CloseTab(0), ctx);
|
|
assert_eq!(workspace.tab_count(), 1);
|
|
// Close the last remaining tab with the shared pane, no dialog should come up because
|
|
// we're going to close the window and there's already a confirmation on window close.
|
|
workspace.handle_action(&WorkspaceAction::CloseActiveTab, ctx);
|
|
assert!(
|
|
!workspace
|
|
.current_workspace_state
|
|
.is_close_session_confirmation_dialog_open
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_notebook_pane_tracking() {
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
let workspace = mock_workspace(&mut app);
|
|
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
// Add a new notebook pane.
|
|
workspace.open_notebook(
|
|
&NotebookSource::New {
|
|
title: None,
|
|
owner: Owner::mock_current_user(),
|
|
initial_folder_id: None,
|
|
},
|
|
&OpenGalaxyDriveObjectSettings::default(),
|
|
ctx,
|
|
true,
|
|
);
|
|
|
|
// Get the ID of the new notebook.
|
|
let pane_group = workspace
|
|
.get_pane_group_view(0)
|
|
.expect("Pane group does not exist")
|
|
.clone();
|
|
let notebook_view = pane_group
|
|
.as_ref(ctx)
|
|
.notebook_view_at_pane_index(0, ctx)
|
|
.expect("Notebook view was not created")
|
|
.clone();
|
|
let notebook_pane_id = pane_group
|
|
.as_ref(ctx)
|
|
.pane_id_from_index(0)
|
|
.expect("Notebook view should have been created");
|
|
let notebook_id = notebook_view
|
|
.as_ref(ctx)
|
|
.notebook_id(ctx)
|
|
.expect("Notebook should have an ID");
|
|
|
|
// The notebook should be registered with the NotebookManager.
|
|
let (window, locator) = NotebookManager::as_ref(ctx)
|
|
.find_pane(&NotebookSource::Existing(notebook_id))
|
|
.expect("Notebook pane should be registered");
|
|
assert_eq!(window, ctx.window_id());
|
|
assert_eq!(
|
|
locator,
|
|
PaneViewLocator {
|
|
pane_group_id: pane_group.id(),
|
|
pane_id: notebook_pane_id,
|
|
}
|
|
);
|
|
|
|
// Re-opening the notebook should not create a new view.
|
|
workspace.open_notebook(
|
|
&NotebookSource::Existing(notebook_id),
|
|
&OpenGalaxyDriveObjectSettings::default(),
|
|
ctx,
|
|
true,
|
|
);
|
|
assert_eq!(
|
|
ctx.views_of_type::<NotebookView>(ctx.window_id()),
|
|
Some(vec![notebook_view])
|
|
);
|
|
|
|
// Finally, closing the notebook pane should de-register it.
|
|
pane_group.update(ctx, |pane_group, ctx| {
|
|
pane_group.handle_action(&PaneGroupAction::RemoveActive, ctx)
|
|
});
|
|
assert_eq!(
|
|
NotebookManager::handle(ctx)
|
|
.as_ref(ctx)
|
|
.find_pane(&NotebookSource::Existing(notebook_id)),
|
|
None
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_set_active_terminal_input_contents_and_focus_app() {
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
let workspace = mock_workspace(&mut app);
|
|
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
let initial_buffer_contents = workspace
|
|
.get_active_input_view_handle(ctx)
|
|
.map(|input_view_handle| input_view_handle.as_ref(ctx).buffer_text(ctx))
|
|
.expect("There should be an active input view");
|
|
assert_eq!(
|
|
"", initial_buffer_contents,
|
|
"initial active input should be empty"
|
|
);
|
|
|
|
workspace.set_active_terminal_input_contents_and_focus_app("foobar", ctx);
|
|
|
|
assert_eq!(
|
|
"foobar",
|
|
workspace
|
|
.get_active_input_view_handle(ctx)
|
|
.map(|input_view_handle| input_view_handle.as_ref(ctx).buffer_text(ctx))
|
|
.expect("There should be an active input view")
|
|
);
|
|
assert!(ctx.windows().app_is_active());
|
|
});
|
|
});
|
|
}
|
|
|
|
/// Ensures that the terminal model is destroyed when it is no longer needed.
|
|
/// This is only a "workspace" test because we want to mimic what a normal
|
|
/// user would do and expect (e.g. close a tab and expect that its backing
|
|
/// data is correctly deallocated).
|
|
///
|
|
/// TODO(suraj): we may also want to investigate a more "real" integration test
|
|
/// that inspects the application process's overall memory consumption
|
|
/// instead of just the terminal model, but this is not easy because
|
|
/// 1. we want to measure non-shared memory (i.e. the "memory" value in Activity Monitor)
|
|
/// which is not easy; it's easier to measure "real memory" or RSS, but that includes
|
|
/// shared memory across processes.
|
|
/// 2. the test might be flaky depending on how much memory is actually allocated vs
|
|
/// freed up (not something easily controlled).
|
|
///
|
|
/// For now, this test is still useful because the terminal model is one of the largest data structures
|
|
/// maintained by our app, so we want to ensure we're not introducing regressions that cause it to not
|
|
/// be deallocated correctly.
|
|
#[test]
|
|
fn test_terminal_model_isnt_leaked() {
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
// Turn off undo-close so that we don't need to wait for deallocation.
|
|
UndoCloseSettings::handle(&app).update(&mut app, |settings, ctx| {
|
|
settings
|
|
.enabled
|
|
.set_value(false, ctx)
|
|
.expect("Can turn off undo-close via settings.")
|
|
});
|
|
|
|
let workspace = mock_workspace(&mut app);
|
|
|
|
let terminal_model = workspace.update(&mut app, |workspace, ctx| {
|
|
// Add another tab so that the workspace isn't destroyed when we close the tab.
|
|
workspace.add_terminal_tab(false, ctx);
|
|
|
|
// Get a weak reference to the model.
|
|
let model = workspace.get_active_session_terminal_model(ctx).unwrap();
|
|
Arc::downgrade(&model)
|
|
});
|
|
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
// Remove the tab. This should destroy the corresponding terminal view.
|
|
workspace.remove_tab(workspace.active_tab_index(), true, true, ctx);
|
|
});
|
|
// For some reason, the update call above results in more pending effects, one of which
|
|
// contains the actual logic that drops the `TerminalModel`.
|
|
app.update(|_| ());
|
|
|
|
// If we can't upgrade the weak reference, that means it was in fact destructed.
|
|
assert!(
|
|
terminal_model.upgrade().is_none(),
|
|
"The terminal model should not exist once the tab is closed."
|
|
)
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_open_or_toggle_warp_drive() {
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
let workspace = mock_workspace(&mut app);
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
// First, unconditionally open Warp Drive as a system action. WD should be open and welcome tips should not have opening warp drive.
|
|
workspace.open_or_toggle_warp_drive(
|
|
false, /* toggle */
|
|
false, /* explicit_user_action */
|
|
ctx,
|
|
);
|
|
assert!(
|
|
workspace.current_workspace_state.is_warp_drive_open,
|
|
"Warp Drive should be open"
|
|
);
|
|
assert!(
|
|
!workspace
|
|
.tips_completed
|
|
.as_ref(ctx)
|
|
.features_used
|
|
.contains(&Tip::Action(TipAction::OpenGalaxyDrive)),
|
|
"Warp drive welcome tip should not be completed"
|
|
);
|
|
|
|
// Next, toggle warp drive as a user action. WD should be closed and tip should not be filled out.
|
|
workspace.open_or_toggle_warp_drive(
|
|
true, /* toggle */
|
|
true, /* explicit_user_action */
|
|
ctx,
|
|
);
|
|
assert!(
|
|
!workspace.current_workspace_state.is_warp_drive_open,
|
|
"Warp Drive should be closed"
|
|
);
|
|
assert!(
|
|
!workspace
|
|
.tips_completed
|
|
.as_ref(ctx)
|
|
.features_used
|
|
.contains(&Tip::Action(TipAction::OpenGalaxyDrive)),
|
|
"Warp drive welcome tip should not be completed"
|
|
);
|
|
|
|
// Finally, toggle warp drive again as a user action. WD should be open and tip filled out.
|
|
workspace.open_or_toggle_warp_drive(
|
|
true, /* toggle */
|
|
true, /* explicit_user_action */
|
|
ctx,
|
|
);
|
|
assert!(
|
|
workspace.current_workspace_state.is_warp_drive_open,
|
|
"Warp Drive should be open"
|
|
);
|
|
assert!(
|
|
workspace
|
|
.tips_completed
|
|
.as_ref(ctx)
|
|
.features_used
|
|
.contains(&Tip::Action(TipAction::OpenGalaxyDrive)),
|
|
"Warp drive welcome tip should not be completed"
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_stop_sharing_session() {
|
|
let _guard = FeatureFlag::CreatingSharedSessions.override_enabled(true);
|
|
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
// Create a workspace with a single session that's shared.
|
|
let workspace = mock_workspace_with_shared_session(&mut app);
|
|
let terminal_view = workspace.read(&app, |workspace, ctx| {
|
|
assert_eq!(workspace.tabs.len(), 1);
|
|
workspace
|
|
.active_tab_pane_group()
|
|
.as_ref(ctx)
|
|
.focused_session_view(ctx)
|
|
.unwrap()
|
|
});
|
|
|
|
// Stop sharing the shared session.
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
workspace.stop_sharing_session(
|
|
&terminal_view.id(),
|
|
SharedSessionActionSource::Tab,
|
|
ctx,
|
|
);
|
|
});
|
|
|
|
// Ensure that the session is no longer registered with the shared session manager.
|
|
app.read(|ctx| {
|
|
let manager = Manager::as_ref(ctx);
|
|
let shared_sessions = manager.shared_views(ctx).collect_vec();
|
|
assert_eq!(shared_sessions.len(), 0);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_stop_sharing_all_sessions_in_tab() {
|
|
let _guard = FeatureFlag::CreatingSharedSessions.override_enabled(true);
|
|
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
// Create a workspace with two tabs. First tab has two shared sessions. Second tab has one shared session.
|
|
let workspace = mock_workspace_with_shared_session(&mut app);
|
|
let second_tab_session = workspace.update(&mut app, |workspace, ctx| {
|
|
workspace
|
|
.active_tab_pane_group()
|
|
.update(ctx, |pane_group, ctx| {
|
|
pane_group.handle_action(&PaneGroupAction::Add(Direction::Right), ctx);
|
|
pane_group
|
|
.terminal_view_at_pane_index(1, ctx)
|
|
.unwrap()
|
|
.update(ctx, |terminal_view, ctx| {
|
|
terminal_view.attempt_to_share_session(
|
|
SharedSessionScrollbackType::None,
|
|
None,
|
|
SharedSessionSource::user(None),
|
|
false,
|
|
ctx,
|
|
);
|
|
});
|
|
});
|
|
|
|
workspace.add_terminal_tab(false, ctx);
|
|
workspace
|
|
.active_tab_pane_group()
|
|
.update(ctx, |pane_group, ctx| {
|
|
pane_group
|
|
.terminal_view_at_pane_index(0, ctx)
|
|
.unwrap()
|
|
.update(ctx, |terminal_view, ctx| {
|
|
terminal_view.attempt_to_share_session(
|
|
SharedSessionScrollbackType::None,
|
|
None,
|
|
SharedSessionSource::user(None),
|
|
false,
|
|
ctx,
|
|
);
|
|
});
|
|
});
|
|
|
|
workspace
|
|
.active_tab_pane_group()
|
|
.read(ctx, |pane_group, ctx| {
|
|
pane_group.terminal_view_at_pane_index(0, ctx).unwrap().id()
|
|
})
|
|
});
|
|
|
|
// Ensure we have three shared sessions registered.
|
|
app.read(|ctx| {
|
|
let manager = Manager::as_ref(ctx);
|
|
let shared_sessions = manager.shared_views(ctx).collect_vec();
|
|
assert_eq!(shared_sessions.len(), 3);
|
|
});
|
|
|
|
// Stop sharing all sessions in first tab.
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
let tab = workspace.tabs[0].pane_group.downgrade();
|
|
workspace.stop_sharing_all_panes_in_tab(&tab, ctx);
|
|
});
|
|
|
|
// Ensure that the only remaining shared session is the one in the other tab.
|
|
app.read(|ctx| {
|
|
let manager = Manager::as_ref(ctx);
|
|
let shared_sessions = manager.shared_views(ctx).collect_vec();
|
|
assert_eq!(shared_sessions.len(), 1);
|
|
assert_eq!(shared_sessions[0].id(), second_tab_session);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_tab_context_menu_share_session_items() {
|
|
let _guard = FeatureFlag::CreatingSharedSessions.override_enabled(true);
|
|
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
let workspace = mock_workspace(&mut app);
|
|
let shared_pane_id = setup_session_sharing_test(&workspace, &mut app);
|
|
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
// Focus the shared session
|
|
workspace.activate_tab(1, ctx);
|
|
workspace
|
|
.active_tab_pane_group()
|
|
.update(ctx, |pane_group, ctx| {
|
|
pane_group.focus_pane_by_id(shared_pane_id, ctx);
|
|
});
|
|
});
|
|
|
|
// When there's a single shared session in a tab (focused), the options
|
|
// for sharing are "Stop sharing" and "Stop sharing all".
|
|
workspace.read(&app, |workspace, ctx| {
|
|
let items =
|
|
workspace.tabs[1].menu_items(1, 3, &workspace.tab_groups, false, true, true, ctx);
|
|
assert!(items[0]
|
|
.is_approximately_same_item_as(&MenuItemFields::new("Stop sharing").into_item()));
|
|
assert!(items[1].is_approximately_same_item_as(
|
|
&MenuItemFields::new("Stop sharing all").into_item()
|
|
));
|
|
});
|
|
|
|
// Focus the other, non-shared pane in the tab
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
workspace.activate_tab(1, ctx);
|
|
workspace
|
|
.active_tab_pane_group()
|
|
.update(ctx, |pane_group, ctx| {
|
|
pane_group.pane_by_index(1).unwrap().focus(ctx);
|
|
});
|
|
});
|
|
|
|
// When there's a single shared session in a tab (unfocused), the options
|
|
// for sharing are "Share session" and "Stop sharing all".
|
|
workspace.read(&app, |workspace, ctx| {
|
|
let items =
|
|
workspace.tabs[1].menu_items(1, 3, &workspace.tab_groups, false, true, true, ctx);
|
|
assert!(items[0]
|
|
.is_approximately_same_item_as(&MenuItemFields::new("Share session").into_item()));
|
|
assert!(items[1].is_approximately_same_item_as(
|
|
&MenuItemFields::new("Stop sharing all").into_item()
|
|
));
|
|
});
|
|
|
|
// Stop sharing.
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
let tab = workspace.tabs[1].pane_group.downgrade();
|
|
workspace.stop_sharing_all_panes_in_tab(&tab, ctx);
|
|
});
|
|
|
|
// When there's no shared sessions in a tab, the only option is "Share session".
|
|
workspace.read(&app, |workspace, ctx| {
|
|
let items =
|
|
workspace.tabs[1].menu_items(1, 3, &workspace.tab_groups, false, true, true, ctx);
|
|
assert!(items[0]
|
|
.is_approximately_same_item_as(&MenuItemFields::new("Share session").into_item()));
|
|
assert!(items[1].is_approximately_same_item_as(&MenuItem::Separator));
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_view_only_session() {
|
|
let _guard = FeatureFlag::ViewingSharedSessions.override_enabled(true);
|
|
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
// Trying to open command search
|
|
let workspace = mock_workspace_viewing_shared_session(&mut app);
|
|
workspace.update(&mut app, |workspace: &mut Workspace, ctx| {
|
|
workspace.handle_action(&WorkspaceAction::ShowCommandSearch(Default::default()), ctx);
|
|
});
|
|
|
|
// Ensure command search doesn't work for read-only shared sessions
|
|
workspace.read(&app, |workspace, _ctx| {
|
|
assert!(!workspace.current_workspace_state.is_command_search_open);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
// This tests the end-to-end behavior to correctly switch focus among panels.
|
|
// (The only panels that can be focused currently are WD, workspace, & the agent panel.)
|
|
fn test_switch_focus_panels() {
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
let workspace = mock_workspace(&mut app);
|
|
|
|
workspace.update(&mut app, |view, ctx| {
|
|
view.focus_active_tab(ctx);
|
|
});
|
|
workspace.update(&mut app, |view, ctx| {
|
|
assert!(
|
|
view.active_tab_pane_group().is_self_or_child_focused(ctx),
|
|
"Expected terminal to be focused"
|
|
);
|
|
});
|
|
|
|
// Shift focus from terminal to left panel when WD is open
|
|
workspace.update(&mut app, |view, ctx| {
|
|
view.current_workspace_state.is_warp_drive_open = true;
|
|
view.focus_left_panel(ctx);
|
|
});
|
|
workspace.update(&mut app, |view, ctx| {
|
|
assert!(
|
|
view.left_panel_view.is_self_or_child_focused(ctx),
|
|
"Expected Warp Drive panel to be focused"
|
|
);
|
|
});
|
|
|
|
// Shift focus from WD to left panel when AI panel is open
|
|
workspace.update(&mut app, |view, ctx| {
|
|
view.current_workspace_state.is_ai_assistant_panel_open = true;
|
|
view.focus_left_panel(ctx);
|
|
});
|
|
workspace.update(&mut app, |view, ctx| {
|
|
assert!(
|
|
view.ai_assistant_panel.is_self_or_child_focused(ctx),
|
|
"Expected AI panel to be focused"
|
|
);
|
|
});
|
|
|
|
// Shift focus from AI panel to left panel (terminal)
|
|
workspace.update(&mut app, |view, ctx| {
|
|
view.focus_left_panel(ctx);
|
|
});
|
|
workspace.update(&mut app, |_view, ctx| {
|
|
assert!(
|
|
workspace.is_self_or_child_focused(ctx),
|
|
"Expected terminal to be focused"
|
|
);
|
|
});
|
|
|
|
// Shift focus from workspace to right panel when the agent panel is open
|
|
workspace.update(&mut app, |view, ctx| {
|
|
view.current_workspace_state.is_ai_assistant_panel_open = true;
|
|
view.focus_right_panel(ctx);
|
|
});
|
|
workspace.update(&mut app, |view, ctx| {
|
|
assert!(
|
|
view.ai_assistant_panel.is_self_or_child_focused(ctx),
|
|
"Expected AI panel to be focused"
|
|
);
|
|
});
|
|
|
|
// Shift focus from WD to right panel (terminal)
|
|
workspace.update(&mut app, |view, ctx| {
|
|
view.focus_right_panel(ctx);
|
|
});
|
|
workspace.update(&mut app, |_view, ctx| {
|
|
assert!(
|
|
workspace.is_self_or_child_focused(ctx),
|
|
"Expected terminal to be focused"
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_focus_notebook() {
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
let workspace = mock_workspace(&mut app);
|
|
let pane_group = workspace.read(&app, |workspace, _ctx| {
|
|
workspace
|
|
.get_pane_group_view(0)
|
|
.expect("should have pane group for tab 0")
|
|
.clone()
|
|
});
|
|
|
|
let first_terminal_id = pane_group.read(&app, |panes, _ctx| {
|
|
get_newly_created_pane_id(panes, &[])
|
|
.as_terminal_pane_id()
|
|
.expect("should be a terminal pane")
|
|
});
|
|
|
|
let notebook_id = pane_group.update(&mut app, |panes, ctx| {
|
|
// Add a notebook to the left.
|
|
let notebook_view = ctx.add_typed_action_view(NotebookView::new);
|
|
panes.add_pane_with_direction(
|
|
Direction::Left,
|
|
NotebookPane::new(notebook_view, ctx),
|
|
true, /* focus_new_pane */
|
|
ctx,
|
|
);
|
|
get_newly_created_pane_id(panes, &[first_terminal_id.into()])
|
|
});
|
|
|
|
// The new pane should be focused, but the terminal is still the active session.
|
|
pane_group.read(&app, |panes, ctx| {
|
|
assert_eq!(panes.focused_pane_id(ctx), notebook_id);
|
|
assert_eq!(panes.active_session_id(ctx), Some(first_terminal_id));
|
|
assert_eq!(
|
|
split_pane_state(panes, first_terminal_id, ctx),
|
|
SplitPaneState::InSplitPane(PaneState::Unfocused)
|
|
);
|
|
assert_eq!(
|
|
active_session_state(panes, first_terminal_id, ctx),
|
|
ActiveSessionState::Active
|
|
);
|
|
assert_eq!(
|
|
split_pane_state(panes, notebook_id, ctx),
|
|
SplitPaneState::InSplitPane(PaneState::Focused)
|
|
);
|
|
});
|
|
|
|
// Add a terminal below.
|
|
let second_terminal_id = pane_group.update(&mut app, |panes, ctx| {
|
|
panes.add_terminal_pane(Direction::Down, None, ctx);
|
|
get_newly_created_pane_id(panes, &[first_terminal_id.into(), notebook_id])
|
|
.as_terminal_pane_id()
|
|
.expect("should be a terminal pane")
|
|
});
|
|
|
|
// The new terminal should be both focused and the active session.
|
|
pane_group.read(&app, |panes, ctx| {
|
|
assert_eq!(panes.focused_pane_id(ctx), second_terminal_id.into());
|
|
assert_eq!(panes.active_session_id(ctx), Some(second_terminal_id));
|
|
assert_eq!(
|
|
split_pane_state(panes, first_terminal_id, ctx),
|
|
SplitPaneState::InSplitPane(PaneState::Unfocused)
|
|
);
|
|
assert_eq!(
|
|
active_session_state(panes, first_terminal_id, ctx),
|
|
ActiveSessionState::Inactive
|
|
);
|
|
assert_eq!(
|
|
split_pane_state(panes, second_terminal_id, ctx),
|
|
SplitPaneState::InSplitPane(PaneState::Focused)
|
|
);
|
|
assert_eq!(
|
|
active_session_state(panes, second_terminal_id, ctx),
|
|
ActiveSessionState::Active
|
|
);
|
|
assert_eq!(
|
|
split_pane_state(panes, notebook_id, ctx),
|
|
SplitPaneState::InSplitPane(PaneState::Unfocused)
|
|
);
|
|
});
|
|
|
|
// Close the new terminal.
|
|
pane_group.update(&mut app, |panes, ctx| {
|
|
panes.close_pane(second_terminal_id.into(), ctx);
|
|
});
|
|
|
|
// Focus should switch to the notebook, and the first terminal session
|
|
// will activate.
|
|
pane_group.read(&app, |panes, ctx| {
|
|
assert_eq!(panes.focused_pane_id(ctx), notebook_id);
|
|
assert_eq!(panes.active_session_id(ctx), Some(first_terminal_id));
|
|
assert_eq!(
|
|
split_pane_state(panes, first_terminal_id, ctx),
|
|
SplitPaneState::InSplitPane(PaneState::Unfocused)
|
|
);
|
|
assert_eq!(
|
|
split_pane_state(panes, notebook_id, ctx),
|
|
SplitPaneState::InSplitPane(PaneState::Focused)
|
|
);
|
|
assert_eq!(
|
|
active_session_state(panes, first_terminal_id, ctx),
|
|
ActiveSessionState::Active
|
|
);
|
|
});
|
|
})
|
|
}
|
|
|
|
#[test]
|
|
fn test_close_active_session() {
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
let workspace = mock_workspace(&mut app);
|
|
let pane_group = workspace.read(&app, |workspace, _ctx| {
|
|
workspace
|
|
.get_pane_group_view(0)
|
|
.expect("should have pane group for tab 0")
|
|
.clone()
|
|
});
|
|
|
|
let first_terminal_id = pane_group.read(&app, |panes, _ctx| {
|
|
get_newly_created_pane_id(panes, &[])
|
|
.as_terminal_pane_id()
|
|
.expect("should be a terminal pane")
|
|
});
|
|
|
|
// Add a terminal above.
|
|
let second_terminal_id = pane_group.update(&mut app, |panes, ctx| {
|
|
panes.add_terminal_pane(Direction::Up, None, ctx);
|
|
get_newly_created_pane_id(panes, &[first_terminal_id.into()])
|
|
.as_terminal_pane_id()
|
|
.expect("should be a terminal pane")
|
|
});
|
|
|
|
let notebook_id = pane_group.update(&mut app, |panes, ctx| {
|
|
// Add a notebook to the left.
|
|
let notebook_view = ctx.add_typed_action_view(NotebookView::new);
|
|
panes.add_pane_with_direction(
|
|
Direction::Left,
|
|
NotebookPane::new(notebook_view, ctx),
|
|
true, /* focus_new_pane */
|
|
ctx,
|
|
);
|
|
get_newly_created_pane_id(
|
|
panes,
|
|
&[first_terminal_id.into(), second_terminal_id.into()],
|
|
)
|
|
});
|
|
|
|
pane_group.read(&app, |panes, ctx| {
|
|
assert_eq!(panes.focused_pane_id(ctx), notebook_id);
|
|
assert_eq!(panes.active_session_id(ctx), Some(second_terminal_id));
|
|
});
|
|
|
|
pane_group.update(&mut app, |panes, ctx| {
|
|
// Close the active session, which should leave the notebook focused and activate the
|
|
// remaining session.
|
|
panes.close_pane(second_terminal_id.into(), ctx);
|
|
});
|
|
|
|
pane_group.read(&app, |panes, ctx| {
|
|
assert_eq!(panes.focused_pane_id(ctx), notebook_id);
|
|
assert_eq!(panes.active_session_id(ctx), Some(first_terminal_id));
|
|
assert_eq!(
|
|
split_pane_state(panes, first_terminal_id, ctx),
|
|
SplitPaneState::InSplitPane(PaneState::Unfocused)
|
|
);
|
|
assert_eq!(
|
|
active_session_state(panes, first_terminal_id, ctx),
|
|
ActiveSessionState::Active
|
|
);
|
|
});
|
|
|
|
pane_group.update(&mut app, |panes, ctx| {
|
|
// Now, focus the remaining session, which should keep it activated.
|
|
panes.focus_pane_by_id(first_terminal_id.into(), ctx);
|
|
});
|
|
|
|
pane_group.read(&app, |panes, ctx| {
|
|
assert_eq!(panes.focused_pane_id(ctx), first_terminal_id.into());
|
|
assert_eq!(panes.active_session_id(ctx), Some(first_terminal_id));
|
|
assert_eq!(
|
|
split_pane_state(panes, first_terminal_id, ctx),
|
|
SplitPaneState::InSplitPane(PaneState::Focused)
|
|
);
|
|
assert_eq!(
|
|
split_pane_state(panes, notebook_id, ctx),
|
|
SplitPaneState::InSplitPane(PaneState::Unfocused)
|
|
);
|
|
assert_eq!(
|
|
active_session_state(panes, first_terminal_id, ctx),
|
|
ActiveSessionState::Active
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
fn set_left_panel_visibility_across_tabs(is_enabled: bool, ctx: &mut ViewContext<Workspace>) {
|
|
WindowSettings::handle(ctx).update(ctx, |window_settings, ctx| {
|
|
window_settings
|
|
.left_panel_visibility_across_tabs
|
|
.set_value(is_enabled, ctx)
|
|
.expect("Failed to update left_panel_visibility_across_tabs setting");
|
|
});
|
|
}
|
|
|
|
fn add_get_started_tab(workspace: &mut Workspace, ctx: &mut ViewContext<Workspace>) {
|
|
workspace.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::<PaneUuid, Vec<SerializedBlockListItem>>::new()),
|
|
None,
|
|
ctx,
|
|
);
|
|
}
|
|
|
|
fn find_terminal_tab_index(workspace: &Workspace, ctx: &AppContext) -> usize {
|
|
workspace
|
|
.tabs
|
|
.iter()
|
|
.position(|tab| tab.pane_group.as_ref(ctx).has_terminal_panes())
|
|
.expect("Expected a terminal tab")
|
|
}
|
|
|
|
fn find_non_following_tab_index(workspace: &Workspace, ctx: &AppContext) -> usize {
|
|
workspace
|
|
.tabs
|
|
.iter()
|
|
.position(|tab| {
|
|
!Workspace::should_enable_file_tree_and_global_search_for_pane_group(
|
|
tab.pane_group.as_ref(ctx),
|
|
)
|
|
})
|
|
.expect("Expected a non-following tab")
|
|
}
|
|
|
|
#[test]
|
|
fn test_left_panel_window_scoped_reconciles_between_terminal_tabs_when_enabled() {
|
|
let _conversation_list_guard =
|
|
FeatureFlag::AgentViewConversationListView.override_enabled(false);
|
|
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
let workspace = mock_workspace(&mut app);
|
|
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
set_left_panel_visibility_across_tabs(true, ctx);
|
|
|
|
workspace.add_terminal_tab(false, ctx);
|
|
|
|
workspace.activate_tab(0, ctx);
|
|
assert!(
|
|
!workspace
|
|
.active_tab_pane_group()
|
|
.as_ref(ctx)
|
|
.left_panel_open
|
|
);
|
|
assert!(!workspace.left_panel_open);
|
|
|
|
workspace.open_left_panel(ctx);
|
|
assert!(
|
|
workspace
|
|
.active_tab_pane_group()
|
|
.as_ref(ctx)
|
|
.left_panel_open
|
|
);
|
|
assert!(workspace.left_panel_open);
|
|
|
|
workspace.activate_tab(1, ctx);
|
|
assert!(
|
|
workspace
|
|
.active_tab_pane_group()
|
|
.as_ref(ctx)
|
|
.left_panel_open
|
|
);
|
|
|
|
workspace.close_left_panel(ctx);
|
|
assert!(
|
|
!workspace
|
|
.active_tab_pane_group()
|
|
.as_ref(ctx)
|
|
.left_panel_open
|
|
);
|
|
assert!(!workspace.left_panel_open);
|
|
|
|
workspace.activate_tab(0, ctx);
|
|
assert!(
|
|
!workspace
|
|
.active_tab_pane_group()
|
|
.as_ref(ctx)
|
|
.left_panel_open
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_left_panel_window_scoped_non_following_tab_does_not_reconcile_but_updates_window_state() {
|
|
let _conversation_list_guard =
|
|
FeatureFlag::AgentViewConversationListView.override_enabled(false);
|
|
let _get_started_guard = FeatureFlag::GetStartedTab.override_enabled(true);
|
|
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
let workspace = mock_workspace(&mut app);
|
|
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
set_left_panel_visibility_across_tabs(true, ctx);
|
|
|
|
// Establish window-scoped desired state = open on a terminal tab.
|
|
workspace.open_left_panel(ctx);
|
|
assert!(workspace.left_panel_open);
|
|
|
|
// Create a non-following tab (e.g. Get Started), which should not auto-open even though
|
|
// the window state is open.
|
|
add_get_started_tab(workspace, ctx);
|
|
let non_following_tab_index = find_non_following_tab_index(workspace, ctx);
|
|
workspace.activate_tab(non_following_tab_index, ctx);
|
|
|
|
assert!(
|
|
!workspace
|
|
.active_tab_pane_group()
|
|
.as_ref(ctx)
|
|
.left_panel_open
|
|
);
|
|
assert!(workspace.left_panel_open);
|
|
|
|
// User actions in the non-following tab still update window state.
|
|
workspace.open_left_panel(ctx);
|
|
assert!(
|
|
workspace
|
|
.active_tab_pane_group()
|
|
.as_ref(ctx)
|
|
.left_panel_open
|
|
);
|
|
assert!(workspace.left_panel_open);
|
|
|
|
workspace.close_left_panel(ctx);
|
|
assert!(
|
|
!workspace
|
|
.active_tab_pane_group()
|
|
.as_ref(ctx)
|
|
.left_panel_open
|
|
);
|
|
assert!(!workspace.left_panel_open);
|
|
|
|
// The window state should reconcile back onto following tabs.
|
|
let terminal_tab_index = find_terminal_tab_index(workspace, ctx);
|
|
workspace.activate_tab(terminal_tab_index, ctx);
|
|
assert!(
|
|
!workspace
|
|
.active_tab_pane_group()
|
|
.as_ref(ctx)
|
|
.left_panel_open
|
|
);
|
|
|
|
// But toggling the window state from a following tab should not auto-open the
|
|
// non-following tab.
|
|
workspace.open_left_panel(ctx);
|
|
assert!(workspace.left_panel_open);
|
|
|
|
workspace.activate_tab(non_following_tab_index, ctx);
|
|
assert!(
|
|
!workspace
|
|
.active_tab_pane_group()
|
|
.as_ref(ctx)
|
|
.left_panel_open
|
|
);
|
|
assert!(workspace.left_panel_open);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_left_panel_window_scoped_disabled_keeps_per_tab_state() {
|
|
let _conversation_list_guard =
|
|
FeatureFlag::AgentViewConversationListView.override_enabled(false);
|
|
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
let workspace = mock_workspace(&mut app);
|
|
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
set_left_panel_visibility_across_tabs(false, ctx);
|
|
|
|
workspace.add_terminal_tab(false, ctx);
|
|
|
|
// Open left panel on tab 0.
|
|
workspace.activate_tab(0, ctx);
|
|
workspace.open_left_panel(ctx);
|
|
assert!(
|
|
workspace
|
|
.active_tab_pane_group()
|
|
.as_ref(ctx)
|
|
.left_panel_open
|
|
);
|
|
|
|
// With window scoping disabled, switching tabs should not reconcile the open state.
|
|
workspace.activate_tab(1, ctx);
|
|
assert!(
|
|
!workspace
|
|
.active_tab_pane_group()
|
|
.as_ref(ctx)
|
|
.left_panel_open
|
|
);
|
|
|
|
// Each tab can be toggled independently.
|
|
workspace.open_left_panel(ctx);
|
|
assert!(
|
|
workspace
|
|
.active_tab_pane_group()
|
|
.as_ref(ctx)
|
|
.left_panel_open
|
|
);
|
|
|
|
workspace.activate_tab(0, ctx);
|
|
assert!(
|
|
workspace
|
|
.active_tab_pane_group()
|
|
.as_ref(ctx)
|
|
.left_panel_open
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_vertical_tabs_panel_visibility_restores_from_window_snapshot() {
|
|
let _vertical_tabs_guard = FeatureFlag::VerticalTabs.override_enabled(true);
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
app.update(|ctx| {
|
|
TabSettings::handle(ctx).update(ctx, |settings, ctx| {
|
|
report_if_error!(settings.use_vertical_tabs.set_value(true, ctx));
|
|
});
|
|
});
|
|
|
|
let workspace = mock_workspace(&mut app);
|
|
|
|
let closed_snapshot = workspace.update(&mut app, |workspace, ctx| {
|
|
workspace.vertical_tabs_panel_open = false;
|
|
workspace.snapshot(ctx.window_id(), false, ctx)
|
|
});
|
|
let open_snapshot = workspace.update(&mut app, |workspace, ctx| {
|
|
workspace.vertical_tabs_panel_open = true;
|
|
workspace.snapshot(ctx.window_id(), false, ctx)
|
|
});
|
|
|
|
let restored_closed = restored_workspace(&mut app, closed_snapshot);
|
|
let restored_open = restored_workspace(&mut app, open_snapshot);
|
|
|
|
restored_closed.read(&app, |workspace, _| {
|
|
assert!(!workspace.vertical_tabs_panel_open);
|
|
});
|
|
restored_open.read(&app, |workspace, _| {
|
|
assert!(workspace.vertical_tabs_panel_open);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_open_vertical_tabs_panel_is_idempotent() {
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
let workspace = mock_workspace(&mut app);
|
|
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
workspace.vertical_tabs_panel_open = false;
|
|
workspace.handle_action(&WorkspaceAction::OpenVerticalTabsPanel, ctx);
|
|
assert!(workspace.vertical_tabs_panel_open);
|
|
|
|
workspace.handle_action(&WorkspaceAction::OpenVerticalTabsPanel, ctx);
|
|
assert!(workspace.vertical_tabs_panel_open);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_vertical_tabs_panel_restored_open_when_show_in_restored_windows_enabled() {
|
|
let _vertical_tabs_guard = FeatureFlag::VerticalTabs.override_enabled(true);
|
|
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
app.update(|ctx| {
|
|
TabSettings::handle(ctx).update(ctx, |settings, ctx| {
|
|
report_if_error!(settings.use_vertical_tabs.set_value(true, ctx));
|
|
report_if_error!(settings
|
|
.show_vertical_tab_panel_in_restored_windows
|
|
.set_value(true, ctx));
|
|
});
|
|
});
|
|
|
|
let workspace = mock_workspace(&mut app);
|
|
|
|
let closed_snapshot = workspace.update(&mut app, |workspace, ctx| {
|
|
workspace.vertical_tabs_panel_open = false;
|
|
workspace.snapshot(ctx.window_id(), false, ctx)
|
|
});
|
|
|
|
let restored = restored_workspace(&mut app, closed_snapshot);
|
|
restored.read(&app, |workspace, _| {
|
|
assert!(workspace.vertical_tabs_panel_open);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_vertical_tabs_panel_closed_when_disabled_even_if_persisted_open() {
|
|
// Regression for #9505: when `vertical_tabs_panel_open=true` is persisted
|
|
// and the user then disables vertical tabs, restoring the workspace must
|
|
// not honor the stale snapshot — otherwise a dismiss underlay paints over
|
|
// the window and silently swallows every click.
|
|
let _vertical_tabs_guard = FeatureFlag::VerticalTabs.override_enabled(true);
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
// Snapshot the workspace with the panel open while vertical tabs are enabled.
|
|
app.update(|ctx| {
|
|
TabSettings::handle(ctx).update(ctx, |settings, ctx| {
|
|
report_if_error!(settings.use_vertical_tabs.set_value(true, ctx));
|
|
});
|
|
});
|
|
let workspace = mock_workspace(&mut app);
|
|
let open_snapshot = workspace.update(&mut app, |workspace, ctx| {
|
|
workspace.vertical_tabs_panel_open = true;
|
|
workspace.snapshot(ctx.window_id(), false, ctx)
|
|
});
|
|
|
|
// Disable vertical tabs, then restore. The panel must stay closed.
|
|
app.update(|ctx| {
|
|
TabSettings::handle(ctx).update(ctx, |settings, ctx| {
|
|
report_if_error!(settings.use_vertical_tabs.set_value(false, ctx));
|
|
});
|
|
});
|
|
let restored = restored_workspace(&mut app, open_snapshot);
|
|
restored.read(&app, |workspace, _| {
|
|
assert!(!workspace.vertical_tabs_panel_open);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_vertical_tabs_panel_defaults_open_for_new_window_when_vertical_tabs_enabled() {
|
|
let _vertical_tabs_guard = FeatureFlag::VerticalTabs.override_enabled(true);
|
|
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
app.update(|ctx| {
|
|
TabSettings::handle(ctx).update(ctx, |settings, ctx| {
|
|
report_if_error!(settings.use_vertical_tabs.set_value(true, ctx));
|
|
});
|
|
});
|
|
|
|
let workspace = mock_workspace(&mut app);
|
|
|
|
workspace.read(&app, |workspace, _| {
|
|
assert!(workspace.vertical_tabs_panel_open);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_vertical_tabs_panel_inherits_transferred_tab_source_window_state() {
|
|
let _vertical_tabs_guard = FeatureFlag::VerticalTabs.override_enabled(true);
|
|
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
app.update(|ctx| {
|
|
TabSettings::handle(ctx).update(ctx, |settings, ctx| {
|
|
report_if_error!(settings.use_vertical_tabs.set_value(true, ctx));
|
|
});
|
|
});
|
|
|
|
let transferred_closed = transferred_tab_workspace(&mut app, false);
|
|
let transferred_open = transferred_tab_workspace(&mut app, true);
|
|
|
|
transferred_closed.read(&app, |workspace, _| {
|
|
assert!(!workspace.vertical_tabs_panel_open);
|
|
});
|
|
transferred_open.read(&app, |workspace, _| {
|
|
assert!(workspace.vertical_tabs_panel_open);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_vertical_tabs_panel_auto_shows_when_setting_enabled() {
|
|
let _vertical_tabs_guard = FeatureFlag::VerticalTabs.override_enabled(true);
|
|
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
let workspace = mock_workspace(&mut app);
|
|
|
|
workspace.read(&app, |workspace, _| {
|
|
assert!(!workspace.vertical_tabs_panel_open);
|
|
});
|
|
|
|
// Enabling vertical tabs should auto-open the panel.
|
|
workspace.update(&mut app, |_, ctx| {
|
|
TabSettings::handle(ctx).update(ctx, |settings, ctx| {
|
|
report_if_error!(settings.use_vertical_tabs.set_value(true, ctx));
|
|
});
|
|
});
|
|
workspace.read(&app, |workspace, _| {
|
|
assert!(workspace.vertical_tabs_panel_open);
|
|
});
|
|
|
|
// Disabling vertical tabs should auto-close the panel.
|
|
workspace.update(&mut app, |_, ctx| {
|
|
TabSettings::handle(ctx).update(ctx, |settings, ctx| {
|
|
report_if_error!(settings.use_vertical_tabs.set_value(false, ctx));
|
|
});
|
|
});
|
|
workspace.read(&app, |workspace, _| {
|
|
assert!(!workspace.vertical_tabs_panel_open);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_active_tab_bar_position_id_tracks_layout() {
|
|
// Cross-window drag hit-testing (`tab_bar_rects_for_window`) targets only
|
|
// the active tab presentation. Regression guard for the bug where the
|
|
// inactive horizontal bar registered as a drop zone while vertical tabs
|
|
// were enabled, lighting up a spurious placeholder over the top bar.
|
|
let _vertical_tabs_guard = FeatureFlag::VerticalTabs.override_enabled(true);
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
// Horizontal tabs (setting off): the horizontal bar is the drop zone.
|
|
app.read(|ctx| {
|
|
assert_eq!(active_tab_bar_position_id(ctx), TAB_BAR_POSITION_ID);
|
|
});
|
|
|
|
// Vertical tabs (setting on): only the vertical panel is the drop zone,
|
|
// so the horizontal bar no longer registers as a cross-window target.
|
|
app.update(|ctx| {
|
|
TabSettings::handle(ctx).update(ctx, |settings, ctx| {
|
|
report_if_error!(settings.use_vertical_tabs.set_value(true, ctx));
|
|
});
|
|
});
|
|
app.read(|ctx| {
|
|
assert_eq!(
|
|
active_tab_bar_position_id(ctx),
|
|
VERTICAL_TABS_PANEL_POSITION_ID
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_toggle_tab_configs_menu_opens_vertical_tabs_panel_and_menu() {
|
|
let _vertical_tabs_guard = FeatureFlag::VerticalTabs.override_enabled(true);
|
|
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
let workspace = mock_workspace(&mut app);
|
|
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
TabSettings::handle(ctx).update(ctx, |settings, ctx| {
|
|
report_if_error!(settings.use_vertical_tabs.set_value(true, ctx));
|
|
});
|
|
workspace.vertical_tabs_panel_open = true;
|
|
});
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
workspace.vertical_tabs_panel_open = false;
|
|
workspace.show_new_session_dropdown_menu = None;
|
|
|
|
workspace.handle_action(&WorkspaceAction::ToggleTabConfigsMenu, ctx);
|
|
|
|
assert!(workspace.vertical_tabs_panel_open);
|
|
assert!(workspace.show_new_session_dropdown_menu.is_some());
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_toggle_tab_configs_menu_keyboard_shortcut_selects_top_item() {
|
|
let _tab_configs_guard = FeatureFlag::TabConfigs.override_enabled(true);
|
|
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
let workspace = mock_workspace(&mut app);
|
|
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
workspace.show_new_session_dropdown_menu = None;
|
|
|
|
workspace.handle_action(&WorkspaceAction::ToggleTabConfigsMenu, ctx);
|
|
|
|
assert!(workspace.show_new_session_dropdown_menu.is_some());
|
|
assert_eq!(
|
|
workspace
|
|
.new_session_dropdown_menu
|
|
.read(ctx, |menu, _| menu.selected_index()),
|
|
Some(0)
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_pointer_opened_tab_configs_menu_does_not_select_top_item() {
|
|
let _tab_configs_guard = FeatureFlag::TabConfigs.override_enabled(true);
|
|
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
let workspace = mock_workspace(&mut app);
|
|
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
workspace.toggle_new_session_dropdown_menu(
|
|
crate::workspace::action::NewSessionMenuAnchor::Pointer(Vector2F::zero()),
|
|
ctx,
|
|
);
|
|
|
|
assert!(workspace.show_new_session_dropdown_menu.is_some());
|
|
assert_eq!(
|
|
workspace
|
|
.new_session_dropdown_menu
|
|
.read(ctx, |menu, _| menu.selected_index()),
|
|
None
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_open_tab_config_with_params_does_not_use_worktree_branch_as_implicit_title() {
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
let workspace = mock_workspace(&mut app);
|
|
let tab_config = crate::tab_configs::TabConfig {
|
|
name: "Untitled worktree".to_string(),
|
|
title: None,
|
|
color: None,
|
|
panes: vec![TabConfigPaneNode {
|
|
id: "main".to_string(),
|
|
pane_type: Some(TabConfigPaneType::Terminal),
|
|
split: None,
|
|
children: None,
|
|
is_focused: Some(true),
|
|
directory: None,
|
|
commands: Some(vec!["echo {{autogenerated_branch_name}}".to_string()]),
|
|
shell: None,
|
|
}],
|
|
params: HashMap::new(),
|
|
source_path: None,
|
|
};
|
|
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
workspace.open_tab_config_with_params(
|
|
tab_config.clone(),
|
|
HashMap::new(),
|
|
Some("mesa-coyote"),
|
|
ctx,
|
|
);
|
|
});
|
|
|
|
workspace.read(&app, |workspace, ctx| {
|
|
assert_eq!(workspace.tab_count(), 2);
|
|
assert_eq!(
|
|
workspace
|
|
.active_tab_pane_group()
|
|
.as_ref(ctx)
|
|
.custom_title(ctx),
|
|
None
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_open_tab_config_with_params_uses_explicit_title_template() {
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
let workspace = mock_workspace(&mut app);
|
|
let tab_config = crate::tab_configs::TabConfig {
|
|
name: "Titled worktree".to_string(),
|
|
title: Some("{{autogenerated_branch_name}}".to_string()),
|
|
color: None,
|
|
panes: vec![TabConfigPaneNode {
|
|
id: "main".to_string(),
|
|
pane_type: Some(TabConfigPaneType::Terminal),
|
|
split: None,
|
|
children: None,
|
|
is_focused: Some(true),
|
|
directory: None,
|
|
commands: Some(vec!["echo {{autogenerated_branch_name}}".to_string()]),
|
|
shell: None,
|
|
}],
|
|
params: HashMap::new(),
|
|
source_path: None,
|
|
};
|
|
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
workspace.open_tab_config_with_params(
|
|
tab_config.clone(),
|
|
HashMap::new(),
|
|
Some("mesa-coyote"),
|
|
ctx,
|
|
);
|
|
});
|
|
|
|
workspace.read(&app, |workspace, ctx| {
|
|
assert_eq!(workspace.tab_count(), 2);
|
|
assert_eq!(
|
|
workspace
|
|
.active_tab_pane_group()
|
|
.as_ref(ctx)
|
|
.custom_title(ctx),
|
|
Some("mesa-coyote".to_string())
|
|
);
|
|
});
|
|
});
|
|
}
|
|
#[test]
|
|
fn test_toggle_tab_configs_menu_does_not_change_vertical_tabs_panel_in_horizontal_mode() {
|
|
let _vertical_tabs_guard = FeatureFlag::VerticalTabs.override_enabled(true);
|
|
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
let workspace = mock_workspace(&mut app);
|
|
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
TabSettings::handle(ctx).update(ctx, |settings, ctx| {
|
|
report_if_error!(settings.use_vertical_tabs.set_value(false, ctx));
|
|
});
|
|
workspace.vertical_tabs_panel_open = true;
|
|
workspace.show_new_session_dropdown_menu = None;
|
|
|
|
workspace.handle_action(&WorkspaceAction::ToggleTabConfigsMenu, ctx);
|
|
|
|
assert!(workspace.vertical_tabs_panel_open);
|
|
assert!(workspace.show_new_session_dropdown_menu.is_some());
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_unified_new_session_menu_uses_new_worktree_config_label_and_order() {
|
|
let _tab_configs_guard = FeatureFlag::TabConfigs.override_enabled(true);
|
|
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
let workspace = mock_workspace(&mut app);
|
|
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
let labels = workspace
|
|
.unified_new_session_menu_items(ctx)
|
|
.iter()
|
|
.map(new_session_menu_label)
|
|
.collect::<Vec<_>>();
|
|
|
|
assert!(!labels.iter().any(|label| label == "Worktree in"));
|
|
|
|
let separator_index = labels
|
|
.iter()
|
|
.position(|label| label == "---")
|
|
.expect("expected a separator in the new-session menu");
|
|
|
|
assert_eq!(
|
|
labels.get(separator_index + 1),
|
|
Some(&"New worktree config".to_string())
|
|
);
|
|
assert_eq!(
|
|
labels.get(separator_index + 2),
|
|
Some(&"New tab config".to_string())
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_unified_new_session_menu_includes_reopen_closed_session() {
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
let workspace = mock_workspace(&mut app);
|
|
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
let menu_items = workspace.unified_new_session_menu_items(ctx);
|
|
assert!(matches!(
|
|
menu_items.get(menu_items.len() - 2),
|
|
Some(MenuItem::Separator)
|
|
));
|
|
|
|
let reopen_item = reopen_closed_session_menu_item(&menu_items);
|
|
assert!(reopen_item.is_disabled());
|
|
assert!(matches!(
|
|
reopen_item.on_select_action(),
|
|
Some(action) if matches!(action, WorkspaceAction::ReopenClosedSession)
|
|
));
|
|
|
|
workspace.add_terminal_tab(false, ctx);
|
|
workspace.remove_tab(workspace.active_tab_index(), true, true, ctx);
|
|
|
|
let menu_items = workspace.unified_new_session_menu_items(ctx);
|
|
let reopen_item = reopen_closed_session_menu_item(&menu_items);
|
|
assert!(!reopen_item.is_disabled());
|
|
});
|
|
});
|
|
}
|
|
|
|
#[cfg(feature = "local_fs")]
|
|
#[test]
|
|
fn test_worktree_sidecar_search_editor_proxies_navigation_and_escape() {
|
|
let _tab_configs_guard = FeatureFlag::TabConfigs.override_enabled(true);
|
|
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
let workspace = mock_workspace(&mut app);
|
|
let temp_root = TempDir::new().expect("failed to create temp dir");
|
|
let alpha_repo = temp_root.path().join("alpha-repo");
|
|
let beta_repo = temp_root.path().join("beta-repo");
|
|
std::fs::create_dir_all(&alpha_repo).expect("failed to create alpha repo dir");
|
|
std::fs::create_dir_all(&beta_repo).expect("failed to create beta repo dir");
|
|
|
|
workspace.update(&mut app, |_, ctx| {
|
|
PersistedWorkspace::handle(ctx).update(ctx, |persisted, ctx| {
|
|
persisted.user_added_workspace(alpha_repo.clone(), ctx);
|
|
persisted.user_added_workspace(beta_repo.clone(), ctx);
|
|
});
|
|
});
|
|
|
|
open_worktree_sidecar(&workspace, &mut app);
|
|
|
|
workspace.read(&app, |workspace, ctx| {
|
|
assert!(workspace.show_new_session_sidecar);
|
|
assert!(workspace.worktree_sidecar_search_editor.is_focused(ctx));
|
|
assert_eq!(
|
|
workspace
|
|
.new_session_sidecar_menu
|
|
.read(ctx, |menu, _| menu.selected_index()),
|
|
Some(1)
|
|
);
|
|
});
|
|
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
workspace
|
|
.worktree_sidecar_search_editor
|
|
.update(ctx, |_, ctx| {
|
|
ctx.emit(Event::Navigate(NavigationKey::Down));
|
|
});
|
|
});
|
|
workspace.read(&app, |workspace, ctx| {
|
|
assert_eq!(
|
|
workspace
|
|
.new_session_sidecar_menu
|
|
.read(ctx, |menu, _| menu.selected_index()),
|
|
Some(2)
|
|
);
|
|
});
|
|
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
workspace
|
|
.worktree_sidecar_search_editor
|
|
.update(ctx, |_, ctx| {
|
|
ctx.emit(Event::Navigate(NavigationKey::Up));
|
|
});
|
|
});
|
|
workspace.read(&app, |workspace, ctx| {
|
|
assert_eq!(
|
|
workspace
|
|
.new_session_sidecar_menu
|
|
.read(ctx, |menu, _| menu.selected_index()),
|
|
Some(1)
|
|
);
|
|
});
|
|
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
workspace
|
|
.worktree_sidecar_search_editor
|
|
.update(ctx, |editor, ctx| {
|
|
editor.set_buffer_text("beta", ctx);
|
|
});
|
|
});
|
|
workspace.read(&app, |workspace, ctx| {
|
|
assert_eq!(workspace.worktree_sidecar_search_query, "beta");
|
|
assert_eq!(
|
|
workspace
|
|
.new_session_sidecar_menu
|
|
.read(ctx, |menu, _| menu.items_len()),
|
|
2
|
|
);
|
|
assert_eq!(
|
|
workspace
|
|
.new_session_sidecar_menu
|
|
.read(ctx, |menu, _| menu.selected_index()),
|
|
Some(1)
|
|
);
|
|
});
|
|
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
workspace
|
|
.worktree_sidecar_search_editor
|
|
.update(ctx, |_, ctx| {
|
|
ctx.emit(Event::Escape);
|
|
});
|
|
});
|
|
workspace.read(&app, |workspace, ctx| {
|
|
assert!(workspace.show_new_session_dropdown_menu.is_none());
|
|
assert!(!workspace.show_new_session_sidecar);
|
|
assert!(workspace.worktree_sidecar_search_query.is_empty());
|
|
assert!(workspace
|
|
.worktree_sidecar_search_editor
|
|
.as_ref(ctx)
|
|
.buffer_text(ctx)
|
|
.is_empty());
|
|
});
|
|
});
|
|
}
|
|
|
|
#[cfg(feature = "local_fs")]
|
|
#[test]
|
|
fn test_worktree_sidecar_hides_linked_worktrees_from_repo_list() {
|
|
let _tab_configs_guard = FeatureFlag::TabConfigs.override_enabled(true);
|
|
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
let workspace = mock_workspace(&mut app);
|
|
let temp_root = TempDir::new().expect("failed to create temp dir");
|
|
let main_repo = temp_root.path().join("main-repo");
|
|
let linked_worktree = temp_root.path().join("linked-worktree");
|
|
let external_git_dir = main_repo
|
|
.join(".git")
|
|
.join("worktrees")
|
|
.join("linked-worktree");
|
|
|
|
std::fs::create_dir_all(&main_repo).expect("failed to create main repo dir");
|
|
std::fs::create_dir_all(&linked_worktree).expect("failed to create linked worktree dir");
|
|
std::fs::create_dir_all(&external_git_dir).expect("failed to create external git dir");
|
|
|
|
workspace.update(&mut app, |_, ctx| {
|
|
PersistedWorkspace::handle(ctx).update(ctx, |persisted, ctx| {
|
|
persisted.user_added_workspace(main_repo.clone(), ctx);
|
|
persisted.user_added_workspace(linked_worktree.clone(), ctx);
|
|
});
|
|
|
|
let main_repo_canon =
|
|
CanonicalizedPath::try_from(main_repo.as_path()).expect("canonical main repo");
|
|
let linked_worktree_canon = CanonicalizedPath::try_from(linked_worktree.as_path())
|
|
.expect("canonical linked worktree");
|
|
let external_git_dir_canon = CanonicalizedPath::try_from(external_git_dir.as_path())
|
|
.expect("canonical external git dir");
|
|
|
|
let main_repo_std: galaxy_util::standardized_path::StandardizedPath =
|
|
main_repo_canon.into();
|
|
let linked_worktree_std: galaxy_util::standardized_path::StandardizedPath =
|
|
linked_worktree_canon.into();
|
|
let external_git_dir_std: galaxy_util::standardized_path::StandardizedPath =
|
|
external_git_dir_canon.into();
|
|
|
|
DetectedRepositories::handle(ctx).update(ctx, |repos, _ctx| {
|
|
repos.insert_test_repo_root(main_repo_std.clone());
|
|
repos.insert_test_repo_root(linked_worktree_std.clone());
|
|
});
|
|
|
|
DirectoryWatcher::handle(ctx).update(ctx, |watcher, ctx| {
|
|
watcher
|
|
.add_directory_with_git_dir(main_repo_std, None, ctx)
|
|
.expect("register main repo");
|
|
watcher
|
|
.add_directory_with_git_dir(
|
|
linked_worktree_std,
|
|
Some(external_git_dir_std),
|
|
ctx,
|
|
)
|
|
.expect("register linked worktree");
|
|
});
|
|
});
|
|
|
|
open_worktree_sidecar(&workspace, &mut app);
|
|
|
|
workspace.read(&app, |workspace, ctx| {
|
|
let labels = workspace.new_session_sidecar_menu.read(ctx, |menu, _| {
|
|
menu.items()
|
|
.iter()
|
|
.filter_map(|item| match item {
|
|
MenuItem::Item(fields) => Some(fields.label().to_string()),
|
|
_ => None,
|
|
})
|
|
.collect::<Vec<_>>()
|
|
});
|
|
|
|
let main_repo_label = main_repo.to_string_lossy().to_string();
|
|
let linked_worktree_label = linked_worktree.to_string_lossy().to_string();
|
|
|
|
assert!(labels.iter().any(|label| label == "Search repos"));
|
|
assert!(labels.iter().any(|label| label == &main_repo_label));
|
|
assert!(!labels.iter().any(|label| label == &linked_worktree_label));
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_vertical_tabs_context_menu_does_not_show_hover_only_tab_bar() {
|
|
let _full_screen_zen_mode_guard = FeatureFlag::FullScreenZenMode.override_enabled(true);
|
|
let _vertical_tabs_guard = FeatureFlag::VerticalTabs.override_enabled(true);
|
|
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
let workspace = mock_workspace(&mut app);
|
|
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
TabSettings::handle(ctx).update(ctx, |settings, ctx| {
|
|
report_if_error!(settings
|
|
.workspace_decoration_visibility
|
|
.set_value(WorkspaceDecorationVisibility::OnHover, ctx));
|
|
report_if_error!(settings.use_vertical_tabs.set_value(true, ctx));
|
|
});
|
|
workspace.should_show_ai_assistant_warm_welcome = false;
|
|
workspace.vertical_tabs_panel_open = true;
|
|
|
|
workspace.show_tab_right_click_menu =
|
|
Some((0, TabContextMenuAnchor::Pointer(Vector2F::zero())));
|
|
|
|
assert_eq!(workspace.tab_bar_mode(ctx), ShowTabBar::Hidden);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_standard_tab_context_menu_shows_hover_only_tab_bar() {
|
|
let _full_screen_zen_mode_guard = FeatureFlag::FullScreenZenMode.override_enabled(true);
|
|
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
let workspace = mock_workspace(&mut app);
|
|
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
TabSettings::handle(ctx).update(ctx, |settings, ctx| {
|
|
report_if_error!(settings
|
|
.workspace_decoration_visibility
|
|
.set_value(WorkspaceDecorationVisibility::OnHover, ctx));
|
|
});
|
|
workspace.should_show_ai_assistant_warm_welcome = false;
|
|
|
|
workspace.show_tab_right_click_menu =
|
|
Some((0, TabContextMenuAnchor::Pointer(Vector2F::zero())));
|
|
|
|
assert_eq!(workspace.tab_bar_mode(ctx), ShowTabBar::Stacked);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_open_cloud_agent_setup_guide_action_opens_management_view_and_is_idempotent() {
|
|
let _agent_management_guard = FeatureFlag::AgentManagementView.override_enabled(true);
|
|
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
let workspace = mock_workspace(&mut app);
|
|
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
assert!(
|
|
!workspace
|
|
.current_workspace_state
|
|
.is_agent_management_view_open
|
|
);
|
|
|
|
workspace.handle_action(&WorkspaceAction::OpenCloudAgentSetupGuide, ctx);
|
|
assert!(
|
|
workspace
|
|
.current_workspace_state
|
|
.is_agent_management_view_open
|
|
);
|
|
assert!(workspace
|
|
.agent_management_view
|
|
.as_ref(ctx)
|
|
.is_showing_setup_guide());
|
|
|
|
workspace.handle_action(&WorkspaceAction::OpenCloudAgentSetupGuide, ctx);
|
|
assert!(
|
|
workspace
|
|
.current_workspace_state
|
|
.is_agent_management_view_open
|
|
);
|
|
assert!(workspace
|
|
.agent_management_view
|
|
.as_ref(ctx)
|
|
.is_showing_setup_guide());
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_tab_mru_order() {
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
let workspace = mock_workspace(&mut app);
|
|
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
workspace.add_terminal_tab(false, ctx);
|
|
workspace.add_terminal_tab(false, ctx);
|
|
|
|
let id_a = workspace.tabs[0].pane_group.id();
|
|
let id_b = workspace.tabs[1].pane_group.id();
|
|
let id_c = workspace.tabs[2].pane_group.id();
|
|
|
|
workspace.handle_action(&WorkspaceAction::ActivateTab(0), ctx);
|
|
workspace.handle_action(&WorkspaceAction::ActivateTab(1), ctx);
|
|
workspace.handle_action(&WorkspaceAction::ActivateTab(2), ctx);
|
|
workspace.handle_action(&WorkspaceAction::ActivateTab(0), ctx);
|
|
|
|
assert_eq!(workspace.tab_mru_order(), &[id_a, id_c, id_b]);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_create_new_tab_group_groups_active_tab() {
|
|
let _grouped_tabs_guard = FeatureFlag::GroupedTabs.override_enabled(true);
|
|
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
let workspace = mock_workspace(&mut app);
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
// Workspace starts with one tab from `Empty` source. Create a tab
|
|
// group and verify the active tab is assigned to it.
|
|
assert_eq!(workspace.tab_count(), 1);
|
|
assert!(workspace.tabs[0].group_id.is_none());
|
|
assert!(workspace.tab_groups.is_empty());
|
|
|
|
workspace.handle_action(
|
|
&WorkspaceAction::SelectNewSessionMenuItem(NewSessionMenuItem::CreateNewTabGroup),
|
|
ctx,
|
|
);
|
|
|
|
assert_eq!(workspace.tab_groups.len(), 1);
|
|
let group_id = workspace.tabs[0]
|
|
.group_id
|
|
.expect("active tab should be assigned to the new group");
|
|
assert!(workspace.tab_groups.contains_key(&group_id));
|
|
// New groups start expanded so members are visible.
|
|
assert!(!workspace.tab_groups[&group_id].collapsed);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_new_tab_group_from_tab_keeps_tab_in_place() {
|
|
let _grouped_tabs_guard = FeatureFlag::GroupedTabs.override_enabled(true);
|
|
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
let workspace = mock_workspace(&mut app);
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
// Build a three-tab workspace (starts with one tab).
|
|
workspace.add_terminal_tab(false, ctx);
|
|
workspace.add_terminal_tab(false, ctx);
|
|
assert_eq!(workspace.tab_count(), 3);
|
|
|
|
// Target the last tab so a move-to-top would be observable.
|
|
let target_index = workspace.tab_count() - 1;
|
|
let target_id = workspace.tabs[target_index].pane_group.id();
|
|
|
|
workspace.handle_action(&WorkspaceAction::NewTabGroupFromTab(target_index), ctx);
|
|
|
|
// The tab stays at its original index instead of jumping to the top.
|
|
assert_eq!(workspace.tabs[target_index].pane_group.id(), target_id);
|
|
assert_eq!(workspace.active_tab_index(), target_index);
|
|
|
|
let group_id = workspace.tabs[target_index]
|
|
.group_id
|
|
.expect("target tab should be assigned to the new group");
|
|
assert!(workspace.tab_groups.contains_key(&group_id));
|
|
|
|
// The other tabs remain ungrouped.
|
|
assert!(workspace.tabs[0].group_id.is_none());
|
|
assert!(workspace.tabs[1].group_id.is_none());
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_new_tab_group_from_selected_tabs_anchors_at_earliest_tab() {
|
|
let _grouped_tabs_guard = FeatureFlag::GroupedTabs.override_enabled(true);
|
|
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
let workspace = mock_workspace(&mut app);
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
// Build a four-tab workspace (starts with one tab).
|
|
workspace.add_terminal_tab(false, ctx);
|
|
workspace.add_terminal_tab(false, ctx);
|
|
workspace.add_terminal_tab(false, ctx);
|
|
assert_eq!(workspace.tab_count(), 4);
|
|
|
|
let id0 = workspace.tabs[0].pane_group.id();
|
|
let id1 = workspace.tabs[1].pane_group.id();
|
|
let id2 = workspace.tabs[2].pane_group.id();
|
|
let id3 = workspace.tabs[3].pane_group.id();
|
|
|
|
// Select two non-adjacent tabs (indices 1 and 3) with the active
|
|
// tab among the selection so no extra tab is pulled in.
|
|
workspace.activate_tab(1, ctx);
|
|
workspace.tabs[1].in_multi_selection = true;
|
|
workspace.tabs[3].in_multi_selection = true;
|
|
|
|
workspace.handle_action(&WorkspaceAction::NewTabGroupFromSelectedTabs, ctx);
|
|
|
|
// The group block lands at the earliest selected tab's position
|
|
// (index 1), preserving the relative order of its members.
|
|
let order: Vec<_> = workspace
|
|
.tabs
|
|
.iter()
|
|
.map(|tab| tab.pane_group.id())
|
|
.collect();
|
|
assert_eq!(order, vec![id0, id1, id3, id2]);
|
|
|
|
// Both selected tabs share the new group; the others stay ungrouped.
|
|
let group_id = workspace.tabs[1]
|
|
.group_id
|
|
.expect("earliest selected tab should join the new group");
|
|
assert_eq!(workspace.tabs[2].group_id, Some(group_id));
|
|
assert!(workspace.tabs[0].group_id.is_none());
|
|
assert!(workspace.tabs[3].group_id.is_none());
|
|
|
|
// Selection flags are cleared after grouping.
|
|
assert!(workspace.tabs.iter().all(|tab| !tab.in_multi_selection));
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_new_tab_group_from_tab_in_group_anchors_after_group() {
|
|
// Pulling a tab out of the middle of an existing group must not split
|
|
// that group: the new single-tab group should land just past the old
|
|
// group's last remaining member.
|
|
let _grouped_tabs_guard = FeatureFlag::GroupedTabs.override_enabled(true);
|
|
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
let workspace = mock_workspace(&mut app);
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
// Build a four-tab workspace (starts with one tab).
|
|
workspace.add_terminal_tab(false, ctx);
|
|
workspace.add_terminal_tab(false, ctx);
|
|
workspace.add_terminal_tab(false, ctx);
|
|
assert_eq!(workspace.tab_count(), 4);
|
|
|
|
// Group the first three tabs (indices 0, 1, 2) into one group,
|
|
// leaving the last tab ungrouped.
|
|
let group = TabGroup::new();
|
|
let existing_group_id = group.id;
|
|
workspace.tab_groups.insert(existing_group_id, group);
|
|
for index in 0..3 {
|
|
workspace.tabs[index].group_id = Some(existing_group_id);
|
|
}
|
|
|
|
let id0 = workspace.tabs[0].pane_group.id();
|
|
let middle_id = workspace.tabs[1].pane_group.id();
|
|
let id2 = workspace.tabs[2].pane_group.id();
|
|
let id3 = workspace.tabs[3].pane_group.id();
|
|
|
|
// Create a new group from the middle member of the existing group.
|
|
workspace.handle_action(&WorkspaceAction::NewTabGroupFromTab(1), ctx);
|
|
|
|
// The pulled tab lands right after the old group's run, so the old
|
|
// group's members stay contiguous: [g0, g2, new, ungrouped].
|
|
let order: Vec<_> = workspace
|
|
.tabs
|
|
.iter()
|
|
.map(|tab| tab.pane_group.id())
|
|
.collect();
|
|
assert_eq!(order, vec![id0, id2, middle_id, id3]);
|
|
|
|
let new_group_id = workspace.tabs[2]
|
|
.group_id
|
|
.expect("pulled tab should be in the new group");
|
|
assert_ne!(new_group_id, existing_group_id);
|
|
assert_eq!(workspace.active_tab_index(), 2);
|
|
|
|
// The old group keeps its two contiguous members.
|
|
assert_eq!(workspace.tabs[0].group_id, Some(existing_group_id));
|
|
assert_eq!(workspace.tabs[1].group_id, Some(existing_group_id));
|
|
assert!(workspace.tabs[3].group_id.is_none());
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_new_tab_group_from_selected_tabs_in_group_anchors_after_group() {
|
|
// When the earliest selected tab sits inside an existing group, the new
|
|
// group block is anchored past that group's last surviving member so the
|
|
// existing group is never split.
|
|
let _grouped_tabs_guard = FeatureFlag::GroupedTabs.override_enabled(true);
|
|
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
let workspace = mock_workspace(&mut app);
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
// Build a five-tab workspace (starts with one tab).
|
|
for _ in 0..4 {
|
|
workspace.add_terminal_tab(false, ctx);
|
|
}
|
|
assert_eq!(workspace.tab_count(), 5);
|
|
|
|
// Group the first three tabs (indices 0, 1, 2); leave 3 and 4 loose.
|
|
let group = TabGroup::new();
|
|
let existing_group_id = group.id;
|
|
workspace.tab_groups.insert(existing_group_id, group);
|
|
for index in 0..3 {
|
|
workspace.tabs[index].group_id = Some(existing_group_id);
|
|
}
|
|
|
|
let id0 = workspace.tabs[0].pane_group.id();
|
|
let id1 = workspace.tabs[1].pane_group.id();
|
|
let id2 = workspace.tabs[2].pane_group.id();
|
|
let id3 = workspace.tabs[3].pane_group.id();
|
|
let id4 = workspace.tabs[4].pane_group.id();
|
|
|
|
// Select the middle member of the group (index 1) and the trailing
|
|
// ungrouped tab (index 4), with the active tab among the selection.
|
|
workspace.activate_tab(1, ctx);
|
|
workspace.tabs[1].in_multi_selection = true;
|
|
workspace.tabs[4].in_multi_selection = true;
|
|
|
|
workspace.handle_action(&WorkspaceAction::NewTabGroupFromSelectedTabs, ctx);
|
|
|
|
// The block is placed just past the old group's surviving run
|
|
// (g0, g2), keeping that group contiguous:
|
|
// [g0, g2, new1, new4, id3].
|
|
let order: Vec<_> = workspace
|
|
.tabs
|
|
.iter()
|
|
.map(|tab| tab.pane_group.id())
|
|
.collect();
|
|
assert_eq!(order, vec![id0, id2, id1, id4, id3]);
|
|
|
|
let new_group_id = workspace.tabs[2]
|
|
.group_id
|
|
.expect("selected tab should be in the new group");
|
|
assert_ne!(new_group_id, existing_group_id);
|
|
assert_eq!(workspace.tabs[3].group_id, Some(new_group_id));
|
|
|
|
// Old group stays contiguous with its two surviving members.
|
|
assert_eq!(workspace.tabs[0].group_id, Some(existing_group_id));
|
|
assert_eq!(workspace.tabs[1].group_id, Some(existing_group_id));
|
|
assert!(workspace.tabs[4].group_id.is_none());
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_toggle_tab_group_collapsed_flips_state() {
|
|
let _grouped_tabs_guard = FeatureFlag::GroupedTabs.override_enabled(true);
|
|
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
let workspace = mock_workspace(&mut app);
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
workspace.handle_action(
|
|
&WorkspaceAction::SelectNewSessionMenuItem(NewSessionMenuItem::CreateNewTabGroup),
|
|
ctx,
|
|
);
|
|
let group_id = workspace.tabs[0]
|
|
.group_id
|
|
.expect("active tab should be in a group");
|
|
assert!(!workspace.tab_groups[&group_id].collapsed);
|
|
|
|
workspace.handle_action(&WorkspaceAction::ToggleTabGroupCollapsed(group_id), ctx);
|
|
assert!(workspace.tab_groups[&group_id].collapsed);
|
|
|
|
workspace.handle_action(&WorkspaceAction::ToggleTabGroupCollapsed(group_id), ctx);
|
|
assert!(!workspace.tab_groups[&group_id].collapsed);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_close_tab_group_removes_group_and_members() {
|
|
let _grouped_tabs_guard = FeatureFlag::GroupedTabs.override_enabled(true);
|
|
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
let workspace = mock_workspace(&mut app);
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
// Create a group, then add another tab which inherits the
|
|
// active tab's group_id via `add_tab_with_pane_layout`.
|
|
workspace.handle_action(
|
|
&WorkspaceAction::SelectNewSessionMenuItem(NewSessionMenuItem::CreateNewTabGroup),
|
|
ctx,
|
|
);
|
|
let group_id = workspace.tabs[workspace.active_tab_index()]
|
|
.group_id
|
|
.expect("active tab should be in a group");
|
|
|
|
workspace.add_terminal_tab(false, ctx);
|
|
|
|
let group_members: Vec<usize> = workspace
|
|
.tabs
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(_, tab)| tab.group_id == Some(group_id))
|
|
.map(|(idx, _)| idx)
|
|
.collect();
|
|
assert_eq!(
|
|
group_members.len(),
|
|
2,
|
|
"new tab should inherit the active tab's group_id"
|
|
);
|
|
|
|
workspace.handle_action(&WorkspaceAction::CloseTabGroup(group_id), ctx);
|
|
|
|
// All group members are closed and the group entry is removed.
|
|
assert!(!workspace.tab_groups.contains_key(&group_id));
|
|
assert!(workspace
|
|
.tabs
|
|
.iter()
|
|
.all(|tab| tab.group_id != Some(group_id)));
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_new_tab_with_after_all_tabs_setting_lands_top_level_at_end() {
|
|
// With `new_tab_placement = AfterAllTabs`, a new tab lands at the very end
|
|
// of the tab bar, outside any group — even when the active tab is in a
|
|
// group.
|
|
let _grouped_tabs_guard = FeatureFlag::GroupedTabs.override_enabled(true);
|
|
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
app.update(|ctx| {
|
|
TabSettings::handle(ctx).update(ctx, |settings, ctx| {
|
|
report_if_error!(settings
|
|
.new_tab_placement
|
|
.set_value(NewTabPlacement::AfterAllTabs, ctx));
|
|
});
|
|
});
|
|
|
|
let workspace = mock_workspace(&mut app);
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
// Build [g0, g1, ungrouped] by assigning membership directly, so the
|
|
// setup doesn't depend on new-tab placement behavior.
|
|
workspace.add_terminal_tab(false, ctx);
|
|
workspace.add_terminal_tab(false, ctx);
|
|
assert_eq!(workspace.tab_count(), 3);
|
|
|
|
let group = TabGroup::new();
|
|
let group_id = group.id;
|
|
workspace.tab_groups.insert(group_id, group);
|
|
workspace.tabs[0].group_id = Some(group_id);
|
|
workspace.tabs[1].group_id = Some(group_id);
|
|
|
|
// Activate a member of the group, then add a new tab.
|
|
workspace.activate_tab(0, ctx);
|
|
workspace.add_terminal_tab(false, ctx);
|
|
|
|
// The new tab lands at the very end of the bar and is top-level.
|
|
let last = workspace.tab_count() - 1;
|
|
assert_eq!(workspace.active_tab_index(), last);
|
|
assert_eq!(workspace.tabs[last].group_id, None);
|
|
|
|
// The group keeps exactly its original two contiguous members.
|
|
let group_members: Vec<usize> = workspace
|
|
.tabs
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(_, t)| t.group_id == Some(group_id))
|
|
.map(|(idx, _)| idx)
|
|
.collect();
|
|
assert_eq!(group_members, vec![0, 1]);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_new_tab_with_after_current_tab_setting_lands_after_active_tab_in_group() {
|
|
// With `new_tab_placement = AfterCurrentTab` and the active tab in the
|
|
// middle of a group, a new tab should land immediately after the active
|
|
// tab and inherit the group_id, preserving group contiguity rather than
|
|
// jumping to the end of the group or past it.
|
|
let _grouped_tabs_guard = FeatureFlag::GroupedTabs.override_enabled(true);
|
|
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
app.update(|ctx| {
|
|
TabSettings::handle(ctx).update(ctx, |settings, ctx| {
|
|
report_if_error!(settings
|
|
.new_tab_placement
|
|
.set_value(NewTabPlacement::AfterCurrentTab, ctx));
|
|
});
|
|
});
|
|
|
|
let workspace = mock_workspace(&mut app);
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
// Create a group and grow it to two contiguous members so we can
|
|
// activate the first one (i.e. a member that isn't at the end of
|
|
// the group's run).
|
|
workspace.handle_action(
|
|
&WorkspaceAction::SelectNewSessionMenuItem(NewSessionMenuItem::CreateNewTabGroup),
|
|
ctx,
|
|
);
|
|
let group_id = workspace.tabs[workspace.active_tab_index()]
|
|
.group_id
|
|
.expect("active tab should be in a group");
|
|
workspace.add_terminal_tab(false, ctx);
|
|
|
|
// Activate the first grouped tab so the next insertion happens in
|
|
// the middle of the group's contiguous run.
|
|
let first_grouped_idx = workspace
|
|
.tabs
|
|
.iter()
|
|
.position(|t| t.group_id == Some(group_id))
|
|
.expect("expected at least one grouped tab");
|
|
workspace.activate_tab(first_grouped_idx, ctx);
|
|
|
|
let expected_new_idx = first_grouped_idx + 1;
|
|
|
|
workspace.add_terminal_tab(false, ctx);
|
|
|
|
// The new tab lands immediately after the previously-active
|
|
// grouped tab, inherits its group_id, and keeps the group's run
|
|
// contiguous.
|
|
assert_eq!(workspace.active_tab_index(), expected_new_idx);
|
|
assert_eq!(
|
|
workspace.tabs[expected_new_idx].group_id,
|
|
Some(group_id),
|
|
"new tab should inherit the active tab's group_id"
|
|
);
|
|
|
|
let group_indices: Vec<usize> = workspace
|
|
.tabs
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(_, t)| t.group_id == Some(group_id))
|
|
.map(|(idx, _)| idx)
|
|
.collect();
|
|
assert_eq!(
|
|
group_indices.len(),
|
|
3,
|
|
"group should have grown to three members"
|
|
);
|
|
assert!(
|
|
group_indices.windows(2).all(|w| w[1] == w[0] + 1),
|
|
"group's tab indices should be contiguous, got {group_indices:?}"
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_move_tab_to_group_expands_collapsed_group() {
|
|
let _grouped_tabs_guard = FeatureFlag::GroupedTabs.override_enabled(true);
|
|
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
let workspace = mock_workspace(&mut app);
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
// Create a group and a second ungrouped tab.
|
|
workspace.handle_action(
|
|
&WorkspaceAction::SelectNewSessionMenuItem(NewSessionMenuItem::CreateNewTabGroup),
|
|
ctx,
|
|
);
|
|
let group_id = workspace.tabs[workspace.active_tab_index()]
|
|
.group_id
|
|
.expect("active tab should be in a group");
|
|
workspace.add_terminal_tab(false, ctx);
|
|
|
|
// Find the ungrouped tab.
|
|
let ungrouped_idx = workspace
|
|
.tabs
|
|
.iter()
|
|
.position(|t| t.group_id.is_none())
|
|
.expect("expected an ungrouped tab");
|
|
|
|
// Collapse the group, then move the ungrouped tab into it.
|
|
workspace.handle_action(&WorkspaceAction::ToggleTabGroupCollapsed(group_id), ctx);
|
|
assert!(
|
|
workspace.tab_groups[&group_id].collapsed,
|
|
"group should be collapsed"
|
|
);
|
|
|
|
workspace.handle_action(
|
|
&WorkspaceAction::MoveTabToGroup {
|
|
tab_index: ungrouped_idx,
|
|
group_id,
|
|
},
|
|
ctx,
|
|
);
|
|
|
|
assert!(
|
|
!workspace.tab_groups[&group_id].collapsed,
|
|
"group should expand when a tab is moved into it"
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_move_selected_tabs_to_group_expands_collapsed_group() {
|
|
let _grouped_tabs_guard = FeatureFlag::GroupedTabs.override_enabled(true);
|
|
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
let workspace = mock_workspace(&mut app);
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
// Add two extra tabs while no group exists so they remain ungrouped.
|
|
workspace.add_terminal_tab(false, ctx);
|
|
workspace.add_terminal_tab(false, ctx);
|
|
|
|
// Create a group from the first tab (moves it to index 0) leaving
|
|
// the other two tabs ungrouped.
|
|
workspace.handle_action(&WorkspaceAction::NewTabGroupFromTab(0), ctx);
|
|
let group_id = workspace.tabs[0]
|
|
.group_id
|
|
.expect("tab 0 should be in the new group");
|
|
|
|
// Collapse the group.
|
|
workspace.handle_action(&WorkspaceAction::ToggleTabGroupCollapsed(group_id), ctx);
|
|
assert!(
|
|
workspace.tab_groups[&group_id].collapsed,
|
|
"group should be collapsed"
|
|
);
|
|
|
|
// Select the two ungrouped tabs and move them to the group.
|
|
let ungrouped_indices: Vec<usize> = workspace
|
|
.tabs
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(_, t)| t.group_id.is_none())
|
|
.map(|(i, _)| i)
|
|
.collect();
|
|
assert_eq!(ungrouped_indices.len(), 2);
|
|
workspace.activate_tab(ungrouped_indices[0], ctx);
|
|
workspace.tabs[ungrouped_indices[0]].in_multi_selection = true;
|
|
workspace.tabs[ungrouped_indices[1]].in_multi_selection = true;
|
|
|
|
workspace.handle_action(&WorkspaceAction::MoveSelectedTabsToGroup { group_id }, ctx);
|
|
|
|
assert!(
|
|
!workspace.tab_groups[&group_id].collapsed,
|
|
"group should expand when selected tabs are moved into it"
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_new_tab_in_group_expands_collapsed_group_non_member_active() {
|
|
// When the active tab is NOT a member of the group, `new_tab_in_group`
|
|
// must still expand the target group.
|
|
let _grouped_tabs_guard = FeatureFlag::GroupedTabs.override_enabled(true);
|
|
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
let workspace = mock_workspace(&mut app);
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
// Create a group, then activate an ungrouped tab so the active
|
|
// tab is NOT a member of the group.
|
|
workspace.handle_action(
|
|
&WorkspaceAction::SelectNewSessionMenuItem(NewSessionMenuItem::CreateNewTabGroup),
|
|
ctx,
|
|
);
|
|
let group_id = workspace.tabs[workspace.active_tab_index()]
|
|
.group_id
|
|
.expect("active tab should be in a group");
|
|
workspace.add_terminal_tab(false, ctx);
|
|
|
|
let ungrouped_idx = workspace
|
|
.tabs
|
|
.iter()
|
|
.position(|t| t.group_id.is_none())
|
|
.expect("expected an ungrouped tab");
|
|
workspace.activate_tab(ungrouped_idx, ctx);
|
|
|
|
// Collapse the group, then open a new tab inside it.
|
|
workspace.handle_action(&WorkspaceAction::ToggleTabGroupCollapsed(group_id), ctx);
|
|
assert!(
|
|
workspace.tab_groups[&group_id].collapsed,
|
|
"group should be collapsed"
|
|
);
|
|
|
|
workspace.handle_action(&WorkspaceAction::NewTabInGroup(group_id), ctx);
|
|
|
|
assert!(
|
|
!workspace.tab_groups[&group_id].collapsed,
|
|
"group should expand when a new tab is opened in it"
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_new_tab_in_group_expands_collapsed_group_member_active() {
|
|
// When the active tab IS a member of the group, `new_tab_in_group` takes
|
|
// the inheritance path; the group must still expand.
|
|
let _grouped_tabs_guard = FeatureFlag::GroupedTabs.override_enabled(true);
|
|
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
let workspace = mock_workspace(&mut app);
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
workspace.handle_action(
|
|
&WorkspaceAction::SelectNewSessionMenuItem(NewSessionMenuItem::CreateNewTabGroup),
|
|
ctx,
|
|
);
|
|
let group_id = workspace.tabs[workspace.active_tab_index()]
|
|
.group_id
|
|
.expect("active tab should be in a group");
|
|
|
|
// Collapse the group, keeping the group member as the active tab.
|
|
workspace.handle_action(&WorkspaceAction::ToggleTabGroupCollapsed(group_id), ctx);
|
|
assert!(
|
|
workspace.tab_groups[&group_id].collapsed,
|
|
"group should be collapsed"
|
|
);
|
|
|
|
workspace.handle_action(&WorkspaceAction::NewTabInGroup(group_id), ctx);
|
|
|
|
assert!(
|
|
!workspace.tab_groups[&group_id].collapsed,
|
|
"group should expand when a new tab is opened in it"
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_pin_unpin_ungrouped_tab_moves_to_and_from_boundary() {
|
|
let _pinned_guard = FeatureFlag::PinnedTabs.override_enabled(true);
|
|
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
let workspace = mock_workspace(&mut app);
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
workspace.add_terminal_tab(false, ctx);
|
|
workspace.add_terminal_tab(false, ctx);
|
|
assert_eq!(workspace.tab_count(), 3);
|
|
|
|
let id0 = workspace.tabs[0].pane_group.id();
|
|
let id1 = workspace.tabs[1].pane_group.id();
|
|
let id2 = workspace.tabs[2].pane_group.id();
|
|
|
|
// Pin tab at index 2: it should move to the front of the list.
|
|
workspace.handle_action(&WorkspaceAction::PinTab(2), ctx);
|
|
let order: Vec<_> = workspace.tabs.iter().map(|t| t.pane_group.id()).collect();
|
|
assert_eq!(order, vec![id2, id0, id1]);
|
|
assert!(workspace.tabs[0].pinned);
|
|
assert!(!workspace.tabs[1].pinned);
|
|
assert!(!workspace.tabs[2].pinned);
|
|
|
|
// Unpin tab at index 0: it should move to the start of the unpinned region.
|
|
workspace.handle_action(&WorkspaceAction::UnpinTab(0), ctx);
|
|
let order: Vec<_> = workspace.tabs.iter().map(|t| t.pane_group.id()).collect();
|
|
assert_eq!(order, vec![id2, id0, id1]);
|
|
assert!(workspace.tabs.iter().all(|t| !t.pinned));
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_pin_unpin_tab_group_moves_block_without_syncing_members() {
|
|
// The group's own `pinned` flag is the sole source of truth for grouped
|
|
// tabs — members keep `tab.pinned = false` regardless.
|
|
let _pinned_guard = FeatureFlag::PinnedTabs.override_enabled(true);
|
|
let _grouped_tabs_guard = FeatureFlag::GroupedTabs.override_enabled(true);
|
|
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
let workspace = mock_workspace(&mut app);
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
workspace.add_terminal_tab(false, ctx);
|
|
workspace.add_terminal_tab(false, ctx);
|
|
workspace.add_terminal_tab(false, ctx);
|
|
assert_eq!(workspace.tab_count(), 4);
|
|
|
|
let id0 = workspace.tabs[0].pane_group.id();
|
|
let id1 = workspace.tabs[1].pane_group.id();
|
|
let id2 = workspace.tabs[2].pane_group.id();
|
|
let id3 = workspace.tabs[3].pane_group.id();
|
|
|
|
// Group tabs at indices 2, 3.
|
|
let group = TabGroup::new();
|
|
let group_id = group.id;
|
|
workspace.tab_groups.insert(group_id, group);
|
|
workspace.tabs[2].group_id = Some(group_id);
|
|
workspace.tabs[3].group_id = Some(group_id);
|
|
|
|
// Pin the group: the block moves to the front; only the group's
|
|
// flag flips — member tabs keep `pinned = false`.
|
|
workspace.handle_action(&WorkspaceAction::PinTabGroup(group_id), ctx);
|
|
let order: Vec<_> = workspace.tabs.iter().map(|t| t.pane_group.id()).collect();
|
|
assert_eq!(order, vec![id2, id3, id0, id1]);
|
|
assert!(workspace.tab_groups[&group_id].pinned);
|
|
assert!(workspace.tabs.iter().all(|t| !t.pinned));
|
|
|
|
// Unpin the group: block moves to the start of the unpinned
|
|
// region; group's flag clears.
|
|
workspace.handle_action(&WorkspaceAction::UnpinTabGroup(group_id), ctx);
|
|
assert!(!workspace.tab_groups[&group_id].pinned);
|
|
assert!(workspace.tabs.iter().all(|t| !t.pinned));
|
|
|
|
// Group is still contiguous.
|
|
let group_indices: Vec<usize> = workspace
|
|
.tabs
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(_, t)| t.group_id == Some(group_id))
|
|
.map(|(i, _)| i)
|
|
.collect();
|
|
assert_eq!(group_indices.len(), 2);
|
|
assert_eq!(group_indices[1] - group_indices[0], 1);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_pin_tab_on_grouped_tab_extracts_then_pins() {
|
|
let _pinned_guard = FeatureFlag::PinnedTabs.override_enabled(true);
|
|
let _grouped_tabs_guard = FeatureFlag::GroupedTabs.override_enabled(true);
|
|
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
let workspace = mock_workspace(&mut app);
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
workspace.add_terminal_tab(false, ctx);
|
|
workspace.add_terminal_tab(false, ctx);
|
|
assert_eq!(workspace.tab_count(), 3);
|
|
|
|
let id0 = workspace.tabs[0].pane_group.id();
|
|
let id1 = workspace.tabs[1].pane_group.id();
|
|
let id2 = workspace.tabs[2].pane_group.id();
|
|
|
|
// Group tabs 0 and 1; tab 1 is the target.
|
|
let group = TabGroup::new();
|
|
let group_id = group.id;
|
|
workspace.tab_groups.insert(group_id, group);
|
|
workspace.tabs[0].group_id = Some(group_id);
|
|
workspace.tabs[1].group_id = Some(group_id);
|
|
|
|
// Pin tab at index 1: extracts from group, then pins as ungrouped.
|
|
workspace.handle_action(&WorkspaceAction::PinTab(1), ctx);
|
|
|
|
// Pinned tab (id1) is at the front, ungrouped.
|
|
assert_eq!(workspace.tabs[0].pane_group.id(), id1);
|
|
assert!(workspace.tabs[0].pinned);
|
|
assert!(workspace.tabs[0].group_id.is_none());
|
|
|
|
// Source group still has its one remaining member (id0).
|
|
assert_eq!(workspace.tabs[1].pane_group.id(), id0);
|
|
assert_eq!(workspace.tabs[1].group_id, Some(group_id));
|
|
assert!(!workspace.tabs[1].pinned);
|
|
|
|
// Ungrouped tab id2 remains untouched.
|
|
assert_eq!(workspace.tabs[2].pane_group.id(), id2);
|
|
assert!(workspace.tabs[2].group_id.is_none());
|
|
assert!(!workspace.tabs[2].pinned);
|
|
});
|
|
});
|
|
}
|
|
|
|
/// Regression test for #14241.
|
|
///
|
|
/// Creating a tab group opens the inline name editor and also spawns a terminal. About
|
|
/// a second later that terminal's bootstrap block becomes visible and takes focus, which
|
|
/// blurs the editor while the user is still typing. Blur used to be treated as
|
|
/// confirmation, so whatever fragment had been typed became the group's name — and was
|
|
/// persisted.
|
|
///
|
|
/// A rename the user never finished must not be committed.
|
|
#[test]
|
|
fn test_tab_group_rename_blur_does_not_commit_unfinished_name() {
|
|
let _grouped_tabs_guard = FeatureFlag::GroupedTabs.override_enabled(true);
|
|
|
|
App::test((), |mut app| async move {
|
|
initialize_app(&mut app);
|
|
|
|
let workspace = mock_workspace(&mut app);
|
|
workspace.update(&mut app, |workspace, ctx| {
|
|
workspace.handle_action(
|
|
&WorkspaceAction::SelectNewSessionMenuItem(NewSessionMenuItem::CreateNewTabGroup),
|
|
ctx,
|
|
);
|
|
let group_id = workspace.tabs[0]
|
|
.group_id
|
|
.expect("active tab should be assigned to the new group");
|
|
assert_eq!(workspace.tab_groups[&group_id].name, None);
|
|
|
|
workspace.rename_tab_group(group_id, ctx);
|
|
|
|
// The user gets three characters in before the terminal is ready.
|
|
workspace
|
|
.tab_group_rename_editor
|
|
.update(ctx, |editor, ctx| {
|
|
editor.clear_buffer_and_reset_undo_stack(ctx);
|
|
editor.user_insert("Bui", ctx);
|
|
});
|
|
|
|
// The auto-created terminal takes focus; the editor blurs with no user intent
|
|
// to finish.
|
|
workspace.handle_tab_group_rename_editor_event(&EditorEvent::Blurred, ctx);
|
|
|
|
assert_eq!(
|
|
workspace.tab_groups[&group_id].name, None,
|
|
"a rename interrupted by the terminal stealing focus must not be committed"
|
|
);
|
|
});
|
|
});
|
|
}
|