Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,976 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use warp_util::path::LineAndColumnArg;
|
||||
|
||||
use crate::ai::agent::api::ServerConversationToken;
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::agent::AIAgentExchangeId;
|
||||
use crate::ai::ambient_agents::AmbientAgentTaskId;
|
||||
use crate::ai::document::ai_document_model::{AIDocumentId, AIDocumentVersion};
|
||||
use crate::auth::auth_manager::LoginGatedFeature;
|
||||
use crate::drive::items::WarpDriveItemId;
|
||||
use crate::drive::CloudObjectTypeAndId;
|
||||
use crate::palette::PaletteMode;
|
||||
use crate::pane_group::PaneGroup;
|
||||
use crate::prompt::editor_modal::OpenSource as PromptEditorOpenSource;
|
||||
use crate::search;
|
||||
use crate::server::ids::SyncId;
|
||||
use crate::server::telemetry::{
|
||||
AddTabWithShellSource, AgentModeEntrypoint, PaletteSource, SharingDialogSource,
|
||||
};
|
||||
use crate::settings_view::{SettingsAction as SettingsTabAction, SettingsSection};
|
||||
use crate::tab::NewSessionMenuItem;
|
||||
use crate::tab_configs::TabConfig;
|
||||
use crate::terminal::available_shells::AvailableShell;
|
||||
use crate::terminal::view::inline_banner::ZeroStatePromptSuggestionType;
|
||||
use crate::themes::theme::AnsiColorIdentifier;
|
||||
use crate::themes::theme_chooser::ThemeChooserMode;
|
||||
use crate::workflows::{WorkflowSelectionSource, WorkflowSource, WorkflowType};
|
||||
use crate::workspace::PaneViewLocator;
|
||||
use session_sharing_protocol::common::SessionId;
|
||||
|
||||
use ui_components::lightbox;
|
||||
use warpui::accessibility::AccessibilityVerbosity;
|
||||
use warpui::geometry::rect::RectF;
|
||||
use warpui::geometry::vector::Vector2F;
|
||||
use warpui::platform::Cursor;
|
||||
use warpui::{EntityId, WeakViewHandle, WindowId};
|
||||
|
||||
use super::global_actions::{ForkFromExchange, ForkedConversationDestination};
|
||||
use super::tab_settings::{
|
||||
VerticalTabsCompactSubtitle, VerticalTabsDisplayGranularity, VerticalTabsPrimaryInfo,
|
||||
VerticalTabsTabItemMode, VerticalTabsViewMode,
|
||||
};
|
||||
use super::view::{OnboardingTutorial, WorkspaceBanner};
|
||||
|
||||
/// This enum determines how the search query is initialized when opening command search.
|
||||
#[derive(Clone, Default, Debug)]
|
||||
pub enum InitContent {
|
||||
/// Read the content of the active terminal input, and make that the initial search query.
|
||||
#[default]
|
||||
FromInputBuffer,
|
||||
/// Specify an exact string to initialize the query to.
|
||||
Custom(String),
|
||||
}
|
||||
|
||||
/// To initialize command search, we may want to specify a search filter, or the content of the
|
||||
/// query itself.
|
||||
#[derive(Clone, Default, Debug)]
|
||||
pub struct CommandSearchOptions {
|
||||
pub filter: Option<search::QueryFilter>,
|
||||
pub init_content: InitContent,
|
||||
}
|
||||
|
||||
/// Specifies how to restore a conversation when it's not already open in a pane.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, Default)]
|
||||
pub enum RestoreConversationLayout {
|
||||
/// Restore the conversation into the currently active pane.
|
||||
ActivePane,
|
||||
/// Restore the conversation in a new split pane.
|
||||
SplitPane,
|
||||
/// Restore the conversation in a new tab.
|
||||
#[default]
|
||||
NewTab,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum TabContextMenuAnchor {
|
||||
Pointer(Vector2F),
|
||||
VerticalTabsKebab,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum VerticalTabsPaneContextMenuTarget {
|
||||
ClickedPane(PaneViewLocator),
|
||||
ActivePane(PaneViewLocator),
|
||||
}
|
||||
|
||||
impl VerticalTabsPaneContextMenuTarget {
|
||||
pub fn locator(self) -> PaneViewLocator {
|
||||
match self {
|
||||
Self::ClickedPane(locator) | Self::ActivePane(locator) => locator,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum WorkspaceAction {
|
||||
ActivateTab(usize),
|
||||
ActivatePrevTab,
|
||||
ActivateNextTab,
|
||||
ActivateLastTab,
|
||||
CyclePrevSession,
|
||||
CycleNextSession,
|
||||
MoveActiveTabLeft,
|
||||
MoveActiveTabRight,
|
||||
MoveTabLeft(usize),
|
||||
MoveTabRight(usize),
|
||||
RenameTab(usize),
|
||||
ResetTabName(usize),
|
||||
RenamePane(PaneViewLocator),
|
||||
ResetPaneName(PaneViewLocator),
|
||||
RenameActiveTab,
|
||||
SetActiveTabName(String),
|
||||
ToggleTabRightClickMenu {
|
||||
tab_index: usize,
|
||||
anchor: TabContextMenuAnchor,
|
||||
},
|
||||
ToggleVerticalTabsPaneContextMenu {
|
||||
tab_index: usize,
|
||||
target: VerticalTabsPaneContextMenuTarget,
|
||||
position: Vector2F,
|
||||
},
|
||||
TabHoverWidthStart {
|
||||
width: f32,
|
||||
},
|
||||
TabHoverWidthEnd,
|
||||
ToggleTabBarOverflowMenu,
|
||||
ToggleWelcomeTips,
|
||||
CloseTab(usize),
|
||||
CloseActiveTab,
|
||||
CloseOtherTabs(usize),
|
||||
CloseNonActiveTabs,
|
||||
CloseTabsRight(usize),
|
||||
CloseTabsRightActiveTab,
|
||||
AddDefaultTab,
|
||||
AddTerminalTab {
|
||||
hide_homepage: bool,
|
||||
},
|
||||
AddTabWithShell {
|
||||
shell: AvailableShell,
|
||||
source: AddTabWithShellSource,
|
||||
},
|
||||
AddGetStartedTab,
|
||||
AddAmbientAgentTab,
|
||||
/// Add a new tab that immediately enters agent view with a new conversation.
|
||||
AddAgentTab,
|
||||
/// Add a new tab running a local Docker sandbox via `sbx`.
|
||||
AddDockerSandboxTab,
|
||||
OpenNewSessionMenu {
|
||||
position: Vector2F,
|
||||
},
|
||||
ToggleTabConfigsMenu,
|
||||
ToggleNewSessionMenu {
|
||||
position: Vector2F,
|
||||
is_vertical_tabs: bool,
|
||||
},
|
||||
SelectNewSessionMenuItem(NewSessionMenuItem),
|
||||
AutoupdateFailureLink,
|
||||
ApplyUpdate,
|
||||
LogOut,
|
||||
CopyVersion(&'static str),
|
||||
DownloadNewVersion,
|
||||
ConfigureKeybindingSettings {
|
||||
keybinding_name: Option<String>,
|
||||
},
|
||||
ShowSettings,
|
||||
ShowSettingsPage(SettingsSection),
|
||||
ShowSettingsPageWithSearch {
|
||||
search_query: String,
|
||||
section: Option<SettingsSection>,
|
||||
},
|
||||
ShowThemeChooser(ThemeChooserMode),
|
||||
ShowThemeChooserForActiveTheme,
|
||||
IncreaseFontSize,
|
||||
DecreaseFontSize,
|
||||
ResetFontSize,
|
||||
IncreaseZoom,
|
||||
DecreaseZoom,
|
||||
ResetZoom,
|
||||
ActivateTabByNumber(usize),
|
||||
OpenPalette {
|
||||
mode: PaletteMode,
|
||||
source: PaletteSource,
|
||||
query: Option<String>,
|
||||
},
|
||||
TogglePalette {
|
||||
mode: PaletteMode,
|
||||
source: PaletteSource,
|
||||
},
|
||||
ShowUpgrade,
|
||||
ShowReferralSettingsPage,
|
||||
JoinSlack,
|
||||
ViewUserDocs,
|
||||
ViewLatestChangelog,
|
||||
ViewPrivacyPolicy,
|
||||
SendFeedback,
|
||||
/// Open the log directory in the system file explorer with the current log file selected.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
ViewLogs,
|
||||
ChangeCursor(Cursor),
|
||||
ToggleBlockSnackbar,
|
||||
ToggleErrorUnderlining,
|
||||
ToggleSyntaxHighlighting,
|
||||
CheckForUpdate,
|
||||
ExportAllWarpDriveObjects,
|
||||
SetA11yVerbosityLevel(AccessibilityVerbosity),
|
||||
ToggleNotifications,
|
||||
ToggleTabColor {
|
||||
color: AnsiColorIdentifier,
|
||||
tab_index: usize,
|
||||
},
|
||||
OpenLaunchConfigSaveModal,
|
||||
SelectTabConfig(TabConfig),
|
||||
DispatchToSettingsTab(SettingsTabAction),
|
||||
ToggleResourceCenter,
|
||||
ToggleUserMenu,
|
||||
ToggleAIAssistant,
|
||||
ClickedAIAssistantIcon,
|
||||
ToggleKeybindingsPage,
|
||||
ShowCommandSearch(CommandSearchOptions),
|
||||
CreatePersonalNotebook,
|
||||
ImportToPersonalDrive,
|
||||
ImportToTeamDrive,
|
||||
CreateTeamNotebook,
|
||||
CreatePersonalWorkflow,
|
||||
CreateTeamWorkflow,
|
||||
CreatePersonalFolder,
|
||||
CreateTeamFolder,
|
||||
CreateTeamEnvVarCollection,
|
||||
CreatePersonalEnvVarCollection,
|
||||
CreatePersonalAIPrompt,
|
||||
CreateTeamAIPrompt,
|
||||
ToggleMouseReporting,
|
||||
ToggleScrollReporting,
|
||||
ToggleFocusReporting,
|
||||
StartTabDrag,
|
||||
DragTab {
|
||||
tab_index: usize,
|
||||
tab_position: RectF,
|
||||
},
|
||||
HandoffPendingTransfer {
|
||||
target_window_id: WindowId,
|
||||
insertion_index: usize,
|
||||
},
|
||||
ReverseHandoff {
|
||||
target_window_id: WindowId,
|
||||
target_insertion_index: usize,
|
||||
},
|
||||
DropTab,
|
||||
FinalizeDropTab,
|
||||
/// Toggles the left panel. In Code Mode V1 this toggles Warp Drive.
|
||||
/// In Code Mode V2 this toggles the left panel which contains both the project explorer and
|
||||
/// Warp Drive. This happens as explicit action from the user.
|
||||
ToggleLeftPanel,
|
||||
/// Toggles directly to the Warp Drive tab of the left panel in Code Mode V2
|
||||
ToggleWarpDrive,
|
||||
/// Unconditionally opens Warp Drive. This is used in the case of user lifecycle
|
||||
/// events like new user onboarding or when the user joins a team.
|
||||
OpenWarpDrive,
|
||||
/// Toggles the right panel. This happens as an explicit action from the user.
|
||||
ToggleRightPanel,
|
||||
/// Opens the code review panel (right panel) without toggling. If already open,
|
||||
/// switches to the target pane's repo. Used by vertical tabs diff stats chip.
|
||||
OpenCodeReviewPanel(PaneViewLocator),
|
||||
/// Toggles the vertical tabs panel. This happens as an explicit action from the user.
|
||||
ToggleVerticalTabsPanel,
|
||||
ToggleVerticalTabsSettingsPopup,
|
||||
SetVerticalTabsDisplayGranularity(VerticalTabsDisplayGranularity),
|
||||
SetVerticalTabsTabItemMode(VerticalTabsTabItemMode),
|
||||
SetVerticalTabsViewMode(VerticalTabsViewMode),
|
||||
SetVerticalTabsPrimaryInfo(VerticalTabsPrimaryInfo),
|
||||
SetVerticalTabsCompactSubtitle(VerticalTabsCompactSubtitle),
|
||||
ToggleVerticalTabsShowPrLink,
|
||||
ToggleVerticalTabsShowDiffStats,
|
||||
ToggleVerticalTabsShowDetailsOnHover,
|
||||
/// Closes the focused panel. This happens as an explicit action from the user.
|
||||
ClosePanel,
|
||||
CopyTextToClipboard(String),
|
||||
/// An action only registered in dev and local builds, which writes the user's current access
|
||||
/// token to the system clipboard to aid debugging and development.
|
||||
CopyAccessTokenToClipboard,
|
||||
DismissWorkspaceBanner(WorkspaceBanner),
|
||||
/// An action only registered in dev and local builds, which crashes the
|
||||
/// app (via a Sentry helper method) immediately when called.
|
||||
Crash,
|
||||
/// An action only registered in dev and local builds, which triggers a
|
||||
/// panic immediately when called.
|
||||
Panic,
|
||||
/// Stops the heap profiler (if one is running) and writes the profiling
|
||||
/// data to disk.
|
||||
DumpHeapProfile,
|
||||
ShowAIAssistantWarmWelcome,
|
||||
ClickedAIAssistantWarmWelcome,
|
||||
/// An action to open a new window with a view hierarchy debugger.
|
||||
OpenViewTreeDebugWindow,
|
||||
DismissAIAssistantWarmWelcome,
|
||||
/// An action to either upgrade syncing status from none or just in one tab
|
||||
/// to syncing all tabs, or downgrade from syncing all tabs to no syncing
|
||||
ToggleSyncAllTerminalInputsInAllTabs,
|
||||
/// An action to either cancel syncing
|
||||
/// or switch from no syncing/syncing all tabs to syncing within one tab
|
||||
ToggleSyncTerminalInputsInTab,
|
||||
/// An action to force terminal input syncing off
|
||||
DisableTerminalInputSync,
|
||||
HandleConflictingWorkflow(SyncId),
|
||||
HandleConflictingEnvVarCollection(SyncId),
|
||||
OpenPromptEditor {
|
||||
open_source: PromptEditorOpenSource,
|
||||
},
|
||||
OpenAgentToolbarEditor,
|
||||
OpenCLIAgentToolbarEditor,
|
||||
OpenHeaderToolbarEditor,
|
||||
ShowHeaderToolbarContextMenu {
|
||||
position: Vector2F,
|
||||
},
|
||||
Reauth,
|
||||
SignupAnonymousUser,
|
||||
SignInAnonymousWebUser,
|
||||
OpenLink(String),
|
||||
/// On WASM, opens a given URL in the desktop Warp app (if installed) or redirects to download page.
|
||||
#[cfg(target_family = "wasm")]
|
||||
OpenLinkOnDesktop(url::Url),
|
||||
ReopenClosedSession,
|
||||
OpenShareSessionModal(usize),
|
||||
StopSharingSessionFromTabMenu {
|
||||
terminal_view_id: EntityId,
|
||||
},
|
||||
StopSharingAllSessionsInTab {
|
||||
pane_group: WeakViewHandle<PaneGroup>,
|
||||
},
|
||||
CopySharedSessionLinkFromTab {
|
||||
tab_index: usize,
|
||||
},
|
||||
AddWindow,
|
||||
AddWindowWithShell {
|
||||
shell: AvailableShell,
|
||||
},
|
||||
/// Moves focus to the panel on the left
|
||||
FocusLeftPanel,
|
||||
/// Moves focus to the panel on the right
|
||||
FocusRightPanel,
|
||||
/// An action to view a newly created/edited workflow in WD from the toast
|
||||
ViewObjectInWarpDrive(WarpDriveItemId),
|
||||
/// Open the object's sharing settings in WD.
|
||||
OpenObjectSharingSettings {
|
||||
object_id: CloudObjectTypeAndId,
|
||||
source: SharingDialogSource,
|
||||
},
|
||||
UndoTrash(CloudObjectTypeAndId),
|
||||
/// Open a local path in the file explorer.
|
||||
OpenInExplorer {
|
||||
path: PathBuf,
|
||||
},
|
||||
/// Open a local file with the system's default application.
|
||||
OpenFilePath {
|
||||
path: PathBuf,
|
||||
},
|
||||
TerminateApp,
|
||||
CloseWindow,
|
||||
/// Help the user call the Warp executable with the [`crate::args::DEBUG_DUMP_FLAG`].
|
||||
DumpDebugInfo,
|
||||
/// Log review comment send eligibility for panes in the active tab.
|
||||
LogReviewCommentSendStatusForActiveTab,
|
||||
ToggleRecordingMode,
|
||||
ToggleInBandGenerators,
|
||||
ToggleDebugNetworkStatus,
|
||||
ToggleShowMemoryStats,
|
||||
RunAISuggestedCommand(String),
|
||||
RunCommand(String),
|
||||
InsertInInput {
|
||||
content: String,
|
||||
replace_buffer: bool,
|
||||
/// Whether to ensure agent mode is enabled when inserting content
|
||||
ensure_agent_mode: bool,
|
||||
},
|
||||
/// Open a new tab with its input in AI mode.
|
||||
NewTabInAgentMode {
|
||||
/// The entrypoint that triggered this action.
|
||||
entrypoint: AgentModeEntrypoint,
|
||||
/// The type of zero state prompt suggestion to start with (optional).
|
||||
zero_state_prompt_suggestion_type: Option<ZeroStatePromptSuggestionType>,
|
||||
},
|
||||
/// Open a new pane with its input in AI mode.
|
||||
NewPaneInAgentMode {
|
||||
/// The entrypoint that triggered this action.
|
||||
entrypoint: AgentModeEntrypoint,
|
||||
/// The type of zero state prompt suggestion to start with (optional).
|
||||
zero_state_prompt_suggestion_type: Option<ZeroStatePromptSuggestionType>,
|
||||
},
|
||||
OpenCloudAgentSetupGuide,
|
||||
AttemptLoginGatedAIUpgrade,
|
||||
/// Dismisses the Wayland crash recovery banner and opens a link to our docs page with more
|
||||
/// information.
|
||||
#[cfg(target_os = "linux")]
|
||||
DismissWaylandCrashRecoveryBannerAndOpenLink,
|
||||
/// Open a new pane with its input in AI mode
|
||||
/// with query "Fix this" with error name and details from AI summary.
|
||||
FixInAgentMode {
|
||||
query: String,
|
||||
},
|
||||
OpenAIFactCollection,
|
||||
OpenMCPServerCollection,
|
||||
/// Open the Environment Management pane in Create mode.
|
||||
OpenEnvironmentManagementPane,
|
||||
ToggleAIDocumentPane {
|
||||
document_id: AIDocumentId,
|
||||
document_version: AIDocumentVersion,
|
||||
},
|
||||
/// Closes all visible AI document panes in the active pane group.
|
||||
HideAIDocumentPanes,
|
||||
/// Closes any other ai document panes in the active pane group, and opens the specified document_id.
|
||||
OpenAIDocumentPane {
|
||||
document_id: AIDocumentId,
|
||||
document_version: AIDocumentVersion,
|
||||
},
|
||||
FocusTerminalViewInWorkspace {
|
||||
terminal_view_id: EntityId,
|
||||
},
|
||||
/// Focus a specific pane by its locator (pane_group_id and pane_id).
|
||||
FocusPane(PaneViewLocator),
|
||||
/// Start a new AI conversation in a terminal view. This sets the pending query state
|
||||
/// to default and focuses the terminal view.
|
||||
StartNewConversation {
|
||||
terminal_view_id: EntityId,
|
||||
},
|
||||
/// Jump to the terminal pane of the most recent agent toast
|
||||
JumpToLatestToast,
|
||||
/// Open a file in a new tab with a code pane
|
||||
OpenFileInNewTab {
|
||||
full_path: PathBuf,
|
||||
line_and_column: Option<LineAndColumnArg>,
|
||||
},
|
||||
OpenNotebook {
|
||||
id: SyncId,
|
||||
},
|
||||
RunWorkflow {
|
||||
workflow: Arc<WorkflowType>,
|
||||
workflow_source: WorkflowSource,
|
||||
workflow_selection_source: WorkflowSelectionSource,
|
||||
argument_override: Option<HashMap<String, String>>,
|
||||
},
|
||||
ScrollToSettingsWidget {
|
||||
page: SettingsSection,
|
||||
widget_id: &'static str,
|
||||
},
|
||||
/// Navigate to an existing AI conversation, focusing on its terminal view.
|
||||
///
|
||||
/// If the conversation is not in an open pane, restore it based on the layout setting or override.
|
||||
RestoreOrNavigateToConversation {
|
||||
pane_view_locator: Option<PaneViewLocator>,
|
||||
window_id: Option<WindowId>,
|
||||
conversation_id: AIConversationId,
|
||||
terminal_view_id: Option<EntityId>,
|
||||
/// If provided, use this layout to restore the conversation.
|
||||
/// Otherwise, fall back to the user's setting.
|
||||
restore_layout: Option<RestoreConversationLayout>,
|
||||
},
|
||||
/// Fork an existing AI conversation.
|
||||
/// Optionally summarizes the conversation after forking and/or sends an initial prompt.
|
||||
ForkAIConversation {
|
||||
conversation_id: AIConversationId,
|
||||
/// When Some, fork from the given response (or exchange if `fork_from_exact_exchange`
|
||||
/// is true). When None, fork from the last exchange.
|
||||
fork_from_exchange: Option<ForkFromExchange>,
|
||||
/// Whether to summarize the conversation after forking.
|
||||
summarize_after_fork: bool,
|
||||
/// Prompt to use for summarization when `summarize_after_fork` is true.
|
||||
summarization_prompt: Option<String>,
|
||||
/// Initial prompt to send in the forked conversation (sent after summarization if enabled).
|
||||
initial_prompt: Option<String>,
|
||||
/// Where to open the forked conversation.
|
||||
destination: ForkedConversationDestination,
|
||||
},
|
||||
/// Fork an existing AI conversation into a new pane and prefill the input with a local
|
||||
/// continuation command (selecting all text).
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
ContinueConversationLocally {
|
||||
conversation_id: AIConversationId,
|
||||
},
|
||||
/// Insert the /fork slash command into the active terminal's input.
|
||||
InsertForkSlashCommand,
|
||||
/// Summarize the active AI conversation in the focused pane.
|
||||
SummarizeAIConversation {
|
||||
prompt: Option<String>,
|
||||
/// Optional prompt to send after summarization completes successfully.
|
||||
initial_prompt: Option<String>,
|
||||
},
|
||||
/// Queue a prompt to be sent after the current conversation finishes.
|
||||
QueuePromptForConversation {
|
||||
prompt: String,
|
||||
},
|
||||
/// Install the Warp CLI command to /usr/local/bin
|
||||
#[cfg(target_os = "macos")]
|
||||
InstallCLI,
|
||||
/// Uninstall the Warp CLI command from /usr/local/bin
|
||||
#[cfg(target_os = "macos")]
|
||||
UninstallCLI,
|
||||
UndoRevertInCodeReviewPane {
|
||||
window_id: WindowId,
|
||||
view_id: EntityId,
|
||||
},
|
||||
/// Handle a file being renamed in the file tree
|
||||
#[cfg(feature = "local_fs")]
|
||||
FileRenamed {
|
||||
old_path: PathBuf,
|
||||
new_path: PathBuf,
|
||||
},
|
||||
/// Handle a file being deleted in the file tree
|
||||
#[cfg(feature = "local_fs")]
|
||||
FileDeleted {
|
||||
path: PathBuf,
|
||||
},
|
||||
/// Open a repository directory via file picker. The `path` is an `Option` because some
|
||||
/// dispatchers don't know the path to open yet (so the Workspace must open the file picker)
|
||||
/// and some do, e.g. the GetStartedView. The GetStartedView needs to handle the file picker
|
||||
/// because it needs to determine whether or not to close itself based on whether the user
|
||||
/// actually selects a file in the file picker or cancels it.
|
||||
OpenRepository {
|
||||
path: Option<String>,
|
||||
},
|
||||
/// Open the native folder picker for a repo param in the tab-config modal after the
|
||||
/// current interaction cycle finishes.
|
||||
OpenTabConfigRepoPicker {
|
||||
param_index: usize,
|
||||
},
|
||||
/// Open a new blank code file in the current tab
|
||||
NewCodeFile,
|
||||
NavigatePrevPaneOrPanel,
|
||||
NavigateNextPaneOrPanel,
|
||||
ToggleProjectExplorer,
|
||||
ToggleGlobalSearch,
|
||||
OpenGlobalSearch,
|
||||
ToggleConversationListView,
|
||||
/// Open the Build Plan Migration Modal (for debugging)
|
||||
#[cfg(debug_assertions)]
|
||||
OpenBuildPlanMigrationModal,
|
||||
/// Reset the build plan migration modal dismissed state (for debugging)
|
||||
#[cfg(debug_assertions)]
|
||||
ResetBuildPlanMigrationModalState,
|
||||
/// Reset the AWS Bedrock login banner dismissed state (for debugging).
|
||||
#[cfg(debug_assertions)]
|
||||
DebugResetAwsBedrockLoginBannerDismissed,
|
||||
/// Open the Oz Launch Modal (for debugging)
|
||||
#[cfg(debug_assertions)]
|
||||
OpenOzLaunchModal,
|
||||
/// Reset the Oz launch modal dismissed state (for debugging)
|
||||
#[cfg(debug_assertions)]
|
||||
ResetOzLaunchModalState,
|
||||
/// Open the OpenWarp Launch Modal (for debugging)
|
||||
#[cfg(debug_assertions)]
|
||||
OpenOpenWarpLaunchModal,
|
||||
/// Reset the OpenWarp launch modal dismissed state (for debugging)
|
||||
#[cfg(debug_assertions)]
|
||||
ResetOpenWarpLaunchModalState,
|
||||
/// Install the opencode-warp plugin from GitHub into the global opencode config.
|
||||
#[cfg(debug_assertions)]
|
||||
InstallOpenCodeWarpPlugin,
|
||||
/// Use a local checkout of the opencode-warp plugin (for testing/development).
|
||||
#[cfg(debug_assertions)]
|
||||
UseLocalOpenCodeWarpPlugin,
|
||||
/// Take a process sample of the app (equivalent to Activity Monitor > Sample Process).
|
||||
#[cfg(target_os = "macos")]
|
||||
SampleProcess,
|
||||
ToggleNotificationMailbox {
|
||||
select_first: bool,
|
||||
},
|
||||
ToggleAgentManagementView,
|
||||
ViewAgentRunsForEnvironment {
|
||||
environment_id: String,
|
||||
},
|
||||
/// Show the rewind confirmation dialog before rewinding an AI conversation
|
||||
ShowRewindConfirmationDialog {
|
||||
ai_block_view_id: EntityId,
|
||||
exchange_id: AIAgentExchangeId,
|
||||
conversation_id: AIConversationId,
|
||||
},
|
||||
/// Execute the actual rewind after confirmation
|
||||
ExecuteRewindAIConversation {
|
||||
ai_block_view_id: EntityId,
|
||||
exchange_id: AIAgentExchangeId,
|
||||
conversation_id: AIConversationId,
|
||||
},
|
||||
/// Execute the actual deletion of a conversation after confirmation
|
||||
ExecuteDeleteConversation {
|
||||
conversation_id: AIConversationId,
|
||||
terminal_view_id: Option<EntityId>,
|
||||
},
|
||||
/// Open an ambient agent session by joining its shared session.
|
||||
/// Used when the sandbox is running or when we need to view a live session.
|
||||
OpenAmbientAgentSession {
|
||||
session_id: SessionId,
|
||||
task_id: AmbientAgentTaskId,
|
||||
},
|
||||
/// Load cloud conversation data into a transcript viewer.
|
||||
/// Used when CloudConversations is enabled and the sandbox is not running.
|
||||
OpenConversationTranscriptViewer {
|
||||
conversation_id: ServerConversationToken,
|
||||
ambient_agent_task_id: Option<AmbientAgentTaskId>,
|
||||
},
|
||||
/// Toggle the conversation transcript details panel (WASM-only).
|
||||
#[cfg(target_family = "wasm")]
|
||||
ToggleConversationTranscriptDetailsPanel,
|
||||
/// Open a full-window lightbox displaying the given images.
|
||||
OpenLightbox {
|
||||
images: Vec<lightbox::LightboxImage>,
|
||||
/// The index of the image to display initially.
|
||||
initial_index: usize,
|
||||
},
|
||||
/// Update a single image in the currently open lightbox.
|
||||
UpdateLightboxImage {
|
||||
index: usize,
|
||||
image: lightbox::LightboxImage,
|
||||
},
|
||||
StartAgentOnboardingTutorial(OnboardingTutorial),
|
||||
ShowSessionConfigModal,
|
||||
DismissSessionConfigTabConfigChip,
|
||||
/// Start the HOA onboarding flow (for debugging)
|
||||
#[cfg(debug_assertions)]
|
||||
ShowHoaOnboardingFlow,
|
||||
/// Open the "New worktree" modal for creating a reusable worktree tab config.
|
||||
OpenNewWorktreeModal,
|
||||
/// Open the native folder picker for the repo field in the new-worktree modal.
|
||||
OpenNewWorktreeRepoPicker,
|
||||
/// Create a new worktree in the given repo using the default worktree tab config.
|
||||
/// The branch name is auto-generated.
|
||||
OpenWorktreeInRepo {
|
||||
repo_path: String,
|
||||
},
|
||||
/// Open a folder picker to add a new repo to PersistedWorkspace (from the
|
||||
/// "New worktree config" submenu's "+ Add new repo..." item).
|
||||
OpenWorktreeAddRepoPicker,
|
||||
SaveCurrentTabAsNewConfig(usize),
|
||||
SyncTrafficLights,
|
||||
/// Opens a tab config file in the editor and dismisses the associated error toast.
|
||||
OpenTabConfigErrorFile {
|
||||
path: PathBuf,
|
||||
toast_object_id: String,
|
||||
},
|
||||
/// Sidecar action: set the hovered item as the Cmd+T default.
|
||||
TabConfigSidecarMakeDefault {
|
||||
mode: crate::settings::ai::DefaultSessionMode,
|
||||
tab_config_path: Option<PathBuf>,
|
||||
shell: Option<AvailableShell>,
|
||||
},
|
||||
/// Sidecar action: open the tab config TOML in the user's editor.
|
||||
TabConfigSidecarEditConfig {
|
||||
path: PathBuf,
|
||||
},
|
||||
/// Sidecar action: show the remove confirmation dialog for a tab config.
|
||||
TabConfigSidecarRemoveConfig {
|
||||
name: String,
|
||||
path: PathBuf,
|
||||
},
|
||||
/// Opens the settings.toml file in a code editor pane.
|
||||
OpenSettingsFile,
|
||||
/// Opens a new agent session to fix settings.toml errors using the modify-settings skill.
|
||||
FixSettingsWithOz {
|
||||
error_description: String,
|
||||
},
|
||||
/// Opens (or focuses) the in-app network log pane as a right-split of the
|
||||
/// active pane group. Gated on `ContextFlag::NetworkLogConsole`.
|
||||
OpenNetworkLogPane,
|
||||
}
|
||||
|
||||
impl From<&WorkspaceAction> for LoginGatedFeature {
|
||||
fn from(val: &WorkspaceAction) -> LoginGatedFeature {
|
||||
use WorkspaceAction::*;
|
||||
match val {
|
||||
ImportToTeamDrive => "Importing to a team drive",
|
||||
CreateTeamNotebook => "Creating a team notebook",
|
||||
CreateTeamWorkflow => "Creating a team workflow",
|
||||
CreateTeamFolder => "Creating a team folder",
|
||||
CreateTeamEnvVarCollection => "Creating a team environment variable collection",
|
||||
CreateTeamAIPrompt => "Creating a team prompt",
|
||||
OpenShareSessionModal(_) => "Sharing a session",
|
||||
_ => "Unknown reason",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WorkspaceAction {
|
||||
pub fn blocked_for_anonymous_user(&self) -> bool {
|
||||
use WorkspaceAction::*;
|
||||
matches!(
|
||||
self,
|
||||
ImportToTeamDrive
|
||||
| CreateTeamNotebook
|
||||
| CreateTeamWorkflow
|
||||
| CreateTeamFolder
|
||||
| CreateTeamEnvVarCollection
|
||||
| CreateTeamAIPrompt
|
||||
| OpenShareSessionModal(_)
|
||||
)
|
||||
}
|
||||
|
||||
/// Matches what actions require the app state to be saved, and which don't. We match all
|
||||
/// actions directly, rather than using _, so we're forced to make a concious decision for each
|
||||
/// of them, rather than following some default.
|
||||
pub fn should_save_app_state_on_action(&self) -> bool {
|
||||
use WorkspaceAction::*;
|
||||
match self {
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
ContinueConversationLocally { .. } => true,
|
||||
ActivateTab(_)
|
||||
| ActivateTabByNumber(_)
|
||||
| ActivatePrevTab
|
||||
| ActivateNextTab
|
||||
| ActivateLastTab
|
||||
| CyclePrevSession
|
||||
| CycleNextSession
|
||||
| MoveActiveTabLeft
|
||||
| MoveActiveTabRight
|
||||
| MoveTabLeft(_)
|
||||
| MoveTabRight(_)
|
||||
| DropTab
|
||||
| RenameTab(_)
|
||||
| ResetTabName(_)
|
||||
| RenamePane(_)
|
||||
| ResetPaneName(_)
|
||||
| RenameActiveTab
|
||||
| SetActiveTabName(_)
|
||||
| CloseTab(_)
|
||||
| CloseActiveTab
|
||||
| CloseOtherTabs(_)
|
||||
| CloseNonActiveTabs
|
||||
| CloseTabsRight(_)
|
||||
| CloseTabsRightActiveTab
|
||||
| ToggleTabColor { .. }
|
||||
| AddDefaultTab
|
||||
| AddTerminalTab { .. }
|
||||
| AddTabWithShell { .. }
|
||||
| AddGetStartedTab
|
||||
| AddAgentTab
|
||||
| AddAmbientAgentTab
|
||||
| AddDockerSandboxTab
|
||||
| AddWindow
|
||||
| AddWindowWithShell { .. }
|
||||
| CloseWindow
|
||||
| ScrollToSettingsWidget { .. }
|
||||
| NewTabInAgentMode { .. }
|
||||
| NewPaneInAgentMode { .. }
|
||||
| FixInAgentMode { .. }
|
||||
| OpenNotebook { .. }
|
||||
| RunWorkflow { .. }
|
||||
| OpenFileInNewTab { .. }
|
||||
| RestoreOrNavigateToConversation { .. }
|
||||
| NewCodeFile
|
||||
| ForkAIConversation { .. }
|
||||
| SummarizeAIConversation { .. }
|
||||
| OpenRepository { .. }
|
||||
| SelectTabConfig(_)
|
||||
| ToggleVerticalTabsPanel => true, // actions that actually change a state of the state of user's
|
||||
// workspace would most likely require a save, so that if the app gets
|
||||
// restarted, the user can continue working
|
||||
AutoupdateFailureLink
|
||||
| ApplyUpdate
|
||||
| CopyVersion(_)
|
||||
| DownloadNewVersion
|
||||
| ConfigureKeybindingSettings { .. }
|
||||
| ExportAllWarpDriveObjects
|
||||
| ShowSettings
|
||||
| ShowSettingsPage(_)
|
||||
| ShowSettingsPageWithSearch { .. }
|
||||
| ShowThemeChooser(_)
|
||||
| ShowThemeChooserForActiveTheme
|
||||
| IncreaseFontSize
|
||||
| DecreaseFontSize
|
||||
| ResetFontSize
|
||||
| IncreaseZoom
|
||||
| DecreaseZoom
|
||||
| ResetZoom
|
||||
| OpenPalette { .. }
|
||||
| TogglePalette { mode: _, source: _ }
|
||||
| ShowUpgrade
|
||||
| ShowReferralSettingsPage
|
||||
| JoinSlack
|
||||
| ViewUserDocs
|
||||
| ViewLatestChangelog
|
||||
| ViewPrivacyPolicy
|
||||
| SendFeedback
|
||||
| ChangeCursor(_)
|
||||
| ToggleBlockSnackbar
|
||||
| ToggleErrorUnderlining
|
||||
| ToggleSyntaxHighlighting
|
||||
| OpenLaunchConfigSaveModal
|
||||
| ToggleTabRightClickMenu { .. }
|
||||
| ToggleVerticalTabsPaneContextMenu { .. }
|
||||
| OpenNewSessionMenu { .. }
|
||||
| ToggleTabConfigsMenu
|
||||
| ToggleNewSessionMenu { .. }
|
||||
| SelectNewSessionMenuItem(_)
|
||||
| ToggleTabBarOverflowMenu
|
||||
| CheckForUpdate
|
||||
| SetA11yVerbosityLevel(_)
|
||||
| ToggleNotifications
|
||||
| DispatchToSettingsTab { .. }
|
||||
| ToggleResourceCenter
|
||||
| ToggleUserMenu
|
||||
| ClickedAIAssistantIcon
|
||||
| ToggleAIAssistant
|
||||
| OpenCloudAgentSetupGuide
|
||||
| ToggleKeybindingsPage
|
||||
| ShowCommandSearch(_)
|
||||
| ToggleMouseReporting
|
||||
| ToggleScrollReporting
|
||||
| ToggleFocusReporting
|
||||
| ImportToPersonalDrive
|
||||
| ImportToTeamDrive
|
||||
| CreatePersonalNotebook
|
||||
| CreateTeamNotebook
|
||||
| CreatePersonalWorkflow
|
||||
| CreateTeamWorkflow
|
||||
| CreatePersonalFolder
|
||||
| CreateTeamFolder
|
||||
| CreateTeamEnvVarCollection
|
||||
| CreatePersonalEnvVarCollection
|
||||
| CreatePersonalAIPrompt
|
||||
| CreateTeamAIPrompt
|
||||
| OpenInExplorer { .. }
|
||||
| DragTab { .. }
|
||||
| HandoffPendingTransfer { .. }
|
||||
| ReverseHandoff { .. }
|
||||
| StartTabDrag
|
||||
| FinalizeDropTab
|
||||
| ToggleLeftPanel
|
||||
| ToggleWarpDrive
|
||||
| OpenWarpDrive
|
||||
| ClosePanel
|
||||
| ToggleRightPanel
|
||||
| OpenCodeReviewPanel(..)
|
||||
| ToggleVerticalTabsSettingsPopup
|
||||
| SetVerticalTabsDisplayGranularity(_)
|
||||
| SetVerticalTabsTabItemMode(_)
|
||||
| SetVerticalTabsViewMode(_)
|
||||
| SetVerticalTabsPrimaryInfo(_)
|
||||
| SetVerticalTabsCompactSubtitle(_)
|
||||
| ToggleVerticalTabsShowPrLink
|
||||
| ToggleVerticalTabsShowDiffStats
|
||||
| ToggleVerticalTabsShowDetailsOnHover
|
||||
| ToggleWelcomeTips
|
||||
| CopyTextToClipboard(_)
|
||||
| CopyAccessTokenToClipboard
|
||||
| OpenTabConfigRepoPicker { .. }
|
||||
| OpenNewWorktreeModal
|
||||
| OpenNewWorktreeRepoPicker
|
||||
| OpenWorktreeInRepo { .. }
|
||||
| OpenWorktreeAddRepoPicker
|
||||
| Crash
|
||||
| Panic
|
||||
| DumpHeapProfile
|
||||
| OpenViewTreeDebugWindow
|
||||
| ShowAIAssistantWarmWelcome
|
||||
| ClickedAIAssistantWarmWelcome
|
||||
| DismissAIAssistantWarmWelcome
|
||||
| DismissWorkspaceBanner(..)
|
||||
| ToggleSyncAllTerminalInputsInAllTabs
|
||||
| ToggleSyncTerminalInputsInTab
|
||||
| DisableTerminalInputSync
|
||||
| HandleConflictingWorkflow(_)
|
||||
| HandleConflictingEnvVarCollection(_)
|
||||
| OpenPromptEditor { .. }
|
||||
| OpenAgentToolbarEditor
|
||||
| OpenCLIAgentToolbarEditor
|
||||
| OpenHeaderToolbarEditor
|
||||
| ShowHeaderToolbarContextMenu { .. }
|
||||
| Reauth
|
||||
| SignupAnonymousUser
|
||||
| LogOut
|
||||
| OpenLink(_)
|
||||
| OpenShareSessionModal(_)
|
||||
| StopSharingSessionFromTabMenu { .. }
|
||||
| StopSharingAllSessionsInTab { .. }
|
||||
| CopySharedSessionLinkFromTab { .. }
|
||||
| ReopenClosedSession
|
||||
| FocusLeftPanel
|
||||
| FocusRightPanel
|
||||
| DumpDebugInfo
|
||||
| LogReviewCommentSendStatusForActiveTab
|
||||
| ToggleRecordingMode
|
||||
| ToggleInBandGenerators
|
||||
| ToggleDebugNetworkStatus
|
||||
| ToggleShowMemoryStats
|
||||
| RunAISuggestedCommand { .. }
|
||||
| RunCommand { .. }
|
||||
| InsertInInput { .. }
|
||||
| InsertForkSlashCommand
|
||||
| QueuePromptForConversation { .. }
|
||||
| AttemptLoginGatedAIUpgrade
|
||||
| UndoTrash(_)
|
||||
| OpenFilePath { .. }
|
||||
| ViewObjectInWarpDrive(_)
|
||||
| OpenObjectSharingSettings { .. }
|
||||
| TerminateApp
|
||||
| SignInAnonymousWebUser
|
||||
| TabHoverWidthStart { .. }
|
||||
| TabHoverWidthEnd
|
||||
| OpenAIFactCollection
|
||||
| OpenMCPServerCollection
|
||||
| FocusTerminalViewInWorkspace { .. }
|
||||
| FocusPane(..)
|
||||
| StartNewConversation { .. }
|
||||
| UndoRevertInCodeReviewPane { .. }
|
||||
| JumpToLatestToast
|
||||
| NavigatePrevPaneOrPanel
|
||||
| NavigateNextPaneOrPanel
|
||||
| ToggleProjectExplorer
|
||||
| ToggleGlobalSearch
|
||||
| OpenGlobalSearch
|
||||
| ToggleConversationListView
|
||||
| ToggleNotificationMailbox { .. }
|
||||
| ToggleAgentManagementView
|
||||
| ViewAgentRunsForEnvironment { .. }
|
||||
| ToggleAIDocumentPane { .. }
|
||||
| HideAIDocumentPanes
|
||||
| OpenAIDocumentPane { .. }
|
||||
| ShowRewindConfirmationDialog { .. }
|
||||
| ExecuteRewindAIConversation { .. }
|
||||
| ExecuteDeleteConversation { .. }
|
||||
| OpenAmbientAgentSession { .. }
|
||||
| OpenConversationTranscriptViewer { .. }
|
||||
| OpenLightbox { .. }
|
||||
| UpdateLightboxImage { .. }
|
||||
| StartAgentOnboardingTutorial(_)
|
||||
| ShowSessionConfigModal
|
||||
| DismissSessionConfigTabConfigChip
|
||||
| SaveCurrentTabAsNewConfig(_)
|
||||
| SyncTrafficLights
|
||||
| OpenTabConfigErrorFile { .. }
|
||||
| TabConfigSidecarMakeDefault { .. }
|
||||
| TabConfigSidecarEditConfig { .. }
|
||||
| TabConfigSidecarRemoveConfig { .. }
|
||||
| OpenSettingsFile
|
||||
| FixSettingsWithOz { .. }
|
||||
| OpenNetworkLogPane => false,
|
||||
#[cfg(debug_assertions)]
|
||||
ShowHoaOnboardingFlow => false,
|
||||
#[cfg(target_family = "wasm")]
|
||||
ToggleConversationTranscriptDetailsPanel => false,
|
||||
#[cfg(debug_assertions)]
|
||||
OpenBuildPlanMigrationModal
|
||||
| ResetBuildPlanMigrationModalState
|
||||
| DebugResetAwsBedrockLoginBannerDismissed
|
||||
| OpenOzLaunchModal
|
||||
| ResetOzLaunchModalState
|
||||
| OpenOpenWarpLaunchModal
|
||||
| ResetOpenWarpLaunchModalState
|
||||
| InstallOpenCodeWarpPlugin
|
||||
| UseLocalOpenCodeWarpPlugin => false,
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
ViewLogs => false,
|
||||
#[cfg(target_os = "macos")]
|
||||
SampleProcess => false,
|
||||
#[cfg(target_os = "macos")]
|
||||
InstallCLI | UninstallCLI => false,
|
||||
#[cfg(feature = "local_fs")]
|
||||
FileRenamed { .. } => false, // File rename doesn't change workspace state
|
||||
#[cfg(feature = "local_fs")]
|
||||
FileDeleted { .. } => false, // File deletion doesn't change workspace state
|
||||
OpenEnvironmentManagementPane => false,
|
||||
#[cfg(target_os = "linux")]
|
||||
DismissWaylandCrashRecoveryBannerAndOpenLink => false,
|
||||
#[cfg(target_family = "wasm")]
|
||||
OpenLinkOnDesktop(_) => false,
|
||||
// actions that are related to updating user settings or
|
||||
// managing some ui elements (like closing/opening modals)
|
||||
// that don't reflect on actual workspace and don't need to
|
||||
// be preserved between restarts.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "action_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,77 @@
|
||||
use super::WorkspaceAction;
|
||||
use crate::pane_group::TerminalPaneId;
|
||||
use crate::workspace::tab_settings::{
|
||||
VerticalTabsDisplayGranularity, VerticalTabsPrimaryInfo, VerticalTabsTabItemMode,
|
||||
VerticalTabsViewMode,
|
||||
};
|
||||
use crate::workspace::PaneViewLocator;
|
||||
use warpui::EntityId;
|
||||
|
||||
#[test]
|
||||
fn vertical_tabs_view_mode_change_does_not_save_workspace_state() {
|
||||
assert!(
|
||||
!WorkspaceAction::SetVerticalTabsViewMode(VerticalTabsViewMode::Compact)
|
||||
.should_save_app_state_on_action()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertical_tabs_panel_toggle_still_saves_workspace_state() {
|
||||
assert!(WorkspaceAction::ToggleVerticalTabsPanel.should_save_app_state_on_action());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_popup_toggle_does_not_save_workspace_state() {
|
||||
assert!(!WorkspaceAction::ToggleVerticalTabsSettingsPopup.should_save_app_state_on_action());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_granularity_change_does_not_save_workspace_state() {
|
||||
assert!(!WorkspaceAction::SetVerticalTabsDisplayGranularity(
|
||||
VerticalTabsDisplayGranularity::Panes
|
||||
)
|
||||
.should_save_app_state_on_action());
|
||||
assert!(!WorkspaceAction::SetVerticalTabsDisplayGranularity(
|
||||
VerticalTabsDisplayGranularity::Tabs
|
||||
)
|
||||
.should_save_app_state_on_action());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_item_mode_change_does_not_save_workspace_state() {
|
||||
assert!(
|
||||
!WorkspaceAction::SetVerticalTabsTabItemMode(VerticalTabsTabItemMode::FocusedSession)
|
||||
.should_save_app_state_on_action()
|
||||
);
|
||||
assert!(
|
||||
!WorkspaceAction::SetVerticalTabsTabItemMode(VerticalTabsTabItemMode::Summary)
|
||||
.should_save_app_state_on_action()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn primary_info_change_does_not_save_workspace_state() {
|
||||
assert!(
|
||||
!WorkspaceAction::SetVerticalTabsPrimaryInfo(VerticalTabsPrimaryInfo::Command)
|
||||
.should_save_app_state_on_action()
|
||||
);
|
||||
assert!(!WorkspaceAction::SetVerticalTabsPrimaryInfo(
|
||||
VerticalTabsPrimaryInfo::WorkingDirectory
|
||||
)
|
||||
.should_save_app_state_on_action());
|
||||
assert!(
|
||||
!WorkspaceAction::SetVerticalTabsPrimaryInfo(VerticalTabsPrimaryInfo::Branch)
|
||||
.should_save_app_state_on_action()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pane_name_actions_save_workspace_state() {
|
||||
let locator = PaneViewLocator {
|
||||
pane_group_id: EntityId::new(),
|
||||
pane_id: TerminalPaneId::dummy_terminal_pane_id().into(),
|
||||
};
|
||||
|
||||
assert!(WorkspaceAction::RenamePane(locator).should_save_app_state_on_action());
|
||||
assert!(WorkspaceAction::ResetPaneName(locator).should_save_app_state_on_action());
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, Weak},
|
||||
};
|
||||
|
||||
use warpui::{Entity, EntityId, ModelContext, SingletonEntity, WindowId};
|
||||
|
||||
use crate::terminal::model::session::Session;
|
||||
|
||||
/// The active terminal session in each window. The active session of a window is the current
|
||||
/// session of the most-recently-focused terminal pane of the active tab of the window's workspace.
|
||||
///
|
||||
/// #### When to use `ActiveSession`
|
||||
/// Generally, if a more specific session is available, it should be preferred. For example, when
|
||||
/// opening a Markdown file from a file link in a block's output, that block's session should be
|
||||
/// the basis. However, sometimes there is no contextual session (such as when opening a file
|
||||
/// in Warp from Finder, or when starting from a cloud object). In that case, the `ActiveSession`
|
||||
/// might be used, but it's often still better to be context-independent.
|
||||
#[derive(Default)]
|
||||
pub struct ActiveSession {
|
||||
window_sessions: HashMap<WindowId, WindowActiveSession>,
|
||||
}
|
||||
|
||||
/// Active session information for an individual window.
|
||||
#[derive(Default)]
|
||||
struct WindowActiveSession {
|
||||
/// The [`Session`] model for the active session. This is a weak reference so that it doesn't
|
||||
/// prevent cleaning up the session when it closes, in case no other session is activated.
|
||||
session: Option<Weak<Session>>,
|
||||
/// The active session's working directory, if it's local.
|
||||
path_if_local: Option<PathBuf>,
|
||||
/// The [`EntityId`]` for the [`TerminalView`] for the active session, if there is one.
|
||||
terminal_view_id: Option<EntityId>,
|
||||
}
|
||||
|
||||
impl ActiveSession {
|
||||
/// The workspace's active session, if there is one.
|
||||
pub fn session(&self, window_id: WindowId) -> Option<Arc<Session>> {
|
||||
self.window_sessions
|
||||
.get(&window_id)?
|
||||
.session
|
||||
.as_ref()?
|
||||
.upgrade()
|
||||
}
|
||||
|
||||
pub fn terminal_view_id(&self, window_id: WindowId) -> Option<EntityId> {
|
||||
self.window_sessions.get(&window_id)?.terminal_view_id
|
||||
}
|
||||
|
||||
/// The current working directory of the active session, if it's local.
|
||||
pub fn path_if_local(&self, window_id: WindowId) -> Option<&Path> {
|
||||
self.window_sessions
|
||||
.get(&window_id)?
|
||||
.path_if_local
|
||||
.as_deref()
|
||||
}
|
||||
|
||||
/// Set the current session, for use in tests.
|
||||
#[cfg(test)]
|
||||
pub fn set_session_for_test(
|
||||
&mut self,
|
||||
window_id: WindowId,
|
||||
session: Arc<Session>,
|
||||
path_if_local: Option<impl Into<PathBuf>>,
|
||||
terminal_view_id: Option<EntityId>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
self.set_session_state(
|
||||
window_id,
|
||||
Some(session),
|
||||
path_if_local.map(Into::into),
|
||||
terminal_view_id,
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn set_session_state(
|
||||
&mut self,
|
||||
window_id: WindowId,
|
||||
session: Option<Arc<Session>>,
|
||||
path_if_local: Option<PathBuf>,
|
||||
terminal_view_id: Option<EntityId>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let window_state = self.window_sessions.entry(window_id).or_default();
|
||||
|
||||
let session = session.map(|session| Arc::downgrade(&session));
|
||||
if window_state.session.is_some() != session.is_some() {
|
||||
window_state.session = session;
|
||||
ctx.notify();
|
||||
} else if let Some((prev_session, next_session)) =
|
||||
window_state.session.as_ref().zip(session)
|
||||
{
|
||||
// Session IDs can't necessarily be compared across terminal panes, so check if the backing
|
||||
// allocation is the same. We can do this because each `Session` is a singleton.
|
||||
if !Weak::ptr_eq(prev_session, &next_session) {
|
||||
window_state.session = Some(next_session);
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
if window_state.path_if_local != path_if_local {
|
||||
window_state.path_if_local = path_if_local;
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
if window_state.terminal_view_id != terminal_view_id {
|
||||
window_state.terminal_view_id = terminal_view_id;
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn close_workspace(&mut self, window_id: WindowId) {
|
||||
self.window_sessions.remove(&window_id);
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for ActiveSession {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl SingletonEntity for ActiveSession {}
|
||||
@@ -0,0 +1,130 @@
|
||||
use crate::ai::request_usage_model::{
|
||||
AIRequestUsageModel, AIRequestUsageModelEvent, BonusGrant, BonusGrantScope,
|
||||
};
|
||||
use crate::terminal::general_settings::GeneralSettings;
|
||||
use chrono::{Duration, Utc};
|
||||
use std::collections::HashSet;
|
||||
use warp_core::settings::Setting;
|
||||
use warpui::{Entity, ModelContext, SingletonEntity};
|
||||
|
||||
pub struct BonusGrantNotificationModel {
|
||||
/// In-memory tracking of grants shown during this session. This prevents duplicate
|
||||
/// notifications when multiple `AIRequestUsageModelEvent::RequestUsageUpdated` events
|
||||
/// fire in quick succession before the persisted settings can be updated.
|
||||
shown_grants_session: HashSet<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum BonusGrantNotificationEvent {
|
||||
ShowNotification { grant: BonusGrant, message: String },
|
||||
}
|
||||
|
||||
impl Entity for BonusGrantNotificationModel {
|
||||
type Event = BonusGrantNotificationEvent;
|
||||
}
|
||||
|
||||
impl SingletonEntity for BonusGrantNotificationModel {}
|
||||
|
||||
impl BonusGrantNotificationModel {
|
||||
pub fn new(ctx: &mut ModelContext<Self>) -> Self {
|
||||
ctx.subscribe_to_model(&AIRequestUsageModel::handle(ctx), |me, event, ctx| {
|
||||
if let AIRequestUsageModelEvent::RequestUsageUpdated = event {
|
||||
me.check_for_new_bonus_grants(ctx);
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
shown_grants_session: HashSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn check_for_new_bonus_grants(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
let usage_model = AIRequestUsageModel::as_ref(ctx);
|
||||
let bonus_grants = usage_model.bonus_grants();
|
||||
|
||||
let shown_grants = GeneralSettings::as_ref(ctx)
|
||||
.bonus_grants_shown
|
||||
.value()
|
||||
.clone();
|
||||
|
||||
// Only show grants created in the past 2 weeks
|
||||
let cutoff_date = Utc::now() - Duration::days(14);
|
||||
|
||||
let mut grants_to_notify = Vec::new();
|
||||
let mut grants_to_persist_to_settings = Vec::new();
|
||||
|
||||
for grant in bonus_grants {
|
||||
// Only notify about Warp-granted credits (cost = 0), not user purchases
|
||||
if grant.cost_cents != 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only show grants created in the past 2 weeks to avoid overwhelming users
|
||||
// with old grant notifications
|
||||
if grant.created_at < cutoff_date {
|
||||
continue;
|
||||
}
|
||||
|
||||
// doesn't make sense to show "you've got a bonus grant" message if no credits remain (i.e. your teammate used them all)
|
||||
if grant.request_credits_remaining <= 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Use server-provided message if available, otherwise fall back to generic message
|
||||
let message = if let Some(user_facing_message) = &grant.user_facing_message {
|
||||
user_facing_message.clone()
|
||||
} else {
|
||||
Self::format_generic_grant_message(grant)
|
||||
};
|
||||
|
||||
let grant_key = Self::create_grant_key(grant);
|
||||
|
||||
let in_persisted = shown_grants.contains(&grant_key);
|
||||
let in_session = self.shown_grants_session.contains(&grant_key);
|
||||
|
||||
if !in_persisted && !in_session {
|
||||
grants_to_notify.push((grant.clone(), message, grant_key.clone()));
|
||||
}
|
||||
|
||||
if !in_persisted {
|
||||
grants_to_persist_to_settings.push(grant_key.clone());
|
||||
}
|
||||
}
|
||||
|
||||
for (grant, message, grant_key) in grants_to_notify {
|
||||
self.shown_grants_session.insert(grant_key);
|
||||
ctx.emit(BonusGrantNotificationEvent::ShowNotification { grant, message });
|
||||
}
|
||||
|
||||
for grant_key in grants_to_persist_to_settings {
|
||||
self.mark_grant_as_shown(&grant_key, ctx);
|
||||
self.shown_grants_session.insert(grant_key);
|
||||
}
|
||||
}
|
||||
|
||||
fn format_generic_grant_message(grant: &BonusGrant) -> String {
|
||||
let scope_text = match grant.scope {
|
||||
BonusGrantScope::User => "account",
|
||||
BonusGrantScope::Workspace(_) => "team",
|
||||
};
|
||||
format!(
|
||||
"{} Reload Credits have been added to your {}.",
|
||||
grant.request_credits_granted, scope_text
|
||||
)
|
||||
}
|
||||
|
||||
fn create_grant_key(grant: &BonusGrant) -> String {
|
||||
format!("{}:{}", grant.reason, grant.created_at.timestamp())
|
||||
}
|
||||
|
||||
fn mark_grant_as_shown(&self, grant_key: &str, ctx: &mut ModelContext<Self>) {
|
||||
GeneralSettings::handle(ctx).update(ctx, |settings, ctx| {
|
||||
let mut shown_grants = settings.bonus_grants_shown.value().clone();
|
||||
shown_grants.insert(grant_key.to_string());
|
||||
|
||||
if let Err(e) = settings.bonus_grants_shown.set_value(shown_grants, ctx) {
|
||||
log::warn!("Failed to mark bonus grant as shown: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
use std::fs;
|
||||
use std::os::unix::fs::symlink;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use command::blocking::Command;
|
||||
use warp_core::channel::ChannelState;
|
||||
use warp_util::path::ShellFamily;
|
||||
|
||||
/// Compute the target path where the symlink should be installed, based on channel
|
||||
fn cli_install_target_path() -> PathBuf {
|
||||
PathBuf::from("/usr/local/bin").join(ChannelState::channel().cli_command_name())
|
||||
}
|
||||
|
||||
/// Create a symlink with elevated privileges using osascript
|
||||
///
|
||||
/// This function uses macOS's osascript to prompt for administrator privileges
|
||||
/// and create a symlink
|
||||
fn create_symlink_with_admin(source: &Path, target: &Path) -> Result<()> {
|
||||
let source_str = source
|
||||
.to_str()
|
||||
.ok_or_else(|| anyhow!("Source path contains invalid UTF-8: {source:?}"))?;
|
||||
let target_str = target
|
||||
.to_str()
|
||||
.ok_or_else(|| anyhow!("Target path contains invalid UTF-8: {target:?}"))?;
|
||||
|
||||
let escaped_source = ShellFamily::Posix.shell_escape(source_str);
|
||||
let escaped_target = ShellFamily::Posix.shell_escape(target_str);
|
||||
|
||||
// Use osascript to run the ln command with admin privileges, with a custom prompt
|
||||
let script = format!(
|
||||
"do shell script \"ln -sf {escaped_source} {escaped_target}\" with prompt \"Warp needs administrator privileges to install the command in /usr/local/bin.\" with administrator privileges"
|
||||
);
|
||||
|
||||
log::debug!("Creating symlink with admin privileges");
|
||||
|
||||
let output = Command::new("osascript")
|
||||
.arg("-e")
|
||||
.arg(&script)
|
||||
.output()
|
||||
.context("Failed to execute osascript for admin privileges")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
if stderr.contains("User canceled") || stderr.contains("cancelled") {
|
||||
return Err(anyhow!("Installation cancelled by user."));
|
||||
}
|
||||
return Err(anyhow!(
|
||||
"Failed to create symlink with admin privileges: {stderr}"
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a file with elevated privileges using osascript
|
||||
///
|
||||
/// This function uses macOS's osascript to prompt for administrator privileges
|
||||
/// and remove a file, used for CLI uninstallation.
|
||||
fn remove_file_with_admin(target: &Path) -> Result<()> {
|
||||
let target_str = target
|
||||
.to_str()
|
||||
.ok_or_else(|| anyhow!("Target path contains invalid UTF-8: {target:?}"))?;
|
||||
|
||||
let escaped_target = ShellFamily::Posix.shell_escape(target_str);
|
||||
|
||||
let script = format!(
|
||||
"do shell script \"rm {escaped_target}\" with prompt \"Warp needs administrator privileges to uninstall the command from /usr/local/bin.\" with administrator privileges"
|
||||
);
|
||||
|
||||
log::debug!("Removing file with admin privileges");
|
||||
|
||||
let output = Command::new("osascript")
|
||||
.arg("-e")
|
||||
.arg(&script)
|
||||
.output()
|
||||
.context("Failed to execute osascript for admin privileges")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
if stderr.contains("User canceled") || stderr.contains("cancelled") {
|
||||
return Err(anyhow!("Uninstallation cancelled by user."));
|
||||
}
|
||||
return Err(anyhow!(
|
||||
"Failed to remove file with admin privileges: {stderr}"
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Install the CLI by creating a symlink (channel-specific target)
|
||||
///
|
||||
/// This function:
|
||||
/// 1. Detects the current Warp channel and finds the appropriate binary
|
||||
/// 2. Attempts to create a symlink without admin privileges first
|
||||
/// 3. Falls back to prompting for admin privileges if needed
|
||||
/// 4. Handles existing installations and edge cases
|
||||
pub fn install_cli() -> Result<()> {
|
||||
let cli_path = cli_install_target_path();
|
||||
let current_binary =
|
||||
std::env::current_exe().context("Failed to get current executable path")?;
|
||||
|
||||
// Check if target file exists and handle conflicts
|
||||
if cli_path.exists() && !cli_path.is_symlink() {
|
||||
return Err(anyhow!(
|
||||
"Cannot install: {:?} exists but is not a symlink. Please remove it manually first.",
|
||||
cli_path
|
||||
));
|
||||
}
|
||||
|
||||
// Try to create symlink without admin privileges first
|
||||
let symlink_result = symlink(¤t_binary, &cli_path);
|
||||
|
||||
match symlink_result {
|
||||
Ok(_) => {
|
||||
log::debug!(
|
||||
"CLI installed successfully without admin privileges: {:?} -> {}",
|
||||
cli_path,
|
||||
current_binary.display()
|
||||
);
|
||||
}
|
||||
Err(_) => {
|
||||
log::debug!("Symlink creation failed, trying with admin privileges");
|
||||
|
||||
create_symlink_with_admin(¤t_binary, &cli_path)
|
||||
.context("Failed to create symlink even with admin privileges")?;
|
||||
|
||||
log::debug!("CLI installed successfully with admin privileges");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Uninstall the CLI by removing the symlink (channel-specific target)
|
||||
///
|
||||
/// This function:
|
||||
/// 1. Verifies that the target is actually a symlink (safety check)
|
||||
/// 2. Attempts to remove without admin privileges first
|
||||
/// 3. Falls back to prompting for admin privileges if needed
|
||||
pub fn uninstall_cli() -> Result<()> {
|
||||
let cli_path = cli_install_target_path();
|
||||
|
||||
if !cli_path.exists() {
|
||||
return Err(anyhow!("Oz command is not currently installed."));
|
||||
}
|
||||
|
||||
// Safety check: verify it's actually a symlink before removing
|
||||
if !cli_path.is_symlink() {
|
||||
return Err(anyhow!(
|
||||
"Cannot uninstall: {:?} exists but is not a symlink. Please remove it manually.",
|
||||
cli_path
|
||||
));
|
||||
}
|
||||
|
||||
// Try to remove without admin privileges first
|
||||
let remove_result = fs::remove_file(&cli_path);
|
||||
|
||||
match remove_result {
|
||||
Ok(_) => {
|
||||
log::debug!("CLI uninstalled successfully without admin privileges");
|
||||
}
|
||||
Err(_) => {
|
||||
log::debug!("File removal failed, trying with admin privileges");
|
||||
|
||||
remove_file_with_admin(&cli_path)
|
||||
.context("Failed to remove symlink even with admin privileges")?;
|
||||
|
||||
log::debug!("CLI uninstalled successfully with admin privileges");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use warp_core::ui::theme::Fill;
|
||||
use warpui::{
|
||||
elements::{
|
||||
Align, ChildAnchor, Container, MouseStateHandle, OffsetPositioning, ParentAnchor,
|
||||
ParentOffsetBounds, Stack,
|
||||
},
|
||||
fonts::Weight,
|
||||
platform::Cursor,
|
||||
ui_components::{
|
||||
button::ButtonVariant,
|
||||
components::{Coords, UiComponent, UiComponentStyles},
|
||||
text::Span,
|
||||
},
|
||||
AppContext, Element, Entity, EntityId, SingletonEntity, TypedActionView, View, ViewContext,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
appearance::Appearance,
|
||||
pane_group::PaneId,
|
||||
ui_components::dialog::{dialog_styles, Dialog},
|
||||
workspace::TabMovement,
|
||||
};
|
||||
|
||||
#[allow(clippy::enum_variant_names)]
|
||||
#[derive(Copy, Clone)]
|
||||
/// Describes the action which opened the close session confirmation dialog
|
||||
pub enum OpenDialogSource {
|
||||
/// Close a specific pane
|
||||
ClosePane {
|
||||
pane_group_id: EntityId,
|
||||
pane_id: PaneId,
|
||||
},
|
||||
/// Close a specific tab
|
||||
CloseTab { tab_index: usize },
|
||||
/// Close all tabs other than the tab_index
|
||||
CloseOtherTabs { tab_index: usize },
|
||||
/// Close all tabs to the right/left of tab_index
|
||||
CloseTabsDirection {
|
||||
tab_index: usize,
|
||||
direction: TabMovement,
|
||||
},
|
||||
}
|
||||
|
||||
pub struct CloseSessionConfirmationDialog {
|
||||
cancel_mouse_state: MouseStateHandle,
|
||||
confirm_mouse_state: MouseStateHandle,
|
||||
dont_show_again_mouse_state: MouseStateHandle,
|
||||
dont_show_again: bool,
|
||||
// Source will be None if dialog was never opened, since there is no reasonable default
|
||||
open_confirmation_source: Option<OpenDialogSource>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl CloseSessionConfirmationDialog {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
cancel_mouse_state: Default::default(),
|
||||
confirm_mouse_state: Default::default(),
|
||||
dont_show_again_mouse_state: Default::default(),
|
||||
open_confirmation_source: None,
|
||||
dont_show_again: false,
|
||||
}
|
||||
}
|
||||
pub fn set_open_confirmation_source(&mut self, source: OpenDialogSource) {
|
||||
self.open_confirmation_source = Some(source);
|
||||
}
|
||||
|
||||
pub fn get_open_confirmation_source(&self) -> Option<OpenDialogSource> {
|
||||
self.open_confirmation_source
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for CloseSessionConfirmationDialog {
|
||||
type Event = CloseSessionConfirmationEvent;
|
||||
}
|
||||
|
||||
impl View for CloseSessionConfirmationDialog {
|
||||
fn ui_name() -> &'static str {
|
||||
"CloseSessionConfirmation"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let button_style = UiComponentStyles {
|
||||
font_size: Some(14.),
|
||||
font_weight: Some(Weight::Bold),
|
||||
width: Some(202.),
|
||||
height: Some(40.),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let dont_show_again_checkbox = appearance
|
||||
.ui_builder()
|
||||
.checkbox(self.dont_show_again_mouse_state.clone(), Some(14.))
|
||||
.with_label(Span::new("Don't show again.", Default::default()))
|
||||
.check(self.dont_show_again)
|
||||
.build()
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(CloseSessionConfirmationAction::ToggleDontShowAgain)
|
||||
})
|
||||
.finish();
|
||||
|
||||
let dont_show_again_value = self.dont_show_again;
|
||||
let close_session_button = appearance
|
||||
.ui_builder()
|
||||
.button(ButtonVariant::Accent, self.confirm_mouse_state.clone())
|
||||
.with_centered_text_label("Close session".into())
|
||||
.with_style(button_style)
|
||||
.build()
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(CloseSessionConfirmationAction::CloseSession {
|
||||
dont_show_again: dont_show_again_value,
|
||||
})
|
||||
})
|
||||
.finish();
|
||||
|
||||
let cancel_button = appearance
|
||||
.ui_builder()
|
||||
.button(ButtonVariant::Basic, self.cancel_mouse_state.clone())
|
||||
.with_centered_text_label("Cancel".into())
|
||||
.with_style(button_style)
|
||||
.build()
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(CloseSessionConfirmationAction::Cancel)
|
||||
})
|
||||
.finish();
|
||||
|
||||
let dialog = Container::new(
|
||||
Dialog::new(
|
||||
"Close session?".into(),
|
||||
Some(
|
||||
"You are about to close a session that is currently being shared. Closing it will end sharing for everyone."
|
||||
.into(),
|
||||
),
|
||||
UiComponentStyles {
|
||||
width: Some(460.),
|
||||
padding: Some(Coords::uniform(24.)),
|
||||
..dialog_styles(appearance)
|
||||
},
|
||||
)
|
||||
.with_child(dont_show_again_checkbox)
|
||||
.with_bottom_row_child(cancel_button)
|
||||
.with_bottom_row_child(close_session_button)
|
||||
.build()
|
||||
.finish()
|
||||
)
|
||||
.with_margin_top(35.)
|
||||
.finish();
|
||||
|
||||
// Stack needed so that dialog can get bounds information,
|
||||
// specifically to ensure no overlap with the window's traffic lights
|
||||
let mut stack = Stack::new();
|
||||
stack.add_positioned_child(
|
||||
dialog,
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(0., 0.),
|
||||
ParentOffsetBounds::WindowByPosition,
|
||||
ParentAnchor::Center,
|
||||
ChildAnchor::Center,
|
||||
),
|
||||
);
|
||||
|
||||
// This blurs the background and makes it uninteractable
|
||||
Container::new(Align::new(stack.finish()).finish())
|
||||
.with_background_color(Fill::blur().into())
|
||||
.with_corner_radius(app.windows().window_corner_radius())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
pub enum CloseSessionConfirmationEvent {
|
||||
CloseSession {
|
||||
dont_show_again: bool,
|
||||
open_confirmation_source: OpenDialogSource,
|
||||
},
|
||||
Cancel,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum CloseSessionConfirmationAction {
|
||||
CloseSession { dont_show_again: bool },
|
||||
Cancel,
|
||||
ToggleDontShowAgain,
|
||||
}
|
||||
|
||||
impl TypedActionView for CloseSessionConfirmationDialog {
|
||||
type Action = CloseSessionConfirmationAction;
|
||||
|
||||
fn handle_action(
|
||||
&mut self,
|
||||
action: &CloseSessionConfirmationAction,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
match action {
|
||||
CloseSessionConfirmationAction::CloseSession { dont_show_again } => {
|
||||
let Some(open_confirmation_source) = self.open_confirmation_source else {
|
||||
// Should not be possible.
|
||||
log::error!(
|
||||
"Close session button pressed with no open confirmation dialog source"
|
||||
);
|
||||
return;
|
||||
};
|
||||
ctx.emit(CloseSessionConfirmationEvent::CloseSession {
|
||||
dont_show_again: *dont_show_again,
|
||||
open_confirmation_source,
|
||||
});
|
||||
}
|
||||
CloseSessionConfirmationAction::Cancel => {
|
||||
ctx.emit(CloseSessionConfirmationEvent::Cancel);
|
||||
self.dont_show_again = false;
|
||||
}
|
||||
CloseSessionConfirmationAction::ToggleDontShowAgain => {
|
||||
self.dont_show_again = !self.dont_show_again;
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use warp_core::ui::theme::Fill;
|
||||
use warpui::{
|
||||
elements::{
|
||||
Align, ChildAnchor, ChildView, Container, OffsetPositioning, ParentAnchor,
|
||||
ParentOffsetBounds, Stack,
|
||||
},
|
||||
keymap::{FixedBinding, Keystroke},
|
||||
ui_components::components::{UiComponent, UiComponentStyles},
|
||||
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
ai::agent::conversation::AIConversationId,
|
||||
appearance::Appearance,
|
||||
ui_components::dialog::{dialog_styles, Dialog},
|
||||
view_components::action_button::{
|
||||
ActionButton, DangerPrimaryTheme, KeystrokeSource, NakedTheme,
|
||||
},
|
||||
};
|
||||
|
||||
pub fn init(app: &mut AppContext) {
|
||||
use warpui::keymap::macros::*;
|
||||
|
||||
app.register_fixed_bindings([
|
||||
FixedBinding::new(
|
||||
"escape",
|
||||
DeleteConversationConfirmationAction::Cancel,
|
||||
id!(DeleteConversationConfirmationDialog::ui_name()),
|
||||
),
|
||||
FixedBinding::new(
|
||||
"enter",
|
||||
DeleteConversationConfirmationAction::Confirm,
|
||||
id!(DeleteConversationConfirmationDialog::ui_name()),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
const DIALOG_WIDTH: f32 = 460.;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct DeleteConversationDialogSource {
|
||||
pub conversation_id: AIConversationId,
|
||||
pub conversation_title: String,
|
||||
pub terminal_view_id: Option<warpui::EntityId>,
|
||||
}
|
||||
|
||||
pub struct DeleteConversationConfirmationDialog {
|
||||
cancel_button: ViewHandle<ActionButton>,
|
||||
delete_button: ViewHandle<ActionButton>,
|
||||
source: Option<DeleteConversationDialogSource>,
|
||||
}
|
||||
|
||||
impl DeleteConversationConfirmationDialog {
|
||||
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
|
||||
let cancel_button = ctx.add_typed_action_view(|_| {
|
||||
ActionButton::new("Cancel", NakedTheme).on_click(|ctx| {
|
||||
ctx.dispatch_typed_action(DeleteConversationConfirmationAction::Cancel);
|
||||
})
|
||||
});
|
||||
|
||||
let enter_keystroke = Keystroke::parse("enter").expect("Valid keystroke");
|
||||
let delete_button = ctx.add_typed_action_view(|ctx| {
|
||||
ActionButton::new("Delete", DangerPrimaryTheme)
|
||||
.with_keybinding(KeystrokeSource::Fixed(enter_keystroke), ctx)
|
||||
.on_click(|ctx| {
|
||||
ctx.dispatch_typed_action(DeleteConversationConfirmationAction::Confirm);
|
||||
})
|
||||
});
|
||||
|
||||
Self {
|
||||
cancel_button,
|
||||
delete_button,
|
||||
source: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_source(&mut self, source: DeleteConversationDialogSource) {
|
||||
self.source = Some(source);
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for DeleteConversationConfirmationDialog {
|
||||
type Event = DeleteConversationConfirmationEvent;
|
||||
}
|
||||
|
||||
impl View for DeleteConversationConfirmationDialog {
|
||||
fn ui_name() -> &'static str {
|
||||
"DeleteConversationConfirmationDialog"
|
||||
}
|
||||
|
||||
fn on_focus(&mut self, _focus_ctx: &warpui::FocusContext, ctx: &mut ViewContext<Self>) {
|
||||
ctx.focus_self();
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
|
||||
let cancel_button = Container::new(ChildView::new(&self.cancel_button).finish())
|
||||
.with_margin_right(12.)
|
||||
.finish();
|
||||
|
||||
let title = self
|
||||
.source
|
||||
.as_ref()
|
||||
.map(|s| format!("Delete '{}'?", s.conversation_title))
|
||||
.unwrap_or_else(|| "Delete conversation?".into());
|
||||
|
||||
let dialog = Dialog::new(
|
||||
title,
|
||||
Some(
|
||||
"This conversation will be permanently deleted. This action cannot be undone."
|
||||
.into(),
|
||||
),
|
||||
UiComponentStyles {
|
||||
width: Some(DIALOG_WIDTH),
|
||||
..dialog_styles(appearance)
|
||||
},
|
||||
)
|
||||
.with_bottom_row_child(cancel_button)
|
||||
.with_bottom_row_child(ChildView::new(&self.delete_button).finish())
|
||||
.build()
|
||||
.finish();
|
||||
|
||||
let mut stack = Stack::new();
|
||||
stack.add_positioned_child(
|
||||
dialog,
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(0., 0.),
|
||||
ParentOffsetBounds::WindowByPosition,
|
||||
ParentAnchor::Center,
|
||||
ChildAnchor::Center,
|
||||
),
|
||||
);
|
||||
|
||||
Container::new(Align::new(stack.finish()).finish())
|
||||
.with_background_color(Fill::blur().into())
|
||||
.with_corner_radius(app.windows().window_corner_radius())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
pub enum DeleteConversationConfirmationEvent {
|
||||
Confirm {
|
||||
source: DeleteConversationDialogSource,
|
||||
},
|
||||
Cancel,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum DeleteConversationConfirmationAction {
|
||||
Confirm,
|
||||
Cancel,
|
||||
}
|
||||
|
||||
impl TypedActionView for DeleteConversationConfirmationDialog {
|
||||
type Action = DeleteConversationConfirmationAction;
|
||||
|
||||
fn handle_action(
|
||||
&mut self,
|
||||
action: &DeleteConversationConfirmationAction,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
match action {
|
||||
DeleteConversationConfirmationAction::Confirm => {
|
||||
let Some(source) = self.source.clone() else {
|
||||
log::error!("Delete confirm button pressed with no source");
|
||||
return;
|
||||
};
|
||||
ctx.emit(DeleteConversationConfirmationEvent::Confirm { source });
|
||||
}
|
||||
DeleteConversationConfirmationAction::Cancel => {
|
||||
ctx.emit(DeleteConversationConfirmationEvent::Cancel);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
use crate::auth;
|
||||
use crate::network::NetworkStatus;
|
||||
use crate::persistence::ModelEvent;
|
||||
use crate::server::server_api::auth::AuthClient;
|
||||
use crate::terminal::alt_screen_reporting::AltScreenReporting;
|
||||
use crate::terminal::general_settings::GeneralSettings;
|
||||
use crate::{app_state::get_app_state, server::server_api::ServerApiProvider};
|
||||
use ::settings::ToggleableSetting;
|
||||
use warp_core::execution_mode::AppExecutionMode;
|
||||
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::agent::AIAgentExchangeId;
|
||||
use crate::root_view::OpenPath;
|
||||
use crate::undo_close::UndoCloseStack;
|
||||
use crate::workspace::{Workspace, WorkspaceAction};
|
||||
use crate::GlobalResourceHandlesProvider;
|
||||
use std::path::PathBuf;
|
||||
use warp_graphql::mutations::create_anonymous_user::AnonymousUserType;
|
||||
use warpui::windowing::WindowManager;
|
||||
use warpui::{AppContext, SingletonEntity, TypedActionView};
|
||||
|
||||
/// Specifies where a forked conversation should be opened.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub enum ForkedConversationDestination {
|
||||
/// Open the forked conversation in a new pane (split to the right).
|
||||
#[default]
|
||||
SplitPane,
|
||||
/// Open the forked conversation in the current pane, replacing the current view.
|
||||
CurrentPane,
|
||||
/// Open the forked conversation in a new tab.
|
||||
NewTab,
|
||||
}
|
||||
|
||||
impl ForkedConversationDestination {
|
||||
pub fn is_new_tab(&self) -> bool {
|
||||
matches!(self, Self::NewTab)
|
||||
}
|
||||
|
||||
pub fn is_split_pane(&self) -> bool {
|
||||
matches!(self, Self::SplitPane)
|
||||
}
|
||||
|
||||
pub fn is_current_pane(&self) -> bool {
|
||||
matches!(self, Self::CurrentPane)
|
||||
}
|
||||
}
|
||||
|
||||
/// Specifies the exchange at which to fork an AI conversation.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct ForkFromExchange {
|
||||
pub exchange_id: AIAgentExchangeId,
|
||||
/// When true, the fork stops immediately after this exchange without extending
|
||||
/// to the next user query boundary.
|
||||
pub fork_from_exact_exchange: bool,
|
||||
}
|
||||
|
||||
/// Parameters for forking an AI conversation.
|
||||
pub struct ForkAIConversationParams {
|
||||
pub conversation_id: AIConversationId,
|
||||
/// When Some, fork from the given response (or exchange if `fork_from_exact_exchange` is true).
|
||||
pub fork_from_exchange: Option<ForkFromExchange>,
|
||||
pub summarize_after_fork: bool,
|
||||
pub summarization_prompt: Option<String>,
|
||||
pub initial_prompt: Option<String>,
|
||||
pub destination: ForkedConversationDestination,
|
||||
}
|
||||
|
||||
/// DEPRECATED. Global actions are being phased out.
|
||||
/// Do not add any more global actions; use typed actions instead.
|
||||
pub fn init_global_actions(app: &mut AppContext) {
|
||||
app.add_global_action("workspace:toggle_mouse_reporting", toggle_mouse_reporting);
|
||||
app.add_global_action("workspace:toggle_scroll_reporting", toggle_scroll_reporting);
|
||||
app.add_global_action("workspace:toggle_focus_reporting", toggle_focus_reporting);
|
||||
app.add_global_action("workspace:save_app", save_app);
|
||||
app.add_global_action("workspace:fork_ai_conversation", fork_ai_conversation);
|
||||
app.add_global_action(
|
||||
"workspace:summarize_ai_conversation",
|
||||
summarize_ai_conversation,
|
||||
);
|
||||
app.add_global_action(
|
||||
"workspace:toggle_debug_network_status",
|
||||
toggle_debug_network_status,
|
||||
);
|
||||
app.add_global_action(
|
||||
"workspace:debug_create_anonymous_user",
|
||||
create_anonymous_user,
|
||||
);
|
||||
app.add_global_action("workspace:open_repository", open_repository);
|
||||
app.add_global_action("app:undo_close", undo_close);
|
||||
app.add_global_action("app:maybe_log_out", trigger_maybe_log_out);
|
||||
app.add_global_action("app:log_out", trigger_log_out);
|
||||
}
|
||||
|
||||
fn toggle_mouse_reporting(_: &(), ctx: &mut AppContext) {
|
||||
AltScreenReporting::handle(ctx).update(ctx, |reporting, ctx| {
|
||||
reporting
|
||||
.mouse_reporting_enabled
|
||||
.toggle_and_save_value(ctx)
|
||||
.expect("MouseReportingEnabled failed to serialize");
|
||||
});
|
||||
}
|
||||
|
||||
fn toggle_scroll_reporting(_: &(), ctx: &mut AppContext) {
|
||||
AltScreenReporting::handle(ctx).update(ctx, |reporting, ctx| {
|
||||
reporting
|
||||
.scroll_reporting_enabled
|
||||
.toggle_and_save_value(ctx)
|
||||
.expect("ScrollReportingEnabled failed to serialize");
|
||||
});
|
||||
}
|
||||
|
||||
fn toggle_focus_reporting(_: &(), ctx: &mut AppContext) {
|
||||
AltScreenReporting::handle(ctx).update(ctx, |reporting, ctx| {
|
||||
reporting
|
||||
.focus_reporting_enabled
|
||||
.toggle_and_save_value(ctx)
|
||||
.expect("FocusReportingEnabled failed to serialize");
|
||||
});
|
||||
}
|
||||
|
||||
fn save_app(_: &(), ctx: &mut AppContext) {
|
||||
if !AppExecutionMode::as_ref(ctx).can_save_session() {
|
||||
return;
|
||||
}
|
||||
|
||||
if !*GeneralSettings::as_ref(ctx).restore_session {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(model_event_sender) = GlobalResourceHandlesProvider::as_ref(ctx)
|
||||
.get()
|
||||
.model_event_sender
|
||||
.clone()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Only compute the app state if we're definitely going to use it.
|
||||
let app_state = get_app_state(ctx);
|
||||
let event = ModelEvent::Snapshot(app_state);
|
||||
|
||||
if let Err(err) = model_event_sender.send(event) {
|
||||
log::error!("Error trying to send model event {err:?}");
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle_debug_network_status(_: &(), ctx: &mut AppContext) {
|
||||
NetworkStatus::handle(ctx).update(ctx, move |me, ctx| {
|
||||
let is_reachable = me.is_online();
|
||||
let new_is_reachable = !is_reachable;
|
||||
if new_is_reachable {
|
||||
log::info!("Manually toggled network status to be reachable");
|
||||
} else {
|
||||
log::info!("Manually toggled network status to be not reachable");
|
||||
}
|
||||
me.reachability_changed(new_is_reachable, ctx)
|
||||
});
|
||||
}
|
||||
|
||||
fn create_anonymous_user(_: &(), ctx: &mut AppContext) {
|
||||
log::info!("Creating anonymous user");
|
||||
let anonymous_user_type = AnonymousUserType::NativeClientAnonymousUser;
|
||||
let server_api = ServerApiProvider::handle(ctx).read(ctx, |provider, _ctx| provider.get());
|
||||
let result =
|
||||
warpui::r#async::block_on(server_api.create_anonymous_user(None, anonymous_user_type));
|
||||
match result {
|
||||
Ok(user) => log::info!("Successfully created anonymous user {user:?}"),
|
||||
Err(err) => log::error!("Failed to create anonymous user: {err:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reopens the last closed item (window or tab).
|
||||
fn undo_close(_: &(), ctx: &mut AppContext) {
|
||||
UndoCloseStack::handle(ctx).update(ctx, |stack, ctx| {
|
||||
stack.undo_close(ctx);
|
||||
});
|
||||
}
|
||||
|
||||
fn trigger_maybe_log_out(_: &(), ctx: &mut AppContext) {
|
||||
auth::maybe_log_out(ctx)
|
||||
}
|
||||
|
||||
/// Dispatches an action to the active workspace, if one exists.
|
||||
fn dispatch_to_active_workspace(ctx: &mut AppContext, action: WorkspaceAction) {
|
||||
if let Some(window_id) = WindowManager::as_ref(ctx).active_window() {
|
||||
if let Some(workspaces) = ctx.views_of_type::<Workspace>(window_id) {
|
||||
if let Some(workspace) = workspaces.into_iter().next() {
|
||||
workspace.update(ctx, |workspace, ctx| {
|
||||
workspace.handle_action(&action, ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn open_repository(path: &String, ctx: &mut AppContext) {
|
||||
if WindowManager::as_ref(ctx).active_window().is_some() {
|
||||
// There's an active window, dispatch to its workspace
|
||||
dispatch_to_active_workspace(
|
||||
ctx,
|
||||
WorkspaceAction::OpenRepository {
|
||||
path: Some(path.clone()),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
// No active window, create a new one with the repository path
|
||||
let path_buf = PathBuf::from(path);
|
||||
ctx.dispatch_global_action("root_view:open_new_from_path", &OpenPath { path: path_buf });
|
||||
}
|
||||
}
|
||||
|
||||
fn fork_ai_conversation(params: &ForkAIConversationParams, ctx: &mut AppContext) {
|
||||
dispatch_to_active_workspace(
|
||||
ctx,
|
||||
WorkspaceAction::ForkAIConversation {
|
||||
conversation_id: params.conversation_id,
|
||||
fork_from_exchange: params.fork_from_exchange,
|
||||
summarize_after_fork: params.summarize_after_fork,
|
||||
summarization_prompt: params.summarization_prompt.clone(),
|
||||
initial_prompt: params.initial_prompt.clone(),
|
||||
destination: params.destination,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn summarize_ai_conversation(prompt: &Option<String>, ctx: &mut AppContext) {
|
||||
dispatch_to_active_workspace(
|
||||
ctx,
|
||||
WorkspaceAction::SummarizeAIConversation {
|
||||
prompt: prompt.clone(),
|
||||
initial_prompt: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn trigger_log_out(_: &(), ctx: &mut AppContext) {
|
||||
auth::log_out(ctx)
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
use warpui::keymap::FixedBinding;
|
||||
|
||||
use warpui::{AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext};
|
||||
|
||||
use crate::chip_configurator::{
|
||||
render_chip_editor_modal, render_chip_editor_sections, ChipConfigurator,
|
||||
ChipConfiguratorAction, ChipConfiguratorLayout, ChipEditorModalConfig, ChipEditorMouseHandles,
|
||||
ChipEditorSectionsConfig, ConfigurableItem, ControlItemRenderer,
|
||||
};
|
||||
use crate::report_if_error;
|
||||
use crate::settings::AISettings;
|
||||
use crate::workspace::header_toolbar_item::HeaderToolbarItemKind;
|
||||
use crate::workspace::tab_settings::{
|
||||
HeaderToolbarChipSelection, TabSettings, TabSettingsChangedEvent,
|
||||
};
|
||||
use crate::Appearance;
|
||||
|
||||
use settings::Setting as _;
|
||||
|
||||
const MODAL_TITLE: &str = "Edit toolbar";
|
||||
|
||||
pub fn init(app: &mut AppContext) {
|
||||
use warpui::keymap::macros::*;
|
||||
|
||||
app.register_fixed_bindings([FixedBinding::new(
|
||||
"escape",
|
||||
HeaderToolbarEditorAction::Cancel,
|
||||
id!(HeaderToolbarEditorModal::ui_name()),
|
||||
)]);
|
||||
}
|
||||
|
||||
pub enum HeaderToolbarEditorEvent {
|
||||
Close,
|
||||
}
|
||||
|
||||
pub struct HeaderToolbarEditorModal {
|
||||
mouse_handles: ChipEditorMouseHandles,
|
||||
chip_configurator: ChipConfigurator,
|
||||
is_dirty: bool,
|
||||
}
|
||||
pub struct HeaderToolbarInlineEditor {
|
||||
mouse_handles: ChipEditorMouseHandles,
|
||||
chip_configurator: ChipConfigurator,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum HeaderToolbarEditorAction {
|
||||
Cancel,
|
||||
Save,
|
||||
Chip(ChipConfiguratorAction),
|
||||
ResetDefault,
|
||||
Activate,
|
||||
}
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum HeaderToolbarInlineEditorAction {
|
||||
Chip(ChipConfiguratorAction),
|
||||
ResetDefault,
|
||||
Activate,
|
||||
}
|
||||
|
||||
fn open_toolbar_items_from_settings<V: View>(
|
||||
chip_configurator: &mut ChipConfigurator,
|
||||
ctx: &mut ViewContext<V>,
|
||||
) {
|
||||
let selection = TabSettings::as_ref(ctx)
|
||||
.header_toolbar_chip_selection
|
||||
.clone();
|
||||
|
||||
open_toolbar_items(
|
||||
chip_configurator,
|
||||
selection.left_items(),
|
||||
selection.right_items(),
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
|
||||
fn open_toolbar_items<V: View>(
|
||||
chip_configurator: &mut ChipConfigurator,
|
||||
current_left: Vec<HeaderToolbarItemKind>,
|
||||
current_right: Vec<HeaderToolbarItemKind>,
|
||||
ctx: &mut ViewContext<V>,
|
||||
) {
|
||||
let used_set: Vec<HeaderToolbarItemKind> = current_left
|
||||
.iter()
|
||||
.chain(current_right.iter())
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
chip_configurator.reset();
|
||||
chip_configurator.left_chips = current_left
|
||||
.into_iter()
|
||||
.filter(|kind| kind.is_supported(ctx))
|
||||
.map(|kind| build_configurable_item(&kind))
|
||||
.collect();
|
||||
chip_configurator.right_chips = current_right
|
||||
.into_iter()
|
||||
.filter(|kind| kind.is_supported(ctx))
|
||||
.map(|kind| build_configurable_item(&kind))
|
||||
.collect();
|
||||
chip_configurator.unused_chips = HeaderToolbarItemKind::all_items()
|
||||
.into_iter()
|
||||
.filter(|kind| !used_set.contains(kind) && kind.is_supported(ctx))
|
||||
.map(|kind| build_configurable_item(&kind))
|
||||
.collect();
|
||||
}
|
||||
|
||||
fn open_default_toolbar_items<V: View>(
|
||||
chip_configurator: &mut ChipConfigurator,
|
||||
ctx: &mut ViewContext<V>,
|
||||
) {
|
||||
open_toolbar_items(
|
||||
chip_configurator,
|
||||
HeaderToolbarItemKind::default_left(),
|
||||
HeaderToolbarItemKind::default_right(),
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
|
||||
fn current_toolbar_items(
|
||||
chip_configurator: &ChipConfigurator,
|
||||
) -> (Vec<HeaderToolbarItemKind>, Vec<HeaderToolbarItemKind>) {
|
||||
let left = chip_configurator
|
||||
.left_chips
|
||||
.iter()
|
||||
.filter_map(header_toolbar_item_kind)
|
||||
.collect();
|
||||
let right = chip_configurator
|
||||
.right_chips
|
||||
.iter()
|
||||
.filter_map(header_toolbar_item_kind)
|
||||
.collect();
|
||||
(left, right)
|
||||
}
|
||||
|
||||
fn toolbar_items_match_defaults(
|
||||
left: &[HeaderToolbarItemKind],
|
||||
right: &[HeaderToolbarItemKind],
|
||||
) -> bool {
|
||||
left == HeaderToolbarItemKind::default_left() && right == HeaderToolbarItemKind::default_right()
|
||||
}
|
||||
|
||||
fn is_toolbar_editor_at_defaults(chip_configurator: &ChipConfigurator) -> bool {
|
||||
let (left, right) = current_toolbar_items(chip_configurator);
|
||||
toolbar_items_match_defaults(&left, &right)
|
||||
}
|
||||
|
||||
fn save_toolbar_selection<V: View>(
|
||||
left: Vec<HeaderToolbarItemKind>,
|
||||
right: Vec<HeaderToolbarItemKind>,
|
||||
ctx: &mut ViewContext<V>,
|
||||
) {
|
||||
sync_show_hide_settings(&left, &right, ctx);
|
||||
|
||||
let selection = if toolbar_items_match_defaults(&left, &right) {
|
||||
HeaderToolbarChipSelection::Default
|
||||
} else {
|
||||
HeaderToolbarChipSelection::Custom { left, right }
|
||||
};
|
||||
|
||||
TabSettings::handle(ctx).update(ctx, |settings, ctx| {
|
||||
report_if_error!(settings
|
||||
.header_toolbar_chip_selection
|
||||
.set_value(selection, ctx));
|
||||
});
|
||||
}
|
||||
|
||||
fn sync_show_hide_settings<V: View>(
|
||||
left: &[HeaderToolbarItemKind],
|
||||
right: &[HeaderToolbarItemKind],
|
||||
ctx: &mut ViewContext<V>,
|
||||
) {
|
||||
let placed: Vec<&HeaderToolbarItemKind> = left.iter().chain(right.iter()).collect();
|
||||
|
||||
let code_review_placed = placed.contains(&&HeaderToolbarItemKind::CodeReview);
|
||||
if *TabSettings::as_ref(ctx).show_code_review_button.value() != code_review_placed {
|
||||
TabSettings::handle(ctx).update(ctx, |settings, ctx| {
|
||||
report_if_error!(settings
|
||||
.show_code_review_button
|
||||
.set_value(code_review_placed, ctx));
|
||||
});
|
||||
}
|
||||
|
||||
let notifications_placed = placed.contains(&&HeaderToolbarItemKind::NotificationsMailbox);
|
||||
if *AISettings::as_ref(ctx).show_agent_notifications != notifications_placed {
|
||||
AISettings::handle(ctx).update(ctx, |settings, ctx| {
|
||||
report_if_error!(settings
|
||||
.show_agent_notifications
|
||||
.set_value(notifications_placed, ctx));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl HeaderToolbarInlineEditor {
|
||||
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
|
||||
let mut editor = Self {
|
||||
mouse_handles: Default::default(),
|
||||
chip_configurator: ChipConfigurator::new(ChipConfiguratorLayout::LeftRightZones),
|
||||
};
|
||||
editor.reset_from_settings(ctx);
|
||||
|
||||
ctx.subscribe_to_model(&TabSettings::handle(ctx), |me, _, event, ctx| {
|
||||
if matches!(
|
||||
event,
|
||||
TabSettingsChangedEvent::HeaderToolbarChipSelection { .. }
|
||||
) && me.chip_configurator.current_dragging_state.is_none()
|
||||
{
|
||||
me.reset_from_settings(ctx);
|
||||
ctx.notify();
|
||||
}
|
||||
});
|
||||
|
||||
editor
|
||||
}
|
||||
|
||||
fn reset_from_settings(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
open_toolbar_items_from_settings(&mut self.chip_configurator, ctx);
|
||||
}
|
||||
|
||||
fn save_current_selection(&self, ctx: &mut ViewContext<Self>) {
|
||||
let (left, right) = current_toolbar_items(&self.chip_configurator);
|
||||
save_toolbar_selection(left, right, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for HeaderToolbarInlineEditor {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl TypedActionView for HeaderToolbarInlineEditor {
|
||||
type Action = HeaderToolbarInlineEditorAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
Self::Action::Chip(chip_action) => {
|
||||
let should_save = self.chip_configurator.handle_action(chip_action, ctx);
|
||||
if should_save {
|
||||
self.save_current_selection(ctx);
|
||||
}
|
||||
ctx.notify();
|
||||
}
|
||||
Self::Action::ResetDefault => {
|
||||
open_default_toolbar_items(&mut self.chip_configurator, ctx);
|
||||
self.save_current_selection(ctx);
|
||||
ctx.notify();
|
||||
}
|
||||
Self::Action::Activate => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl View for HeaderToolbarInlineEditor {
|
||||
fn ui_name() -> &'static str {
|
||||
"HeaderToolbarInlineEditor"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
render_chip_editor_sections(
|
||||
&self.chip_configurator,
|
||||
ChipEditorSectionsConfig {
|
||||
available_section_label: "Available items",
|
||||
is_at_defaults: is_toolbar_editor_at_defaults(&self.chip_configurator),
|
||||
reset_action: HeaderToolbarInlineEditorAction::ResetDefault,
|
||||
activate_action: HeaderToolbarInlineEditorAction::Activate,
|
||||
chip_action_wrapper: HeaderToolbarInlineEditorAction::Chip,
|
||||
mouse_handles: &self.mouse_handles,
|
||||
},
|
||||
appearance,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl HeaderToolbarEditorModal {
|
||||
pub fn new(_ctx: &mut ViewContext<Self>) -> Self {
|
||||
Self {
|
||||
mouse_handles: Default::default(),
|
||||
chip_configurator: ChipConfigurator::new(ChipConfiguratorLayout::LeftRightZones),
|
||||
is_dirty: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.reset();
|
||||
open_toolbar_items_from_settings(&mut self.chip_configurator, ctx);
|
||||
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn save_to_settings(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
if !self.is_dirty {
|
||||
return;
|
||||
}
|
||||
|
||||
let (left, right) = current_toolbar_items(&self.chip_configurator);
|
||||
save_toolbar_selection(left, right, ctx);
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.chip_configurator.reset();
|
||||
self.is_dirty = false;
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for HeaderToolbarEditorModal {
|
||||
type Event = HeaderToolbarEditorEvent;
|
||||
}
|
||||
|
||||
impl TypedActionView for HeaderToolbarEditorModal {
|
||||
type Action = HeaderToolbarEditorAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
Self::Action::Cancel => {
|
||||
self.reset();
|
||||
ctx.emit(HeaderToolbarEditorEvent::Close);
|
||||
}
|
||||
Self::Action::Save => {
|
||||
self.save_to_settings(ctx);
|
||||
ctx.emit(HeaderToolbarEditorEvent::Close);
|
||||
}
|
||||
Self::Action::Chip(chip_action) => {
|
||||
let mutated = self.chip_configurator.handle_action(chip_action, ctx);
|
||||
if mutated {
|
||||
self.is_dirty = true;
|
||||
}
|
||||
ctx.notify();
|
||||
}
|
||||
Self::Action::ResetDefault => {
|
||||
self.is_dirty = true;
|
||||
open_default_toolbar_items(&mut self.chip_configurator, ctx);
|
||||
|
||||
ctx.notify();
|
||||
}
|
||||
Self::Action::Activate => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl HeaderToolbarEditorModal {
|
||||
fn is_at_defaults(&self) -> bool {
|
||||
is_toolbar_editor_at_defaults(&self.chip_configurator)
|
||||
}
|
||||
}
|
||||
|
||||
impl View for HeaderToolbarEditorModal {
|
||||
fn ui_name() -> &'static str {
|
||||
"HeaderToolbarEditorModal"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
render_chip_editor_modal(
|
||||
&self.chip_configurator,
|
||||
ChipEditorModalConfig {
|
||||
title: MODAL_TITLE,
|
||||
available_section_label: "Available items",
|
||||
is_at_defaults: self.is_at_defaults(),
|
||||
is_dirty: self.is_dirty,
|
||||
cancel_action: HeaderToolbarEditorAction::Cancel,
|
||||
save_action: HeaderToolbarEditorAction::Save,
|
||||
reset_action: HeaderToolbarEditorAction::ResetDefault,
|
||||
activate_action: HeaderToolbarEditorAction::Activate,
|
||||
chip_action_wrapper: HeaderToolbarEditorAction::Chip,
|
||||
mouse_handles: &self.mouse_handles,
|
||||
},
|
||||
appearance,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn build_configurable_item(kind: &HeaderToolbarItemKind) -> ConfigurableItem {
|
||||
let id = serde_json::to_string(kind).expect("HeaderToolbarItemKind is serializable");
|
||||
let renderer =
|
||||
ControlItemRenderer::new_with_label_and_icon(kind.display_label().to_string(), kind.icon())
|
||||
.with_identifier(id);
|
||||
let renderer = match kind {
|
||||
HeaderToolbarItemKind::TabsPanel => renderer.non_removable(),
|
||||
_ => renderer,
|
||||
};
|
||||
ConfigurableItem::Control(renderer)
|
||||
}
|
||||
|
||||
fn header_toolbar_item_kind(item: &ConfigurableItem) -> Option<HeaderToolbarItemKind> {
|
||||
match item {
|
||||
ConfigurableItem::Control(renderer) => {
|
||||
let id = renderer.identifier()?;
|
||||
serde_json::from_str(id).ok()
|
||||
}
|
||||
ConfigurableItem::ContextChip(_) => None,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::auth::AuthStateProvider;
|
||||
use crate::features::FeatureFlag;
|
||||
use crate::settings::AISettings;
|
||||
use crate::ui_components::icons::Icon;
|
||||
use crate::workspace::tab_settings::TabSettings;
|
||||
|
||||
use settings::Setting as _;
|
||||
use warpui::{AppContext, SingletonEntity};
|
||||
|
||||
/// A configurable item in the vertical tabs header toolbar.
|
||||
///
|
||||
/// Each variant represents a panel toggle button that can be placed on either
|
||||
/// the left or right side of the toolbar. The side determines which side of the
|
||||
/// main content area the panel opens on.
|
||||
#[derive(
|
||||
Clone,
|
||||
Debug,
|
||||
Eq,
|
||||
PartialEq,
|
||||
Hash,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
schemars::JsonSchema,
|
||||
settings_value::SettingsValue,
|
||||
)]
|
||||
#[schemars(rename_all = "snake_case")]
|
||||
pub enum HeaderToolbarItemKind {
|
||||
TabsPanel,
|
||||
ToolsPanel,
|
||||
AgentManagement,
|
||||
CodeReview,
|
||||
NotificationsMailbox,
|
||||
}
|
||||
|
||||
impl HeaderToolbarItemKind {
|
||||
pub fn display_label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::TabsPanel => "Tabs Panel",
|
||||
Self::ToolsPanel => "Tools Panel",
|
||||
Self::AgentManagement => "Agent Management",
|
||||
Self::CodeReview => "Code Review",
|
||||
Self::NotificationsMailbox => "Notifications",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn icon(&self) -> Icon {
|
||||
match self {
|
||||
Self::TabsPanel => Icon::Menu,
|
||||
Self::ToolsPanel => Icon::Tool2,
|
||||
Self::AgentManagement => Icon::Grid,
|
||||
Self::CodeReview => Icon::Diff,
|
||||
Self::NotificationsMailbox => Icon::Inbox,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this item is supported on the current platform/configuration
|
||||
/// (feature flags, compile-time features, AI enabled, auth state).
|
||||
/// Does not check user show/hide preferences — use `is_available` for that.
|
||||
pub fn is_supported(&self, app: &AppContext) -> bool {
|
||||
match self {
|
||||
Self::TabsPanel => {
|
||||
FeatureFlag::VerticalTabs.is_enabled()
|
||||
&& *TabSettings::as_ref(app).use_vertical_tabs
|
||||
}
|
||||
Self::ToolsPanel => true,
|
||||
Self::AgentManagement => {
|
||||
let is_web_anonymous_user = AuthStateProvider::as_ref(app)
|
||||
.get()
|
||||
.is_user_web_anonymous_user()
|
||||
.unwrap_or_default();
|
||||
AISettings::as_ref(app).is_any_ai_enabled(app)
|
||||
&& FeatureFlag::AgentManagementView.is_enabled()
|
||||
&& !is_web_anonymous_user
|
||||
}
|
||||
Self::CodeReview => cfg!(feature = "local_fs"),
|
||||
Self::NotificationsMailbox => FeatureFlag::HOANotifications.is_enabled(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this item should be shown in the toolbar.
|
||||
/// Checks both `is_supported` and user show/hide preferences.
|
||||
pub fn is_available(&self, app: &AppContext) -> bool {
|
||||
if !self.is_supported(app) {
|
||||
return false;
|
||||
}
|
||||
match self {
|
||||
Self::CodeReview => *TabSettings::as_ref(app).show_code_review_button.value(),
|
||||
Self::NotificationsMailbox => *AISettings::as_ref(app).show_agent_notifications,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this item opens a side panel (as opposed to replacing the content
|
||||
/// area or opening a popover).
|
||||
pub fn is_panel(&self) -> bool {
|
||||
matches!(self, Self::TabsPanel | Self::ToolsPanel | Self::CodeReview)
|
||||
}
|
||||
|
||||
pub fn default_left() -> Vec<Self> {
|
||||
vec![Self::TabsPanel, Self::ToolsPanel, Self::AgentManagement]
|
||||
}
|
||||
|
||||
pub fn default_right() -> Vec<Self> {
|
||||
vec![Self::CodeReview, Self::NotificationsMailbox]
|
||||
}
|
||||
|
||||
/// All toolbar item variants (availability filtering is done at the call site).
|
||||
pub fn all_items() -> Vec<Self> {
|
||||
vec![
|
||||
Self::TabsPanel,
|
||||
Self::ToolsPanel,
|
||||
Self::AgentManagement,
|
||||
Self::CodeReview,
|
||||
Self::NotificationsMailbox,
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,730 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use markdown_parser::{
|
||||
FormattedText, FormattedTextFragment, FormattedTextLine, FormattedTextStyles, Hyperlink,
|
||||
};
|
||||
use warpui::elements::{
|
||||
Align, ChildView, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Empty, Flex,
|
||||
FormattedTextElement, MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, Radius,
|
||||
Text,
|
||||
};
|
||||
use warpui::fonts::{Properties, Weight};
|
||||
use warpui::geometry::vector::Vector2F;
|
||||
use warpui::keymap::{FixedBinding, Keystroke};
|
||||
use warpui::platform::file_picker::{FilePickerConfiguration, FilePickerError};
|
||||
use warpui::platform::Cursor;
|
||||
use warpui::ui_components::components::UiComponent;
|
||||
use warpui::{
|
||||
AppContext, Element, Entity, EventContext, SingletonEntity, TypedActionView, View, ViewContext,
|
||||
ViewHandle,
|
||||
};
|
||||
|
||||
use pathfinder_color::ColorU;
|
||||
use warp_core::ui::theme::{phenomenon::PhenomenonStyle, Fill};
|
||||
|
||||
use crate::appearance::Appearance;
|
||||
use crate::settings::AISettings;
|
||||
use crate::tab_configs::session_config::{is_git_repo, SessionConfigSelection, SessionType};
|
||||
use crate::tab_configs::session_config_rendering;
|
||||
use crate::ui_components::icons::Icon;
|
||||
use crate::view_components::action_button::{
|
||||
ActionButton, ActionButtonTheme, ButtonSize, KeystrokeSource,
|
||||
};
|
||||
use crate::view_components::callout_bubble::{
|
||||
callout_body_color, callout_checkbox, callout_label_color, callout_title_color,
|
||||
render_callout_bubble, CalloutArrowDirection, CalloutArrowPosition, CalloutBubbleConfig,
|
||||
};
|
||||
use crate::workspace::tab_settings::TabSettings;
|
||||
|
||||
use settings::Setting;
|
||||
|
||||
use super::tab_config_step;
|
||||
use super::welcome_banner;
|
||||
|
||||
const CALLOUT_WIDTH: f32 = 480.;
|
||||
|
||||
struct HoaPrimaryButtonTheme;
|
||||
|
||||
impl ActionButtonTheme for HoaPrimaryButtonTheme {
|
||||
fn background(&self, hovered: bool, _appearance: &Appearance) -> Option<Fill> {
|
||||
Some(PhenomenonStyle::primary_button_background(hovered))
|
||||
}
|
||||
|
||||
fn text_color(
|
||||
&self,
|
||||
_hovered: bool,
|
||||
_background: Option<Fill>,
|
||||
_appearance: &Appearance,
|
||||
) -> ColorU {
|
||||
PhenomenonStyle::primary_button_text()
|
||||
}
|
||||
}
|
||||
struct HoaWelcomeModalButtonTheme;
|
||||
struct HoaWelcomeModalCloseButtonTheme;
|
||||
|
||||
impl ActionButtonTheme for HoaWelcomeModalButtonTheme {
|
||||
fn background(&self, hovered: bool, _appearance: &Appearance) -> Option<Fill> {
|
||||
Some(PhenomenonStyle::modal_button_background_fill(hovered))
|
||||
}
|
||||
|
||||
fn text_color(
|
||||
&self,
|
||||
_hovered: bool,
|
||||
_background: Option<Fill>,
|
||||
_appearance: &Appearance,
|
||||
) -> ColorU {
|
||||
PhenomenonStyle::modal_button_text()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActionButtonTheme for HoaWelcomeModalCloseButtonTheme {
|
||||
fn background(&self, hovered: bool, _appearance: &Appearance) -> Option<Fill> {
|
||||
if hovered {
|
||||
Some(Fill::Solid(PhenomenonStyle::modal_close_button_hover()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn text_color(
|
||||
&self,
|
||||
_hovered: bool,
|
||||
_background: Option<Fill>,
|
||||
_appearance: &Appearance,
|
||||
) -> ColorU {
|
||||
PhenomenonStyle::modal_close_button_text()
|
||||
}
|
||||
}
|
||||
|
||||
/// The 4 sequential steps in the HOA onboarding flow.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum HoaOnboardingStep {
|
||||
WelcomeBanner,
|
||||
VerticalTabsCallout,
|
||||
AgentInboxCallout,
|
||||
TabConfig,
|
||||
}
|
||||
|
||||
impl HoaOnboardingStep {
|
||||
fn index(&self) -> usize {
|
||||
match self {
|
||||
HoaOnboardingStep::WelcomeBanner => 0,
|
||||
HoaOnboardingStep::VerticalTabsCallout => 0,
|
||||
HoaOnboardingStep::AgentInboxCallout => 1,
|
||||
HoaOnboardingStep::TabConfig => 2,
|
||||
}
|
||||
}
|
||||
|
||||
fn total_dots() -> usize {
|
||||
3
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init(app: &mut warpui::AppContext) {
|
||||
use warpui::keymap::macros::*;
|
||||
|
||||
app.register_fixed_bindings([FixedBinding::new(
|
||||
"enter",
|
||||
HoaOnboardingAction::EnterPressed,
|
||||
id!(HoaOnboardingFlow::ui_name()),
|
||||
)]);
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum HoaOnboardingAction {
|
||||
EnterPressed,
|
||||
AdvanceFromWelcome,
|
||||
AdvanceFromVerticalTabs,
|
||||
AdvanceFromInbox,
|
||||
ToggleHorizontalTabs,
|
||||
SelectSessionType(usize),
|
||||
OpenDirectoryPicker,
|
||||
DirectorySelected(Result<String, FilePickerError>),
|
||||
ToggleWorktree,
|
||||
ToggleAutogenerateWorktreeBranchName,
|
||||
Finish,
|
||||
Dismiss,
|
||||
}
|
||||
|
||||
pub enum HoaOnboardingFlowEvent {
|
||||
Completed(Option<SessionConfigSelection>),
|
||||
Dismissed,
|
||||
StepChanged,
|
||||
/// Emitted just before toggling vertical/horizontal tabs so the workspace
|
||||
/// can pin the callout position before the layout shifts.
|
||||
TabLayoutToggled,
|
||||
}
|
||||
|
||||
pub struct HoaOnboardingFlow {
|
||||
step: HoaOnboardingStep,
|
||||
/// When `true`, the user dismissed the welcome banner without clicking
|
||||
/// "See what's new". We show only the vertical-tabs callout with a
|
||||
/// "Dismiss" button and no progress dots.
|
||||
truncated_flow: bool,
|
||||
|
||||
// Step 1 state
|
||||
close_button: ViewHandle<ActionButton>,
|
||||
cta_button: ViewHandle<ActionButton>,
|
||||
|
||||
// Step 2 state
|
||||
horizontal_tabs_checkbox_mouse_state: MouseStateHandle,
|
||||
next_vtabs_button: ViewHandle<ActionButton>,
|
||||
dismiss_vtabs_button: ViewHandle<ActionButton>,
|
||||
|
||||
// Step 3 state
|
||||
next_inbox_button: ViewHandle<ActionButton>,
|
||||
|
||||
// Step 4 state
|
||||
finish_button: ViewHandle<ActionButton>,
|
||||
session_types: Vec<SessionType>,
|
||||
selected_session_type_index: usize,
|
||||
selected_directory: PathBuf,
|
||||
is_git_repo: bool,
|
||||
enable_worktree: bool,
|
||||
autogenerate_worktree_branch_name: bool,
|
||||
session_pill_mouse_states: Vec<MouseStateHandle>,
|
||||
directory_button_mouse_state: MouseStateHandle,
|
||||
worktree_checkbox_mouse_state: MouseStateHandle,
|
||||
worktree_tooltip_mouse_state: MouseStateHandle,
|
||||
autogenerate_checkbox_mouse_state: MouseStateHandle,
|
||||
autogenerate_tooltip_mouse_state: MouseStateHandle,
|
||||
}
|
||||
|
||||
impl HoaOnboardingFlow {
|
||||
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
|
||||
let show_oz = AISettings::as_ref(ctx).is_any_ai_enabled(ctx);
|
||||
let session_types = session_config_rendering::visible_session_types(show_oz);
|
||||
let pill_mouse_states: Vec<_> = session_types
|
||||
.iter()
|
||||
.map(|_| MouseStateHandle::default())
|
||||
.collect();
|
||||
|
||||
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"));
|
||||
let is_git = is_git_repo(&home);
|
||||
|
||||
let close_button = ctx.add_view(|_ctx| {
|
||||
ActionButton::new("", HoaWelcomeModalCloseButtonTheme)
|
||||
.with_icon(Icon::X)
|
||||
.with_size(ButtonSize::Small)
|
||||
.on_click(|ctx| ctx.dispatch_typed_action(HoaOnboardingAction::Dismiss))
|
||||
});
|
||||
|
||||
let cta_button = ctx.add_view(|_ctx| {
|
||||
ActionButton::new("See what's new", HoaWelcomeModalButtonTheme)
|
||||
.with_full_width(true)
|
||||
.on_click(|ctx| ctx.dispatch_typed_action(HoaOnboardingAction::AdvanceFromWelcome))
|
||||
});
|
||||
|
||||
let enter = Keystroke::parse("enter").unwrap_or_default();
|
||||
|
||||
let next_vtabs_button = ctx.add_view(|ctx| {
|
||||
ActionButton::new("Next", HoaPrimaryButtonTheme)
|
||||
.with_keybinding(KeystrokeSource::Fixed(enter.clone()), ctx)
|
||||
.on_click(|ctx| {
|
||||
ctx.dispatch_typed_action(HoaOnboardingAction::AdvanceFromVerticalTabs)
|
||||
})
|
||||
});
|
||||
|
||||
let dismiss_vtabs_button = ctx.add_view(|ctx| {
|
||||
ActionButton::new("Dismiss", HoaPrimaryButtonTheme)
|
||||
.with_keybinding(KeystrokeSource::Fixed(enter.clone()), ctx)
|
||||
.on_click(|ctx| ctx.dispatch_typed_action(HoaOnboardingAction::Dismiss))
|
||||
});
|
||||
|
||||
let next_inbox_button = ctx.add_view(|ctx| {
|
||||
ActionButton::new("Next", HoaPrimaryButtonTheme)
|
||||
.with_keybinding(KeystrokeSource::Fixed(enter.clone()), ctx)
|
||||
.on_click(|ctx| ctx.dispatch_typed_action(HoaOnboardingAction::AdvanceFromInbox))
|
||||
});
|
||||
|
||||
let finish_button = ctx.add_view(|ctx| {
|
||||
ActionButton::new("Finish", HoaPrimaryButtonTheme)
|
||||
.with_keybinding(KeystrokeSource::Fixed(enter), ctx)
|
||||
.on_click(|ctx| ctx.dispatch_typed_action(HoaOnboardingAction::Finish))
|
||||
});
|
||||
|
||||
Self {
|
||||
step: HoaOnboardingStep::WelcomeBanner,
|
||||
truncated_flow: false,
|
||||
close_button,
|
||||
cta_button,
|
||||
horizontal_tabs_checkbox_mouse_state: MouseStateHandle::default(),
|
||||
next_vtabs_button,
|
||||
dismiss_vtabs_button,
|
||||
next_inbox_button,
|
||||
finish_button,
|
||||
session_types,
|
||||
selected_session_type_index: 0,
|
||||
selected_directory: home,
|
||||
is_git_repo: is_git,
|
||||
enable_worktree: false,
|
||||
autogenerate_worktree_branch_name: false,
|
||||
session_pill_mouse_states: pill_mouse_states,
|
||||
directory_button_mouse_state: MouseStateHandle::default(),
|
||||
worktree_checkbox_mouse_state: MouseStateHandle::default(),
|
||||
worktree_tooltip_mouse_state: MouseStateHandle::default(),
|
||||
autogenerate_checkbox_mouse_state: MouseStateHandle::default(),
|
||||
autogenerate_tooltip_mouse_state: MouseStateHandle::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn step(&self) -> HoaOnboardingStep {
|
||||
self.step
|
||||
}
|
||||
|
||||
fn selected_session_type(&self) -> SessionType {
|
||||
self.session_types[self.selected_session_type_index]
|
||||
}
|
||||
|
||||
fn advance(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
// In truncated mode, there are no steps after the vertical-tabs callout.
|
||||
if self.truncated_flow && self.step == HoaOnboardingStep::VerticalTabsCallout {
|
||||
ctx.emit(HoaOnboardingFlowEvent::Dismissed);
|
||||
return;
|
||||
}
|
||||
|
||||
self.step = match self.step {
|
||||
HoaOnboardingStep::WelcomeBanner => HoaOnboardingStep::VerticalTabsCallout,
|
||||
HoaOnboardingStep::VerticalTabsCallout => HoaOnboardingStep::AgentInboxCallout,
|
||||
HoaOnboardingStep::AgentInboxCallout => HoaOnboardingStep::TabConfig,
|
||||
HoaOnboardingStep::TabConfig => {
|
||||
self.finish(ctx);
|
||||
return;
|
||||
}
|
||||
};
|
||||
// Emit StepChanged so the workspace re-renders and switches
|
||||
// from add_child to add_positioned_child for the new step.
|
||||
ctx.emit(HoaOnboardingFlowEvent::StepChanged);
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn finish(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
ctx.emit(HoaOnboardingFlowEvent::Completed(Some(
|
||||
SessionConfigSelection {
|
||||
session_type: self.selected_session_type(),
|
||||
directory: self.selected_directory.clone(),
|
||||
enable_worktree: self.enable_worktree,
|
||||
autogenerate_worktree_branch_name: self.autogenerate_worktree_branch_name,
|
||||
},
|
||||
)));
|
||||
}
|
||||
|
||||
fn dismiss(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
if self.step == HoaOnboardingStep::WelcomeBanner {
|
||||
// User dismissed the welcome banner without clicking "See what's new".
|
||||
// Show only the vertical-tabs callout in truncated mode.
|
||||
self.truncated_flow = true;
|
||||
self.step = HoaOnboardingStep::VerticalTabsCallout;
|
||||
ctx.emit(HoaOnboardingFlowEvent::StepChanged);
|
||||
ctx.notify();
|
||||
} else {
|
||||
ctx.emit(HoaOnboardingFlowEvent::Dismissed);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rendering helpers ──
|
||||
|
||||
fn render_progress_dots(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
let _ = appearance;
|
||||
let active_index = self.step.index();
|
||||
|
||||
let mut row = Flex::row().with_spacing(4.);
|
||||
for i in 0..HoaOnboardingStep::total_dots() {
|
||||
let fill = if i == active_index {
|
||||
Fill::Solid(PhenomenonStyle::blue())
|
||||
} else {
|
||||
Fill::Solid(PhenomenonStyle::subtle_border())
|
||||
};
|
||||
row.add_child(
|
||||
ConstrainedBox::new(
|
||||
Container::new(Empty::new().finish())
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Percentage(50.)))
|
||||
.with_background(fill)
|
||||
.finish(),
|
||||
)
|
||||
.with_width(8.)
|
||||
.with_height(8.)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
row.finish()
|
||||
}
|
||||
|
||||
fn render_callout_footer(
|
||||
&self,
|
||||
button: &ViewHandle<ActionButton>,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let mut row = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_main_axis_size(MainAxisSize::Max);
|
||||
|
||||
if self.truncated_flow {
|
||||
// No progress dots – right-align the dismiss button.
|
||||
row = row.with_main_axis_alignment(MainAxisAlignment::End);
|
||||
} else {
|
||||
row = row.with_main_axis_alignment(MainAxisAlignment::SpaceBetween);
|
||||
row.add_child(self.render_progress_dots(appearance));
|
||||
}
|
||||
row.add_child(ChildView::new(button).finish());
|
||||
|
||||
Container::new(row.finish())
|
||||
.with_horizontal_padding(16.)
|
||||
.with_vertical_padding(16.)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_callout_content(
|
||||
&self,
|
||||
title: &'static str,
|
||||
description: &'static str,
|
||||
extra_child: Option<Box<dyn Element>>,
|
||||
button: &ViewHandle<ActionButton>,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
let title = Text::new(title, appearance.ui_font_family(), 16.)
|
||||
.with_color(callout_title_color(appearance))
|
||||
.with_style(Properties::default().weight(Weight::Bold))
|
||||
.finish();
|
||||
|
||||
let description = Text::new(description, appearance.ui_font_family(), 14.)
|
||||
.with_color(callout_body_color(appearance))
|
||||
.finish();
|
||||
|
||||
let mut body_content = Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
||||
.with_child(title)
|
||||
.with_child(Container::new(description).with_margin_top(8.).finish());
|
||||
|
||||
if let Some(extra_child) = extra_child {
|
||||
body_content.add_child(Container::new(extra_child).with_margin_top(8.).finish());
|
||||
}
|
||||
|
||||
let body = Container::new(body_content.finish())
|
||||
.with_horizontal_padding(16.)
|
||||
.with_padding_top(16.)
|
||||
.with_padding_bottom(12.)
|
||||
.finish();
|
||||
let footer = self.render_callout_footer(button, appearance);
|
||||
|
||||
Flex::column().with_child(body).with_child(footer).finish()
|
||||
}
|
||||
|
||||
fn render_vertical_tabs_callout(
|
||||
&self,
|
||||
appearance: &Appearance,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let use_vertical = *TabSettings::as_ref(app).use_vertical_tabs;
|
||||
let checkbox_mouse = self.horizontal_tabs_checkbox_mouse_state.clone();
|
||||
let checkbox_el = callout_checkbox(checkbox_mouse, Some(10.5), appearance)
|
||||
.check(!use_vertical)
|
||||
.build()
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(HoaOnboardingAction::ToggleHorizontalTabs);
|
||||
})
|
||||
.finish();
|
||||
|
||||
let checkbox_label = Text::new_inline(
|
||||
"Switch back to horizontal tabs".to_string(),
|
||||
appearance.ui_font_family(),
|
||||
12.,
|
||||
)
|
||||
.with_color(callout_label_color(appearance))
|
||||
.finish();
|
||||
|
||||
let checkbox_row = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_spacing(8.)
|
||||
.with_child(checkbox_el)
|
||||
.with_child(checkbox_label)
|
||||
.finish();
|
||||
|
||||
let button = if self.truncated_flow {
|
||||
&self.dismiss_vtabs_button
|
||||
} else {
|
||||
&self.next_vtabs_button
|
||||
};
|
||||
|
||||
self.render_callout_content(
|
||||
"Introducing vertical tabs - the new default",
|
||||
"Vertical tabs show all open agent and terminal panes, grouped by tab. Customize what information you want to see to support your workflow.",
|
||||
Some(checkbox_row),
|
||||
button,
|
||||
appearance,
|
||||
)
|
||||
}
|
||||
|
||||
fn render_inbox_callout(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
let title = Text::new(
|
||||
"Meet your new agent inbox",
|
||||
appearance.ui_font_family(),
|
||||
16.,
|
||||
)
|
||||
.with_color(callout_title_color(appearance))
|
||||
.with_style(Properties::default().weight(Weight::Bold))
|
||||
.finish();
|
||||
|
||||
// Build the description with an inline "Learn more" hyperlink.
|
||||
let learn_more_fragment = FormattedTextFragment {
|
||||
text: "Learn more".into(),
|
||||
styles: FormattedTextStyles {
|
||||
underline: true,
|
||||
hyperlink: Some(Hyperlink::Url(
|
||||
"https://docs.warp.dev/agent-platform/warp-agents/agent-notifications".into(),
|
||||
)),
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
|
||||
let formatted = FormattedText::new([FormattedTextLine::Line(vec![
|
||||
FormattedTextFragment::plain_text(
|
||||
"Warp pipes through notifications from any CLI coding agent into a unified notification center that works across all coding agents and harnesses. ",
|
||||
),
|
||||
learn_more_fragment,
|
||||
])]);
|
||||
|
||||
let description = FormattedTextElement::new(
|
||||
formatted,
|
||||
14.,
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_family(),
|
||||
callout_body_color(appearance),
|
||||
Default::default(),
|
||||
)
|
||||
.with_line_height_ratio(1.2)
|
||||
.register_default_click_handlers(|link, _ctx, app| {
|
||||
app.open_url(&link.url);
|
||||
})
|
||||
.finish();
|
||||
|
||||
let body_content = Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
||||
.with_child(title)
|
||||
.with_child(Container::new(description).with_margin_top(8.).finish())
|
||||
.finish();
|
||||
|
||||
let body = Container::new(body_content)
|
||||
.with_horizontal_padding(16.)
|
||||
.with_padding_top(16.)
|
||||
.with_padding_bottom(12.)
|
||||
.finish();
|
||||
let footer = self.render_callout_footer(&self.next_inbox_button, appearance);
|
||||
|
||||
Flex::column().with_child(body).with_child(footer).finish()
|
||||
}
|
||||
|
||||
fn render_tab_config_step(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
let form = tab_config_step::render_tab_config_form(
|
||||
tab_config_step::TabConfigFormState {
|
||||
session_types: &self.session_types,
|
||||
selected_session_type_index: self.selected_session_type_index,
|
||||
session_pill_mouse_states: &self.session_pill_mouse_states,
|
||||
selected_directory: &self.selected_directory,
|
||||
directory_button_mouse_state: self.directory_button_mouse_state.clone(),
|
||||
enable_worktree: self.enable_worktree,
|
||||
is_git_repo: self.is_git_repo,
|
||||
worktree_checkbox_mouse_state: self.worktree_checkbox_mouse_state.clone(),
|
||||
worktree_tooltip_mouse_state: self.worktree_tooltip_mouse_state.clone(),
|
||||
autogenerate_worktree_branch_name: self.autogenerate_worktree_branch_name,
|
||||
autogenerate_checkbox_mouse_state: self.autogenerate_checkbox_mouse_state.clone(),
|
||||
autogenerate_tooltip_mouse_state: self.autogenerate_tooltip_mouse_state.clone(),
|
||||
},
|
||||
tab_config_step::TabConfigFormHandlers {
|
||||
on_select_session_type: |i: usize, ctx: &mut EventContext, _: Vector2F| {
|
||||
ctx.dispatch_typed_action(HoaOnboardingAction::SelectSessionType(i));
|
||||
},
|
||||
on_open_directory_picker: |ctx: &mut EventContext, _: Vector2F| {
|
||||
ctx.dispatch_typed_action(HoaOnboardingAction::OpenDirectoryPicker);
|
||||
},
|
||||
on_toggle_worktree: |ctx: &mut EventContext, _: Vector2F| {
|
||||
ctx.dispatch_typed_action(HoaOnboardingAction::ToggleWorktree);
|
||||
},
|
||||
on_toggle_autogenerate: |ctx: &mut EventContext, _: Vector2F| {
|
||||
ctx.dispatch_typed_action(
|
||||
HoaOnboardingAction::ToggleAutogenerateWorktreeBranchName,
|
||||
);
|
||||
},
|
||||
},
|
||||
appearance,
|
||||
);
|
||||
|
||||
let footer = self.render_callout_footer(&self.finish_button, appearance);
|
||||
|
||||
let body = Container::new(form)
|
||||
.with_horizontal_padding(16.)
|
||||
.with_vertical_padding(16.)
|
||||
.finish();
|
||||
|
||||
Flex::column().with_child(body).with_child(footer).finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for HoaOnboardingFlow {
|
||||
type Event = HoaOnboardingFlowEvent;
|
||||
}
|
||||
|
||||
impl View for HoaOnboardingFlow {
|
||||
fn ui_name() -> &'static str {
|
||||
"HoaOnboardingFlow"
|
||||
}
|
||||
|
||||
fn on_focus(&mut self, _focus_ctx: &warpui::FocusContext, ctx: &mut ViewContext<Self>) {
|
||||
ctx.focus_self();
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
|
||||
match self.step {
|
||||
HoaOnboardingStep::WelcomeBanner => {
|
||||
// Full-window scrim with centered banner
|
||||
let banner = welcome_banner::render_welcome_banner(
|
||||
&self.close_button,
|
||||
&self.cta_button,
|
||||
appearance,
|
||||
);
|
||||
|
||||
Container::new(Align::new(banner).finish())
|
||||
.with_background_color(ColorU::new(18, 18, 18, 128))
|
||||
.finish()
|
||||
}
|
||||
HoaOnboardingStep::VerticalTabsCallout => {
|
||||
let content = self.render_vertical_tabs_callout(appearance, app);
|
||||
let use_vertical = *TabSettings::as_ref(app).use_vertical_tabs;
|
||||
let (arrow_direction, arrow_position) = if use_vertical {
|
||||
(
|
||||
CalloutArrowDirection::Left,
|
||||
CalloutArrowPosition::Start(16.),
|
||||
)
|
||||
} else {
|
||||
(CalloutArrowDirection::Up, CalloutArrowPosition::Start(24.))
|
||||
};
|
||||
render_callout_bubble(
|
||||
content,
|
||||
&CalloutBubbleConfig {
|
||||
width: CALLOUT_WIDTH,
|
||||
arrow_direction,
|
||||
arrow_position,
|
||||
},
|
||||
appearance,
|
||||
)
|
||||
}
|
||||
HoaOnboardingStep::AgentInboxCallout => {
|
||||
let content = self.render_inbox_callout(appearance);
|
||||
render_callout_bubble(
|
||||
content,
|
||||
&CalloutBubbleConfig {
|
||||
width: CALLOUT_WIDTH,
|
||||
arrow_direction: CalloutArrowDirection::Up,
|
||||
arrow_position: CalloutArrowPosition::End(24.),
|
||||
},
|
||||
appearance,
|
||||
)
|
||||
}
|
||||
HoaOnboardingStep::TabConfig => {
|
||||
let tab_content = self.render_tab_config_step(appearance);
|
||||
let use_vertical = *TabSettings::as_ref(app).use_vertical_tabs;
|
||||
let (arrow_direction, arrow_position) = if use_vertical {
|
||||
(
|
||||
CalloutArrowDirection::Left,
|
||||
CalloutArrowPosition::Start(16.),
|
||||
)
|
||||
} else {
|
||||
(CalloutArrowDirection::Up, CalloutArrowPosition::Center)
|
||||
};
|
||||
render_callout_bubble(
|
||||
tab_content,
|
||||
&CalloutBubbleConfig {
|
||||
width: CALLOUT_WIDTH,
|
||||
arrow_direction,
|
||||
arrow_position,
|
||||
},
|
||||
appearance,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for HoaOnboardingFlow {
|
||||
type Action = HoaOnboardingAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
HoaOnboardingAction::EnterPressed => {
|
||||
self.advance(ctx);
|
||||
}
|
||||
HoaOnboardingAction::AdvanceFromWelcome
|
||||
| HoaOnboardingAction::AdvanceFromVerticalTabs
|
||||
| HoaOnboardingAction::AdvanceFromInbox => {
|
||||
self.advance(ctx);
|
||||
}
|
||||
HoaOnboardingAction::ToggleHorizontalTabs => {
|
||||
// Emit before toggling so workspace can pin the callout position.
|
||||
ctx.emit(HoaOnboardingFlowEvent::TabLayoutToggled);
|
||||
let current = *TabSettings::as_ref(ctx).use_vertical_tabs;
|
||||
TabSettings::handle(ctx).update(ctx, |settings, ctx| {
|
||||
let _ = settings.use_vertical_tabs.set_value(!current, ctx);
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
HoaOnboardingAction::SelectSessionType(index) => {
|
||||
self.selected_session_type_index = *index;
|
||||
ctx.notify();
|
||||
}
|
||||
HoaOnboardingAction::OpenDirectoryPicker => {
|
||||
ctx.open_file_picker(
|
||||
|result, ctx| {
|
||||
if let Some(path_result) =
|
||||
result.map(|paths| paths.into_iter().next()).transpose()
|
||||
{
|
||||
ctx.dispatch_typed_action(&HoaOnboardingAction::DirectorySelected(
|
||||
path_result,
|
||||
));
|
||||
}
|
||||
},
|
||||
FilePickerConfiguration::new().folders_only(),
|
||||
);
|
||||
}
|
||||
HoaOnboardingAction::DirectorySelected(result) => match result {
|
||||
Ok(path) => {
|
||||
let path = PathBuf::from(path);
|
||||
self.is_git_repo = is_git_repo(&path);
|
||||
if !self.is_git_repo {
|
||||
self.enable_worktree = false;
|
||||
self.autogenerate_worktree_branch_name = false;
|
||||
}
|
||||
self.selected_directory = path;
|
||||
ctx.notify();
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!("File picker error in HOA onboarding: {err}");
|
||||
}
|
||||
},
|
||||
HoaOnboardingAction::ToggleWorktree => {
|
||||
if self.is_git_repo {
|
||||
self.enable_worktree = !self.enable_worktree;
|
||||
if !self.enable_worktree {
|
||||
self.autogenerate_worktree_branch_name = false;
|
||||
}
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
HoaOnboardingAction::ToggleAutogenerateWorktreeBranchName => {
|
||||
if self.enable_worktree {
|
||||
self.autogenerate_worktree_branch_name =
|
||||
!self.autogenerate_worktree_branch_name;
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
HoaOnboardingAction::Finish => {
|
||||
self.finish(ctx);
|
||||
}
|
||||
HoaOnboardingAction::Dismiss => {
|
||||
self.dismiss(ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
mod hoa_onboarding_flow;
|
||||
mod tab_config_step;
|
||||
mod welcome_banner;
|
||||
|
||||
pub use hoa_onboarding_flow::{init, HoaOnboardingFlow, HoaOnboardingFlowEvent, HoaOnboardingStep};
|
||||
|
||||
use warpui::AppContext;
|
||||
|
||||
use warp_core::user_preferences::GetUserPreferences;
|
||||
|
||||
const HAS_COMPLETED_HOA_ONBOARDING_KEY: &str = "HasCompletedHOAOnboarding";
|
||||
|
||||
pub fn has_completed_hoa_onboarding(ctx: &AppContext) -> bool {
|
||||
ctx.private_user_preferences()
|
||||
.read_value(HAS_COMPLETED_HOA_ONBOARDING_KEY)
|
||||
.unwrap_or_default()
|
||||
.and_then(|s| serde_json::from_str::<bool>(&s).ok())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn mark_hoa_onboarding_completed(ctx: &AppContext) {
|
||||
let _ = ctx.private_user_preferences().write_value(
|
||||
HAS_COMPLETED_HOA_ONBOARDING_KEY,
|
||||
serde_json::to_string(&true).expect("bool serializes to JSON"),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
use std::path::Path;
|
||||
|
||||
use warpui::elements::{
|
||||
Container, CrossAxisAlignment, Flex, MouseStateHandle, ParentElement, Text,
|
||||
};
|
||||
use warpui::fonts::{Properties, Weight};
|
||||
use warpui::geometry::vector::Vector2F;
|
||||
use warpui::Element;
|
||||
use warpui::EventContext;
|
||||
|
||||
use crate::appearance::Appearance;
|
||||
use crate::tab_configs::session_config::SessionType;
|
||||
use crate::tab_configs::session_config_rendering;
|
||||
use crate::view_components::callout_bubble::{
|
||||
callout_background_fill, callout_body_color, callout_title_color,
|
||||
};
|
||||
|
||||
const SECTION_GAP: f32 = 16.;
|
||||
|
||||
pub struct TabConfigFormState<'a> {
|
||||
pub session_types: &'a [SessionType],
|
||||
pub selected_session_type_index: usize,
|
||||
pub session_pill_mouse_states: &'a [MouseStateHandle],
|
||||
pub selected_directory: &'a Path,
|
||||
pub directory_button_mouse_state: MouseStateHandle,
|
||||
pub enable_worktree: bool,
|
||||
pub is_git_repo: bool,
|
||||
pub worktree_checkbox_mouse_state: MouseStateHandle,
|
||||
pub worktree_tooltip_mouse_state: MouseStateHandle,
|
||||
pub autogenerate_worktree_branch_name: bool,
|
||||
pub autogenerate_checkbox_mouse_state: MouseStateHandle,
|
||||
pub autogenerate_tooltip_mouse_state: MouseStateHandle,
|
||||
}
|
||||
|
||||
pub struct TabConfigFormHandlers<F1, F2, F3, F4> {
|
||||
pub on_select_session_type: F1,
|
||||
pub on_open_directory_picker: F2,
|
||||
pub on_toggle_worktree: F3,
|
||||
pub on_toggle_autogenerate: F4,
|
||||
}
|
||||
|
||||
/// Renders the tab config form content (session type + directory + worktree).
|
||||
///
|
||||
/// This is the body of Step 4, without the surrounding popover chrome.
|
||||
/// Action callbacks are passed in so the caller owns the dispatch logic.
|
||||
pub fn render_tab_config_form<F1, F2, F3, F4>(
|
||||
state: TabConfigFormState<'_>,
|
||||
handlers: TabConfigFormHandlers<F1, F2, F3, F4>,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element>
|
||||
where
|
||||
F1: Fn(usize, &mut EventContext, Vector2F) + 'static,
|
||||
F2: Fn(&mut EventContext, Vector2F) + 'static,
|
||||
F3: Fn(&mut EventContext, Vector2F) + 'static,
|
||||
F4: Fn(&mut EventContext, Vector2F) + 'static,
|
||||
{
|
||||
let callout_bg = callout_background_fill(appearance).into_solid();
|
||||
let title = Text::new(
|
||||
"Create your first tab config",
|
||||
appearance.ui_font_family(),
|
||||
16.,
|
||||
)
|
||||
.with_color(callout_title_color(appearance))
|
||||
.with_style(Properties::default().weight(Weight::Bold))
|
||||
.finish();
|
||||
|
||||
let description = Text::new(
|
||||
"Set up a reusable starting point for your tabs. Pick a repo, choose a session type, and optionally attach a worktree. Use it whenever you want to open a tab with this setup.",
|
||||
appearance.ui_font_family(),
|
||||
14.,
|
||||
)
|
||||
.with_color(callout_body_color(appearance))
|
||||
.finish();
|
||||
|
||||
let session_type_section = session_config_rendering::render_session_type_pills_with_background(
|
||||
state.session_types,
|
||||
state.selected_session_type_index,
|
||||
state.session_pill_mouse_states,
|
||||
handlers.on_select_session_type,
|
||||
Some(callout_bg),
|
||||
appearance,
|
||||
);
|
||||
|
||||
let directory_section = session_config_rendering::render_directory_picker_with_background(
|
||||
state.selected_directory,
|
||||
state.directory_button_mouse_state,
|
||||
handlers.on_open_directory_picker,
|
||||
Some(callout_bg),
|
||||
appearance,
|
||||
);
|
||||
|
||||
let worktree_section = session_config_rendering::render_worktree_checkbox_with_background(
|
||||
state.enable_worktree,
|
||||
state.is_git_repo,
|
||||
state.worktree_checkbox_mouse_state,
|
||||
state.worktree_tooltip_mouse_state,
|
||||
handlers.on_toggle_worktree,
|
||||
Some(callout_bg),
|
||||
appearance,
|
||||
);
|
||||
|
||||
let autogenerate_section =
|
||||
session_config_rendering::render_autogenerate_worktree_branch_name_checkbox_with_background(
|
||||
state.autogenerate_worktree_branch_name,
|
||||
state.enable_worktree,
|
||||
state.autogenerate_checkbox_mouse_state,
|
||||
state.autogenerate_tooltip_mouse_state,
|
||||
handlers.on_toggle_autogenerate,
|
||||
Some(callout_bg),
|
||||
appearance,
|
||||
);
|
||||
|
||||
Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
||||
.with_child(title)
|
||||
.with_child(Container::new(description).with_margin_top(8.).finish())
|
||||
.with_child(
|
||||
Container::new(session_type_section)
|
||||
.with_margin_top(SECTION_GAP)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
Container::new(directory_section)
|
||||
.with_margin_top(SECTION_GAP)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
Container::new(worktree_section)
|
||||
.with_margin_top(SECTION_GAP)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
Container::new(autogenerate_section)
|
||||
.with_margin_top(8.)
|
||||
.finish(),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use warp_core::ui::theme::{phenomenon::PhenomenonStyle, Fill};
|
||||
use warpui::assets::asset_cache::AssetSource;
|
||||
use warpui::elements::{
|
||||
CacheOption, ChildAnchor, ChildView, ConstrainedBox, Container, CornerRadius,
|
||||
CrossAxisAlignment, Expanded, Flex, Image, MainAxisSize, OffsetPositioning, ParentAnchor,
|
||||
ParentElement, ParentOffsetBounds, Radius, Stack, Text,
|
||||
};
|
||||
use warpui::fonts::{Properties, Weight};
|
||||
use warpui::Element;
|
||||
|
||||
use crate::appearance::Appearance;
|
||||
use crate::ui_components::icons::Icon;
|
||||
use crate::view_components::action_button::ActionButton;
|
||||
|
||||
use warpui::ViewHandle;
|
||||
|
||||
const BANNER_WIDTH: f32 = 420.;
|
||||
const HERO_HEIGHT: f32 = 92.;
|
||||
const HERO_IMAGE_PATH: &str = "async/png/onboarding/hoa_welcome_banner.png";
|
||||
|
||||
struct FeatureItem {
|
||||
icon: Icon,
|
||||
title: &'static str,
|
||||
description: &'static str,
|
||||
}
|
||||
|
||||
const FEATURE_ITEMS: &[FeatureItem] = &[
|
||||
FeatureItem {
|
||||
icon: Icon::LayoutAlt01,
|
||||
title: "Vertical tabs",
|
||||
description: "Rich tab titles and metadata like git branch, worktree, and PR. Fully customizable.",
|
||||
},
|
||||
FeatureItem {
|
||||
icon: Icon::Sliders,
|
||||
title: "Tab configs",
|
||||
description: "Tab-level schema to set your directory, startup commands, theme, and worktree with one click",
|
||||
},
|
||||
FeatureItem {
|
||||
icon: Icon::Inbox,
|
||||
title: "Agent inbox",
|
||||
description: "Notifications when any agent needs your attention, also accessible in a central inbox",
|
||||
},
|
||||
FeatureItem {
|
||||
icon: Icon::MessageCheckSquare,
|
||||
title: "Native code review",
|
||||
description: "Send inline comments from Warp's code review directly to Claude Code, Codex, or OpenCode",
|
||||
},
|
||||
];
|
||||
|
||||
pub fn render_welcome_banner(
|
||||
close_button: &ViewHandle<ActionButton>,
|
||||
cta_button: &ViewHandle<ActionButton>,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
// Hero image with close button overlay
|
||||
let hero = ConstrainedBox::new(
|
||||
Image::new(
|
||||
AssetSource::Bundled {
|
||||
path: HERO_IMAGE_PATH,
|
||||
},
|
||||
CacheOption::Original,
|
||||
)
|
||||
.with_corner_radius(CornerRadius::with_top(Radius::Pixels(8.)))
|
||||
.cover()
|
||||
.top_aligned()
|
||||
.finish(),
|
||||
)
|
||||
.with_width(BANNER_WIDTH)
|
||||
.with_height(HERO_HEIGHT)
|
||||
.finish();
|
||||
|
||||
let close_el = Container::new(ChildView::new(close_button).finish()).finish();
|
||||
|
||||
let mut hero_stack = Stack::new();
|
||||
hero_stack.add_child(hero);
|
||||
hero_stack.add_positioned_child(
|
||||
close_el,
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(-4., 0.),
|
||||
ParentOffsetBounds::ParentByPosition,
|
||||
ParentAnchor::TopRight,
|
||||
ChildAnchor::TopRight,
|
||||
),
|
||||
);
|
||||
|
||||
// "New" badge
|
||||
let badge = Container::new(
|
||||
Text::new_inline("New".to_string(), appearance.ui_font_family(), 14.)
|
||||
.with_color(PhenomenonStyle::modal_badge_text())
|
||||
.finish(),
|
||||
)
|
||||
.with_horizontal_padding(8.)
|
||||
.with_vertical_padding(2.)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
|
||||
.with_background(Fill::Solid(PhenomenonStyle::modal_badge_background()))
|
||||
.finish();
|
||||
|
||||
// Title
|
||||
let title = Text::new(
|
||||
"Introducing universal agent support: level up any coding agent with Warp",
|
||||
appearance.ui_font_family(),
|
||||
20.,
|
||||
)
|
||||
.with_color(PhenomenonStyle::modal_title_text())
|
||||
.with_style(Properties::default().weight(Weight::Semibold))
|
||||
.finish();
|
||||
|
||||
// Feature list
|
||||
let mut features_col = Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
||||
.with_spacing(12.);
|
||||
|
||||
for item in FEATURE_ITEMS {
|
||||
let icon_el = ConstrainedBox::new(
|
||||
item.icon
|
||||
.to_warpui_icon(Fill::Solid(PhenomenonStyle::modal_feature_title_text()))
|
||||
.finish(),
|
||||
)
|
||||
.with_width(16.)
|
||||
.with_height(16.)
|
||||
.finish();
|
||||
|
||||
let text_col = Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
||||
.with_spacing(2.)
|
||||
.with_child(
|
||||
Text::new_inline(item.title.to_string(), appearance.ui_font_family(), 14.)
|
||||
.with_color(PhenomenonStyle::modal_feature_title_text())
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
Text::new(item.description, appearance.ui_font_family(), 14.)
|
||||
.with_color(PhenomenonStyle::modal_feature_description_text())
|
||||
.finish(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
let row = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
||||
.with_spacing(10.)
|
||||
.with_child(icon_el)
|
||||
.with_child(Expanded::new(1., text_col).finish())
|
||||
.finish();
|
||||
|
||||
features_col.add_child(row);
|
||||
}
|
||||
|
||||
// CTA button
|
||||
let cta = ChildView::new(cta_button).finish();
|
||||
|
||||
// Body content
|
||||
let body = Container::new(
|
||||
Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
||||
.with_child(
|
||||
Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
||||
.with_spacing(8.)
|
||||
.with_child(badge)
|
||||
.with_child(title)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
Container::new(features_col.finish())
|
||||
.with_margin_top(12.)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(Container::new(cta).with_margin_top(32.).finish())
|
||||
.finish(),
|
||||
)
|
||||
.with_horizontal_padding(32.)
|
||||
.with_vertical_padding(32.)
|
||||
.with_background(Fill::Solid(PhenomenonStyle::modal_background()))
|
||||
.with_corner_radius(CornerRadius::with_bottom(Radius::Pixels(8.)))
|
||||
.finish();
|
||||
|
||||
// Full banner
|
||||
ConstrainedBox::new(
|
||||
Container::new(
|
||||
Flex::column()
|
||||
.with_main_axis_size(MainAxisSize::Min)
|
||||
.with_child(hero_stack.finish())
|
||||
.with_child(body)
|
||||
.finish(),
|
||||
)
|
||||
.with_background(Fill::Solid(PhenomenonStyle::modal_background()))
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
|
||||
.finish(),
|
||||
)
|
||||
.with_width(BANNER_WIDTH)
|
||||
.finish()
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
//! Warp Home
|
||||
//!
|
||||
//! This is the landing page for new tabs if session creation isn't supported (e.g. on the web).
|
||||
//! It's barebones at the moment, but may grow into a more full-featured admin experience.
|
||||
|
||||
use warpui::ViewContext;
|
||||
|
||||
use super::view::Workspace;
|
||||
use crate::pane_group::{AnyPaneContent, FilePane};
|
||||
|
||||
const WARP_HOME_TITLE: &str = "Welcome to Warp on Web";
|
||||
const WARP_HOME_CONTENT: &str = r#"
|
||||
Welcome to Warp on Web - your browser-based home for Warp!
|
||||
Use Warp on Web to:
|
||||
* Join Shared Sessions
|
||||
* Create, View, and Edit Warp Drive Objects
|
||||
* Manage your Warp Settings
|
||||
|
||||
Warp on Web can also be used by your teammates and peers who don't have Warp downloaded yet to view your shared sessions, notebooks, and workflows."#;
|
||||
|
||||
/// Create a static "home page" pane.
|
||||
pub fn create_home_pane(ctx: &mut ViewContext<Workspace>) -> Box<dyn AnyPaneContent> {
|
||||
let pane = FilePane::new(
|
||||
None,
|
||||
None,
|
||||
#[cfg(feature = "local_fs")]
|
||||
None,
|
||||
ctx,
|
||||
);
|
||||
pane.file_view(ctx).update(ctx, |pane, ctx| {
|
||||
pane.open_static(WARP_HOME_TITLE, WARP_HOME_CONTENT, ctx);
|
||||
});
|
||||
Box::new(pane)
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use pathfinder_geometry::vector::Vector2F;
|
||||
use ui_components::{lightbox, Component as _};
|
||||
use warpui::assets::asset_cache::{AssetCache, AssetSource, AssetState};
|
||||
use warpui::image_cache::ImageType;
|
||||
use warpui::keymap::{FixedBinding, Keystroke};
|
||||
use warpui::prelude::*;
|
||||
use warpui::{AppContext, BlurContext, Element, Entity, SingletonEntity, View, ViewContext};
|
||||
|
||||
use crate::appearance::Appearance;
|
||||
|
||||
pub use lightbox::LightboxImage;
|
||||
|
||||
pub fn init(app: &mut AppContext) {
|
||||
use warpui::keymap::macros::*;
|
||||
let view_id = id!(LightboxView::ui_name());
|
||||
app.register_fixed_bindings([
|
||||
FixedBinding::new("escape", LightboxViewAction::Dismiss, view_id.clone()),
|
||||
FixedBinding::new(
|
||||
"left",
|
||||
LightboxViewAction::NavigatePrevious,
|
||||
view_id.clone(),
|
||||
),
|
||||
FixedBinding::new("right", LightboxViewAction::NavigateNext, view_id),
|
||||
]);
|
||||
}
|
||||
|
||||
/// Parameters needed to open a lightbox.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct LightboxParams {
|
||||
/// The images to display in the lightbox.
|
||||
pub images: Vec<LightboxImage>,
|
||||
/// The index of the image to display initially.
|
||||
pub initial_index: usize,
|
||||
}
|
||||
|
||||
/// Events emitted by the `LightboxView` to its parent.
|
||||
pub enum LightboxViewEvent {
|
||||
/// The user explicitly dismissed the lightbox (Escape, close button, or scrim click).
|
||||
Close,
|
||||
/// Focus left the lightbox subtree (e.g. the user switched tabs).
|
||||
FocusLost,
|
||||
}
|
||||
|
||||
impl Entity for LightboxView {
|
||||
type Event = LightboxViewEvent;
|
||||
}
|
||||
|
||||
/// Actions dispatched within the `LightboxView`.
|
||||
#[derive(Debug)]
|
||||
pub enum LightboxViewAction {
|
||||
/// Dismiss the lightbox (triggered by clicking outside, close button, or Escape).
|
||||
Dismiss,
|
||||
/// Navigate to the previous image.
|
||||
NavigatePrevious,
|
||||
/// Navigate to the next image.
|
||||
NavigateNext,
|
||||
}
|
||||
|
||||
/// A view that renders a full-window lightbox overlay.
|
||||
pub struct LightboxView {
|
||||
params: LightboxParams,
|
||||
current_index: usize,
|
||||
lightbox: lightbox::Lightbox,
|
||||
}
|
||||
|
||||
impl LightboxView {
|
||||
pub fn new(params: LightboxParams, ctx: &mut ViewContext<Self>) -> Self {
|
||||
let initial_index = params
|
||||
.initial_index
|
||||
.min(params.images.len().saturating_sub(1));
|
||||
let view = Self {
|
||||
params,
|
||||
current_index: initial_index,
|
||||
lightbox: lightbox::Lightbox::default(),
|
||||
};
|
||||
view.start_asset_loads(ctx);
|
||||
view
|
||||
}
|
||||
|
||||
/// Replace the images and navigate to the given initial index.
|
||||
pub fn update_params(&mut self, params: LightboxParams, ctx: &mut ViewContext<Self>) {
|
||||
let initial_index = params
|
||||
.initial_index
|
||||
.min(params.images.len().saturating_sub(1));
|
||||
self.params = params;
|
||||
self.current_index = initial_index;
|
||||
self.start_asset_loads(ctx);
|
||||
}
|
||||
|
||||
/// Update a single image at the given index without replacing the full list.
|
||||
pub fn update_image_at(
|
||||
&mut self,
|
||||
index: usize,
|
||||
image: LightboxImage,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
if let Some(slot) = self.params.images.get_mut(index) {
|
||||
if let lightbox::LightboxImageSource::Resolved { ref asset_source } = image.source {
|
||||
Self::start_asset_load(asset_source, ctx);
|
||||
}
|
||||
*slot = image;
|
||||
}
|
||||
}
|
||||
|
||||
/// Kick off asset loads for all `Resolved` images and schedule re-renders.
|
||||
fn start_asset_loads(&self, ctx: &mut ViewContext<Self>) {
|
||||
for img in &self.params.images {
|
||||
if let lightbox::LightboxImageSource::Resolved { ref asset_source } = img.source {
|
||||
Self::start_asset_load(asset_source, ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Eagerly load a single asset and schedule a `ctx.notify()` when the fetch
|
||||
/// completes so the lightbox re-renders with the loaded image.
|
||||
fn start_asset_load(asset_source: &AssetSource, ctx: &mut ViewContext<Self>) {
|
||||
let asset_cache = AssetCache::as_ref(ctx);
|
||||
if let AssetState::Loading { handle } =
|
||||
asset_cache.load_asset::<ImageType>(asset_source.clone())
|
||||
{
|
||||
if let Some(future) = handle.when_loaded(asset_cache) {
|
||||
ctx.spawn(future, |_me, (), ctx| {
|
||||
ctx.notify();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl View for LightboxView {
|
||||
fn ui_name() -> &'static str {
|
||||
"LightboxView"
|
||||
}
|
||||
|
||||
fn on_blur(&mut self, _blur_ctx: &BlurContext, ctx: &mut ViewContext<Self>) {
|
||||
// Only dismiss if focus has left the entire lightbox subtree.
|
||||
if !ctx.is_self_or_child_focused() {
|
||||
ctx.emit(LightboxViewEvent::FocusLost);
|
||||
}
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
|
||||
// Determine the native pixel size of the current image by querying the
|
||||
// asset cache. This will be `Some` once the image bytes have been fully
|
||||
// loaded and decoded.
|
||||
let current_image_native_size =
|
||||
self.params
|
||||
.images
|
||||
.get(self.current_index)
|
||||
.and_then(|img| match &img.source {
|
||||
lightbox::LightboxImageSource::Resolved { asset_source } => {
|
||||
let asset_cache = AssetCache::as_ref(app);
|
||||
match asset_cache.load_asset::<ImageType>(asset_source.clone()) {
|
||||
AssetState::Loaded { data } => data
|
||||
.image_size()
|
||||
.map(|size| Vector2F::new(size.x() as f32, size.y() as f32)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
lightbox::LightboxImageSource::Loading => None,
|
||||
});
|
||||
|
||||
self.lightbox.render(
|
||||
appearance,
|
||||
lightbox::Params {
|
||||
images: &self.params.images,
|
||||
current_index: self.current_index,
|
||||
on_dismiss: Arc::new(|ctx, _| {
|
||||
ctx.dispatch_typed_action(LightboxViewAction::Dismiss);
|
||||
}),
|
||||
current_image_native_size,
|
||||
options: lightbox::Options {
|
||||
dismiss_keystroke: Keystroke::parse("escape").ok(),
|
||||
on_navigate: Some(Arc::new(|direction, ctx, _| match direction {
|
||||
lightbox::NavigationDirection::Previous => {
|
||||
ctx.dispatch_typed_action(LightboxViewAction::NavigatePrevious);
|
||||
}
|
||||
lightbox::NavigationDirection::Next => {
|
||||
ctx.dispatch_typed_action(LightboxViewAction::NavigateNext);
|
||||
}
|
||||
})),
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for LightboxView {
|
||||
type Action = LightboxViewAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
LightboxViewAction::Dismiss => {
|
||||
ctx.emit(LightboxViewEvent::Close);
|
||||
}
|
||||
LightboxViewAction::NavigatePrevious => {
|
||||
if self.current_index > 0 {
|
||||
self.current_index -= 1;
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
LightboxViewAction::NavigateNext => {
|
||||
if self.current_index + 1 < self.params.images.len() {
|
||||
self.current_index += 1;
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,225 @@
|
||||
use crate::appearance::Appearance;
|
||||
use crate::terminal::general_settings::{GeneralSettings, GeneralSettingsChangedEvent};
|
||||
use crate::ui_components::dialog::{dialog_styles, Dialog};
|
||||
use settings::Setting as _;
|
||||
use warp_core::ui::theme::Fill;
|
||||
use warpui::elements::{Align, Container, Empty, Flex, ParentElement};
|
||||
use warpui::keymap::FixedBinding;
|
||||
use warpui::modals::{AlertDialogWithCallbacks, AppModalCallback};
|
||||
use warpui::ui_components::components::{Coords, UiComponent};
|
||||
use warpui::{
|
||||
elements::MouseStateHandle,
|
||||
fonts::Weight,
|
||||
platform::Cursor,
|
||||
ui_components::{button::ButtonVariant, components::UiComponentStyles, text::Span},
|
||||
Element, Entity, TypedActionView, View,
|
||||
};
|
||||
use warpui::{AppContext, ModelHandle, SingletonEntity, ViewContext};
|
||||
|
||||
pub(super) fn init(app: &mut AppContext) {
|
||||
use warpui::keymap::macros::*;
|
||||
|
||||
app.register_fixed_bindings(vec![
|
||||
FixedBinding::new("escape", NativeModalAction::Close, id!("NativeModal")),
|
||||
FixedBinding::new("enter", NativeModalAction::Confirm, id!("NativeModal")),
|
||||
]);
|
||||
}
|
||||
|
||||
/// Used to show a Warp-native modal dialog above a [`super::Workspace`]. The first button is [`ButtonVariant::Accent`].
|
||||
pub struct NativeModal {
|
||||
alert_dialog: Option<AlertDialogWithCallbacks<AppModalCallback>>,
|
||||
dont_show_again: bool,
|
||||
modal_button_mouse_states: Vec<MouseStateHandle>,
|
||||
dont_show_again_mouse_state: MouseStateHandle,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum NativeModalAction {
|
||||
ToggleDontShowAgain,
|
||||
/// Trigger a callback registered in [`NativeModal::alert_dialog`] and reset the modal.
|
||||
TriggerButtonCallback(usize),
|
||||
/// Triggers the last button in the list, as we assume the last button is "cancel".
|
||||
Close,
|
||||
/// Triggers the first button in the list, as we assume the first button is "confirm".
|
||||
Confirm,
|
||||
}
|
||||
|
||||
pub enum NativeModalEvent {
|
||||
Close,
|
||||
}
|
||||
|
||||
impl NativeModal {
|
||||
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
|
||||
let general_settings = GeneralSettings::handle(ctx);
|
||||
let dont_show_again = !*general_settings
|
||||
.as_ref(ctx)
|
||||
.show_warning_before_quitting
|
||||
.value();
|
||||
ctx.subscribe_to_model(&general_settings, Self::handle_general_settings_event);
|
||||
NativeModal {
|
||||
alert_dialog: None,
|
||||
dont_show_again,
|
||||
dont_show_again_mouse_state: Default::default(),
|
||||
modal_button_mouse_states: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_general_settings_event(
|
||||
&mut self,
|
||||
general_settings: ModelHandle<GeneralSettings>,
|
||||
event: &GeneralSettingsChangedEvent,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
if let GeneralSettingsChangedEvent::ShowWarningBeforeQuitting { .. } = event {
|
||||
self.dont_show_again = !general_settings.read(ctx, |settings, _| {
|
||||
*settings.show_warning_before_quitting.value()
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_alert_dialog(&mut self, alert_dialog: AlertDialogWithCallbacks<AppModalCallback>) {
|
||||
self.modal_button_mouse_states.clear();
|
||||
for _ in 0..alert_dialog.button_data.len() {
|
||||
self.modal_button_mouse_states.push(Default::default());
|
||||
}
|
||||
self.alert_dialog = Some(alert_dialog);
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.alert_dialog = None;
|
||||
self.modal_button_mouse_states = Default::default();
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "integration_tests"))]
|
||||
pub(super) fn has_alert_dialog(&self) -> bool {
|
||||
self.alert_dialog.is_some()
|
||||
}
|
||||
|
||||
fn trigger_button_callback(&mut self, idx: usize, ctx: &mut ViewContext<Self>) {
|
||||
// Once we trigger a callback from a button, we are guaranteed that the modal will close so
|
||||
// it's ok to take the alert dialog from Self.
|
||||
if let Some(mut dialog) = self.alert_dialog.take() {
|
||||
let button = dialog.button_data.remove(idx);
|
||||
(button.on_click)(ctx);
|
||||
if self.dont_show_again {
|
||||
(dialog.on_disable)(ctx);
|
||||
}
|
||||
}
|
||||
self.reset();
|
||||
ctx.emit(NativeModalEvent::Close);
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for NativeModal {
|
||||
type Event = NativeModalEvent;
|
||||
}
|
||||
|
||||
impl View for NativeModal {
|
||||
fn ui_name() -> &'static str {
|
||||
"NativeModal"
|
||||
}
|
||||
|
||||
fn render(&self, app: &warpui::AppContext) -> Box<dyn warpui::Element> {
|
||||
let Some(alert_dialog) = self.alert_dialog.as_ref() else {
|
||||
log::warn!("No alert dialog was set for the native modal");
|
||||
return Empty::new().finish();
|
||||
};
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let button_style = UiComponentStyles {
|
||||
font_size: Some(14.),
|
||||
font_weight: Some(Weight::Bold),
|
||||
width: Some(240.),
|
||||
height: Some(40.),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let dont_show_again_checkbox = appearance
|
||||
.ui_builder()
|
||||
.checkbox(self.dont_show_again_mouse_state.clone(), Some(14.))
|
||||
.with_label(Span::new("Don't show again.", Default::default()))
|
||||
.check(self.dont_show_again)
|
||||
.build()
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(|ctx, _, _| ctx.dispatch_typed_action(NativeModalAction::ToggleDontShowAgain))
|
||||
.finish();
|
||||
|
||||
let mut dialog_column_contents = vec![Container::new(dont_show_again_checkbox)
|
||||
.with_padding_bottom(20.)
|
||||
.finish()];
|
||||
|
||||
for (i, modal_button) in alert_dialog.button_data.iter().enumerate() {
|
||||
let button = Align::new(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.button(
|
||||
if i == 0 {
|
||||
ButtonVariant::Accent
|
||||
} else {
|
||||
ButtonVariant::Basic
|
||||
},
|
||||
self.modal_button_mouse_states
|
||||
.get(i)
|
||||
.expect("Modal button mouse state should be set")
|
||||
.clone(),
|
||||
)
|
||||
.with_centered_text_label(modal_button.title.clone())
|
||||
.with_style(button_style)
|
||||
.build()
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(NativeModalAction::TriggerButtonCallback(i))
|
||||
})
|
||||
.finish(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
dialog_column_contents.push(Container::new(button).with_padding_bottom(8.).finish());
|
||||
}
|
||||
|
||||
let dialog_column = Flex::column()
|
||||
.with_children(dialog_column_contents)
|
||||
.finish();
|
||||
let dialog = Dialog::new(
|
||||
alert_dialog.message_text.clone(),
|
||||
Some(alert_dialog.info_text.clone()),
|
||||
UiComponentStyles {
|
||||
width: Some(280.),
|
||||
padding: Some(Coords::uniform(24.)),
|
||||
..dialog_styles(appearance)
|
||||
},
|
||||
)
|
||||
.with_child(dialog_column)
|
||||
.build()
|
||||
.finish();
|
||||
|
||||
// This blurs the background and makes it uninteractable.
|
||||
Container::new(Align::new(dialog).finish())
|
||||
.with_background_color(Fill::blur().into())
|
||||
.with_corner_radius(app.windows().window_corner_radius())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for NativeModal {
|
||||
type Action = NativeModalAction;
|
||||
|
||||
fn handle_action(&mut self, action: &NativeModalAction, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
NativeModalAction::TriggerButtonCallback(idx) => {
|
||||
self.trigger_button_callback(*idx, ctx);
|
||||
}
|
||||
NativeModalAction::ToggleDontShowAgain => {
|
||||
self.dont_show_again = !self.dont_show_again;
|
||||
ctx.notify();
|
||||
}
|
||||
NativeModalAction::Close => {
|
||||
let last_button_idx = self.modal_button_mouse_states.len() - 1;
|
||||
self.trigger_button_callback(last_button_idx, ctx);
|
||||
}
|
||||
NativeModalAction::Confirm => {
|
||||
self.trigger_button_callback(0, ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
use super::hoa_onboarding;
|
||||
use crate::auth::auth_manager::AuthManagerEvent;
|
||||
use crate::auth::AuthManager;
|
||||
use crate::channel::{Channel, ChannelState};
|
||||
use crate::settings::cloud_preferences_syncer::{
|
||||
CloudPreferencesSyncer, CloudPreferencesSyncerEvent,
|
||||
};
|
||||
use crate::settings::{AISettings, CodeSettings};
|
||||
use crate::terminal::general_settings::GeneralSettings;
|
||||
use settings::Setting as _;
|
||||
use warp_core::features::FeatureFlag;
|
||||
use warpui::{Entity, ModelContext, SingletonEntity, WindowId};
|
||||
|
||||
/// A generic model for managing one-time modals that should be shown to users only once.
|
||||
///
|
||||
/// Initially implemented for the ADE launch modal, but designed to be extensible to support
|
||||
/// other types of one-time modals in the future. The model holds the canonical state of whether
|
||||
/// a modal is currently being shown and automatically triggers the modal when appropriate
|
||||
/// conditions are met (e.g., user becomes onboarded).
|
||||
pub struct OneTimeModalModel {
|
||||
is_build_plan_migration_modal_open: bool,
|
||||
/// Whether the Oz launch modal is currently being shown.
|
||||
is_oz_launch_modal_open: bool,
|
||||
/// Whether the OpenWarp launch modal is currently being shown.
|
||||
is_openwarp_launch_modal_open: bool,
|
||||
/// Whether the HOA onboarding flow is currently being shown.
|
||||
is_hoa_onboarding_open: bool,
|
||||
/// The window ID where the currently open one-time modal should be displayed.
|
||||
/// This is captured when a modal is first opened and ensures the modal stays on that window.
|
||||
target_window_id: Option<WindowId>,
|
||||
}
|
||||
|
||||
impl OneTimeModalModel {
|
||||
pub fn new(ctx: &mut ModelContext<Self>) -> Self {
|
||||
// Subscribe to UserWorkspaces to detect when sunsetted_to_build_ts changes
|
||||
ctx.subscribe_to_model(
|
||||
&crate::workspaces::user_workspaces::UserWorkspaces::handle(ctx),
|
||||
|me, event, ctx| {
|
||||
use crate::workspaces::user_workspaces::UserWorkspacesEvent;
|
||||
if let UserWorkspacesEvent::SunsettedToBuildDataUpdated = event {
|
||||
// When sunsetted_to_build_ts is updated, check if we should show the modal
|
||||
me.check_and_trigger_build_plan_migration_modal(ctx);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Subscribe to auth manager events to automatically trigger modal when user becomes onboarded
|
||||
ctx.subscribe_to_model(&AuthManager::handle(ctx), |_, event, ctx| {
|
||||
let AuthManagerEvent::AuthComplete = event else {
|
||||
return;
|
||||
};
|
||||
|
||||
let auth_state = crate::auth::AuthStateProvider::as_ref(ctx).get().clone();
|
||||
let is_existing_user = auth_state.is_onboarded().unwrap_or_default();
|
||||
if is_existing_user {
|
||||
// Settings modals settings are synced to the cloud, not respecting the user's sync setting, so they
|
||||
// must all await initial load to be triggered, else we risk reading a stale triggered value.
|
||||
ctx.subscribe_to_model(
|
||||
&CloudPreferencesSyncer::handle(ctx),
|
||||
move |me, event, ctx| {
|
||||
if let CloudPreferencesSyncerEvent::InitialLoadCompleted = event {
|
||||
ctx.unsubscribe_from_model(&CloudPreferencesSyncer::handle(ctx));
|
||||
me.check_and_trigger_all_modals(ctx);
|
||||
}
|
||||
},
|
||||
);
|
||||
} else {
|
||||
AISettings::handle(ctx).update(ctx, |settings, ctx| {
|
||||
if let Err(e) = settings
|
||||
.did_check_to_trigger_oz_launch_modal
|
||||
.set_value(true, ctx)
|
||||
{
|
||||
log::warn!("Failed to mark Oz launch modal as dismissed: {e}");
|
||||
}
|
||||
});
|
||||
GeneralSettings::handle(ctx).update(ctx, |settings, ctx| {
|
||||
if let Err(e) = settings
|
||||
.did_check_to_trigger_openwarp_launch_modal
|
||||
.set_value(true, ctx)
|
||||
{
|
||||
log::warn!("Failed to mark OpenWarp launch modal as dismissed: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
is_build_plan_migration_modal_open: false,
|
||||
is_oz_launch_modal_open: false,
|
||||
is_openwarp_launch_modal_open: false,
|
||||
is_hoa_onboarding_open: false,
|
||||
target_window_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether the Oz launch modal is currently open.
|
||||
pub fn is_oz_launch_modal_open(&self) -> bool {
|
||||
self.is_oz_launch_modal_open && self.target_window_id.is_some()
|
||||
}
|
||||
|
||||
/// Returns the window ID where the currently open one-time modal should be displayed.
|
||||
pub fn target_window_id(&self) -> Option<WindowId> {
|
||||
self.target_window_id
|
||||
}
|
||||
|
||||
pub fn mark_oz_launch_modal_dismissed(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
self.set_oz_launch_modal_open(false, ctx);
|
||||
}
|
||||
|
||||
/// Returns whether the OpenWarp launch modal is currently open.
|
||||
pub fn is_openwarp_launch_modal_open(&self) -> bool {
|
||||
self.is_openwarp_launch_modal_open && self.target_window_id.is_some()
|
||||
}
|
||||
|
||||
pub fn mark_openwarp_launch_modal_dismissed(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
self.set_openwarp_launch_modal_open(false, ctx);
|
||||
}
|
||||
|
||||
/// Returns whether the HOA onboarding flow is currently open.
|
||||
pub fn is_hoa_onboarding_open(&self) -> bool {
|
||||
self.is_hoa_onboarding_open && self.target_window_id.is_some()
|
||||
}
|
||||
|
||||
pub fn mark_hoa_onboarding_dismissed(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
self.set_hoa_onboarding_open(false, ctx);
|
||||
}
|
||||
|
||||
/// Returns true if any one-time modal is currently open.
|
||||
pub fn is_any_modal_open(&self) -> bool {
|
||||
(self.is_oz_launch_modal_open
|
||||
|| self.is_openwarp_launch_modal_open
|
||||
|| self.is_build_plan_migration_modal_open
|
||||
|| self.is_hoa_onboarding_open)
|
||||
&& self.target_window_id.is_some()
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
pub fn force_open_oz_launch_modal(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
self.set_oz_launch_modal_open(true, ctx);
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
pub fn force_open_openwarp_launch_modal(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
self.set_openwarp_launch_modal_open(true, ctx);
|
||||
}
|
||||
|
||||
pub fn update_target_window_id(&mut self, window_id: WindowId, ctx: &mut ModelContext<Self>) {
|
||||
let was_any_modal_visible = self.is_any_modal_open();
|
||||
self.target_window_id = Some(window_id);
|
||||
if was_any_modal_visible != self.is_any_modal_open() {
|
||||
ctx.emit(OneTimeModalEvent::VisibilityChanged {
|
||||
is_open: self.is_any_modal_open(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn set_oz_launch_modal_open(&mut self, is_open: bool, ctx: &mut ModelContext<Self>) -> bool {
|
||||
if self.is_oz_launch_modal_open != is_open {
|
||||
self.is_oz_launch_modal_open = is_open;
|
||||
ctx.emit(OneTimeModalEvent::VisibilityChanged { is_open });
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn set_openwarp_launch_modal_open(
|
||||
&mut self,
|
||||
is_open: bool,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> bool {
|
||||
if self.is_openwarp_launch_modal_open != is_open {
|
||||
self.is_openwarp_launch_modal_open = is_open;
|
||||
ctx.emit(OneTimeModalEvent::VisibilityChanged { is_open });
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn check_and_trigger_all_modals(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
// Never show one-time modals on WASM.
|
||||
if cfg!(target_family = "wasm") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Existing users should never see the code toolbelt new feature popup.
|
||||
CodeSettings::handle(ctx).update(ctx, |settings, ctx| {
|
||||
if let Err(e) = settings
|
||||
.dismissed_code_toolbelt_new_feature_popup
|
||||
.set_value(true, ctx)
|
||||
{
|
||||
log::warn!("Failed to mark code toolbelt new feature popup as dismissed: {e}");
|
||||
}
|
||||
});
|
||||
|
||||
// The OpenWarp launch modal takes priority over the Oz launch modal
|
||||
// when both are enabled.
|
||||
if self.check_and_trigger_openwarp_launch_modal(ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
if self.check_and_trigger_oz_launch_modal(ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
if self.check_and_trigger_hoa_onboarding(ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
self.check_and_trigger_build_plan_migration_modal(ctx);
|
||||
}
|
||||
|
||||
fn set_hoa_onboarding_open(&mut self, is_open: bool, ctx: &mut ModelContext<Self>) -> bool {
|
||||
if self.is_hoa_onboarding_open != is_open {
|
||||
self.is_hoa_onboarding_open = is_open;
|
||||
ctx.emit(OneTimeModalEvent::VisibilityChanged { is_open });
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn check_and_trigger_hoa_onboarding(&mut self, ctx: &mut ModelContext<Self>) -> bool {
|
||||
if !FeatureFlag::HOAOnboardingFlow.is_enabled() {
|
||||
return false;
|
||||
}
|
||||
|
||||
if hoa_onboarding::has_completed_hoa_onboarding(ctx) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// All required dependent feature flags must be enabled.
|
||||
if !FeatureFlag::VerticalTabs.is_enabled()
|
||||
|| !FeatureFlag::HOANotifications.is_enabled()
|
||||
|| !FeatureFlag::TabConfigs.is_enabled()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
self.set_hoa_onboarding_open(true, ctx)
|
||||
}
|
||||
|
||||
fn check_and_trigger_oz_launch_modal(&mut self, ctx: &mut ModelContext<Self>) -> bool {
|
||||
// Only show if the feature flag is enabled.
|
||||
if !FeatureFlag::OzLaunchModal.is_enabled() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let ai_settings = AISettings::as_ref(ctx);
|
||||
let oz_modal_shown = *ai_settings.did_check_to_trigger_oz_launch_modal;
|
||||
|
||||
// If Oz modal has already been shown, don't show anything.
|
||||
if oz_modal_shown {
|
||||
return false;
|
||||
}
|
||||
|
||||
AISettings::handle(ctx).update(ctx, |settings, ctx| {
|
||||
if let Err(e) = settings
|
||||
.did_check_to_trigger_oz_launch_modal
|
||||
.set_value(true, ctx)
|
||||
{
|
||||
log::warn!("Failed to mark Oz launch modal as dismissed: {e}");
|
||||
}
|
||||
});
|
||||
|
||||
let should_show_oz_modal = !matches!(ChannelState::channel(), Channel::Integration);
|
||||
self.set_oz_launch_modal_open(should_show_oz_modal, ctx);
|
||||
should_show_oz_modal
|
||||
}
|
||||
|
||||
fn check_and_trigger_openwarp_launch_modal(&mut self, ctx: &mut ModelContext<Self>) -> bool {
|
||||
// Only show if the feature flag is enabled.
|
||||
if !FeatureFlag::OpenWarpLaunchModal.is_enabled() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let general_settings = GeneralSettings::as_ref(ctx);
|
||||
let openwarp_modal_shown = *general_settings
|
||||
.did_check_to_trigger_openwarp_launch_modal
|
||||
.value();
|
||||
|
||||
if openwarp_modal_shown {
|
||||
return false;
|
||||
}
|
||||
|
||||
GeneralSettings::handle(ctx).update(ctx, |settings, ctx| {
|
||||
if let Err(e) = settings
|
||||
.did_check_to_trigger_openwarp_launch_modal
|
||||
.set_value(true, ctx)
|
||||
{
|
||||
log::warn!("Failed to mark OpenWarp launch modal as dismissed: {e}");
|
||||
}
|
||||
});
|
||||
|
||||
let should_show_openwarp_modal = !matches!(ChannelState::channel(), Channel::Integration);
|
||||
self.set_openwarp_launch_modal_open(should_show_openwarp_modal, ctx);
|
||||
should_show_openwarp_modal
|
||||
}
|
||||
|
||||
pub fn is_build_plan_migration_modal_open(&self) -> bool {
|
||||
self.is_build_plan_migration_modal_open && self.target_window_id.is_some()
|
||||
}
|
||||
|
||||
pub fn mark_build_plan_migration_modal_dismissed(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
self.set_build_plan_migration_modal_open(false, ctx);
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
pub fn force_open_build_plan_migration_modal(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
self.set_build_plan_migration_modal_open(true, ctx);
|
||||
}
|
||||
|
||||
fn set_build_plan_migration_modal_open(
|
||||
&mut self,
|
||||
is_open: bool,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> bool {
|
||||
if self.is_build_plan_migration_modal_open != is_open {
|
||||
self.is_build_plan_migration_modal_open = is_open;
|
||||
ctx.emit(OneTimeModalEvent::VisibilityChanged { is_open });
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn check_and_trigger_build_plan_migration_modal(
|
||||
&mut self,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> bool {
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
|
||||
// Check if already dismissed
|
||||
let general_settings = GeneralSettings::as_ref(ctx);
|
||||
if *general_settings
|
||||
.build_plan_migration_modal_dismissed
|
||||
.value()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if user is authenticated
|
||||
let auth_state = crate::auth::AuthStateProvider::as_ref(ctx).get();
|
||||
|
||||
if auth_state.is_anonymous_or_logged_out() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if current workspace has sunsetted_to_build_ts set
|
||||
let user_workspaces = UserWorkspaces::as_ref(ctx);
|
||||
let Some(current_team) = user_workspaces.current_team() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
// Check if user is admin of the team
|
||||
let Some(user_email) = auth_state.user_email() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if !current_team.has_admin_permissions(&user_email) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if service agreement has sunsetted_to_build_ts set
|
||||
let has_sunsetted_to_build = current_team
|
||||
.billing_metadata
|
||||
.service_agreements
|
||||
.first()
|
||||
.is_some_and(|sa| sa.sunsetted_to_build_ts.is_some());
|
||||
|
||||
if !has_sunsetted_to_build {
|
||||
return false;
|
||||
}
|
||||
|
||||
// All conditions met, show the modal
|
||||
self.set_build_plan_migration_modal_open(true, ctx)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum OneTimeModalEvent {
|
||||
VisibilityChanged { is_open: bool },
|
||||
}
|
||||
|
||||
impl Entity for OneTimeModalModel {
|
||||
type Event = OneTimeModalEvent;
|
||||
}
|
||||
|
||||
impl SingletonEntity for OneTimeModalModel {}
|
||||
@@ -0,0 +1,66 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use warpui::{AppContext, Entity, SingletonEntity, WeakViewHandle, WindowId};
|
||||
|
||||
use super::Workspace;
|
||||
|
||||
/// A registry that tracks all workspace views by their window ID.
|
||||
///
|
||||
/// This provides O(1) lookup of workspaces instead of the O(n) linear scan
|
||||
/// that `views_of_type::<Workspace>` performs.
|
||||
pub struct WorkspaceRegistry {
|
||||
workspaces: HashMap<WindowId, WeakViewHandle<Workspace>>,
|
||||
}
|
||||
|
||||
impl Default for WorkspaceRegistry {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl WorkspaceRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
workspaces: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Registers a workspace for the given window.
|
||||
pub fn register(&mut self, window_id: WindowId, workspace: WeakViewHandle<Workspace>) {
|
||||
self.workspaces.insert(window_id, workspace);
|
||||
}
|
||||
|
||||
/// Unregisters the workspace for the given window.
|
||||
pub fn unregister(&mut self, window_id: WindowId) {
|
||||
self.workspaces.remove(&window_id);
|
||||
}
|
||||
|
||||
/// Returns the workspace for the given window, if it is still alive.
|
||||
pub fn get(
|
||||
&self,
|
||||
window_id: WindowId,
|
||||
app: &AppContext,
|
||||
) -> Option<warpui::ViewHandle<Workspace>> {
|
||||
self.workspaces.get(&window_id)?.upgrade(app)
|
||||
}
|
||||
|
||||
/// Returns all registered workspaces that are still alive.
|
||||
/// The returned vector contains tuples of (WindowId, ViewHandle<Workspace>).
|
||||
pub fn all_workspaces(
|
||||
&self,
|
||||
app: &AppContext,
|
||||
) -> Vec<(WindowId, warpui::ViewHandle<Workspace>)> {
|
||||
self.workspaces
|
||||
.iter()
|
||||
.filter_map(|(window_id, weak_handle)| {
|
||||
weak_handle.upgrade(app).map(|handle| (*window_id, handle))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for WorkspaceRegistry {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl SingletonEntity for WorkspaceRegistry {}
|
||||
@@ -0,0 +1,270 @@
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use warp_core::ui::{color::coloru_with_opacity, theme::Fill};
|
||||
use warpui::{
|
||||
elements::{
|
||||
Align, ChildAnchor, ConstrainedBox, Container, CrossAxisAlignment, Flex, Hoverable,
|
||||
MouseStateHandle, OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds,
|
||||
Stack, Text,
|
||||
},
|
||||
fonts::Weight,
|
||||
keymap::{FixedBinding, Keystroke},
|
||||
platform::Cursor,
|
||||
ui_components::{
|
||||
button::ButtonVariant,
|
||||
components::{Coords, UiComponent, UiComponentStyles},
|
||||
},
|
||||
AppContext, Element, Entity, EntityId, SingletonEntity, TypedActionView, View, ViewContext,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
ai::agent::{conversation::AIConversationId, AIAgentExchangeId},
|
||||
appearance::Appearance,
|
||||
ui_components::dialog::{dialog_styles, Dialog},
|
||||
ui_components::icons::Icon,
|
||||
};
|
||||
|
||||
pub fn init(app: &mut AppContext) {
|
||||
use warpui::keymap::macros::*;
|
||||
|
||||
app.register_fixed_bindings([
|
||||
FixedBinding::new(
|
||||
"escape",
|
||||
RewindConfirmationAction::Cancel,
|
||||
id!(RewindConfirmationDialog::ui_name()),
|
||||
),
|
||||
FixedBinding::new(
|
||||
"enter",
|
||||
RewindConfirmationAction::Confirm,
|
||||
id!(RewindConfirmationDialog::ui_name()),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
const DIALOG_WIDTH: f32 = 460.;
|
||||
|
||||
/// Data needed to perform the rewind action after confirmation
|
||||
#[derive(Clone)]
|
||||
pub struct RewindDialogSource {
|
||||
pub ai_block_view_id: EntityId,
|
||||
pub exchange_id: AIAgentExchangeId,
|
||||
pub conversation_id: AIConversationId,
|
||||
}
|
||||
|
||||
pub struct RewindConfirmationDialog {
|
||||
cancel_mouse_state: MouseStateHandle,
|
||||
confirm_mouse_state: MouseStateHandle,
|
||||
/// Source will be None if dialog was never opened
|
||||
rewind_source: Option<RewindDialogSource>,
|
||||
}
|
||||
|
||||
impl Default for RewindConfirmationDialog {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl RewindConfirmationDialog {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
cancel_mouse_state: Default::default(),
|
||||
confirm_mouse_state: Default::default(),
|
||||
rewind_source: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_rewind_source(&mut self, source: RewindDialogSource) {
|
||||
self.rewind_source = Some(source);
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for RewindConfirmationDialog {
|
||||
type Event = RewindConfirmationEvent;
|
||||
}
|
||||
|
||||
impl View for RewindConfirmationDialog {
|
||||
fn ui_name() -> &'static str {
|
||||
"RewindConfirmationDialog"
|
||||
}
|
||||
|
||||
fn on_focus(&mut self, _focus_ctx: &warpui::FocusContext, ctx: &mut ViewContext<Self>) {
|
||||
ctx.focus_self();
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
|
||||
let button_style = UiComponentStyles {
|
||||
font_size: Some(14.),
|
||||
font_weight: Some(Weight::Bold),
|
||||
height: Some(40.),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Build rewind button label with Enter keyboard shortcut indicator
|
||||
let enter_keystroke = Keystroke::parse("enter").expect("Valid keystroke");
|
||||
let text_color = theme.main_text_color(theme.accent()).into_solid();
|
||||
let rewind_button_label = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(
|
||||
Text::new_inline("Rewind", appearance.ui_font_family(), 14.)
|
||||
.with_color(text_color)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
Container::new(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.keyboard_shortcut(&enter_keystroke)
|
||||
.with_style(UiComponentStyles {
|
||||
font_size: Some(10.),
|
||||
height: Some(16.),
|
||||
padding: Some(Coords::uniform(1.)),
|
||||
border_width: Some(1.),
|
||||
border_color: Some(coloru_with_opacity(text_color, 60).into()),
|
||||
font_color: Some(text_color),
|
||||
font_family_id: Some(appearance.ui_font_family()),
|
||||
..Default::default()
|
||||
})
|
||||
.with_line_height_ratio(1.0)
|
||||
.build()
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_left(8.)
|
||||
.finish(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
let rewind_button = appearance
|
||||
.ui_builder()
|
||||
.button(ButtonVariant::Accent, self.confirm_mouse_state.clone())
|
||||
.with_custom_label(rewind_button_label)
|
||||
.with_style(UiComponentStyles {
|
||||
padding: Some(Coords {
|
||||
left: 16.,
|
||||
right: 16.,
|
||||
top: 0.,
|
||||
bottom: 0.,
|
||||
}),
|
||||
..button_style
|
||||
})
|
||||
.build()
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(move |ctx, _, _| ctx.dispatch_typed_action(RewindConfirmationAction::Confirm))
|
||||
.finish();
|
||||
|
||||
let cancel_text_color = theme.sub_text_color(theme.surface_2());
|
||||
let cancel_button = Container::new(
|
||||
Hoverable::new(self.cancel_mouse_state.clone(), move |mouse_state| {
|
||||
let color = if mouse_state.is_mouse_over_element() {
|
||||
theme.main_text_color(theme.surface_2())
|
||||
} else {
|
||||
cancel_text_color
|
||||
};
|
||||
Text::new_inline("Cancel", appearance.ui_font_family(), 14.)
|
||||
.with_color(color.into_solid())
|
||||
.finish()
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(move |ctx, _, _| ctx.dispatch_typed_action(RewindConfirmationAction::Cancel))
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_right(16.)
|
||||
.finish();
|
||||
|
||||
// Info text with icon
|
||||
let info_color = theme.sub_text_color(theme.surface_2());
|
||||
let info_row = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(
|
||||
Container::new(
|
||||
ConstrainedBox::new(Icon::Info.to_warpui_icon(info_color).finish())
|
||||
.with_height(14.)
|
||||
.with_width(14.)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_right(6.)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
Text::new_inline(
|
||||
"Rewinding does not affect files edited manually or via shell commands.",
|
||||
appearance.ui_font_family(),
|
||||
12.,
|
||||
)
|
||||
.with_color(info_color.into_solid())
|
||||
.finish(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
let dialog = Container::new(
|
||||
Dialog::new(
|
||||
"Rewind".into(),
|
||||
Some(
|
||||
"Are you sure you want to rewind? This will restore your code and conversation to before this point, and cancel any commands the agent is currently running. A copy of the original conversation will be saved in your conversation history."
|
||||
.into(),
|
||||
),
|
||||
UiComponentStyles {
|
||||
width: Some(DIALOG_WIDTH),
|
||||
padding: Some(Coords::uniform(24.)),
|
||||
..dialog_styles(appearance)
|
||||
},
|
||||
)
|
||||
.with_child(info_row)
|
||||
.with_bottom_row_child(cancel_button)
|
||||
.with_bottom_row_child(rewind_button)
|
||||
.build()
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_top(35.)
|
||||
.finish();
|
||||
|
||||
// Stack needed so that dialog can get bounds information
|
||||
let mut stack = Stack::new();
|
||||
stack.add_positioned_child(
|
||||
dialog,
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(0., 0.),
|
||||
ParentOffsetBounds::WindowByPosition,
|
||||
ParentAnchor::Center,
|
||||
ChildAnchor::Center,
|
||||
),
|
||||
);
|
||||
|
||||
// This blurs the background and makes it uninteractable
|
||||
Container::new(Align::new(stack.finish()).finish())
|
||||
.with_background_color(Fill::blur().into())
|
||||
.with_corner_radius(app.windows().window_corner_radius())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
pub enum RewindConfirmationEvent {
|
||||
Confirm { rewind_source: RewindDialogSource },
|
||||
Cancel,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum RewindConfirmationAction {
|
||||
Confirm,
|
||||
Cancel,
|
||||
}
|
||||
|
||||
impl TypedActionView for RewindConfirmationDialog {
|
||||
type Action = RewindConfirmationAction;
|
||||
|
||||
fn handle_action(&mut self, action: &RewindConfirmationAction, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
RewindConfirmationAction::Confirm => {
|
||||
let Some(rewind_source) = self.rewind_source.clone() else {
|
||||
log::error!("Rewind confirm button pressed with no rewind source");
|
||||
return;
|
||||
};
|
||||
ctx.emit(RewindConfirmationEvent::Confirm { rewind_source });
|
||||
}
|
||||
RewindConfirmationAction::Cancel => {
|
||||
ctx.emit(RewindConfirmationEvent::Cancel);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use warpui::{keymap::EditableBinding, AppContext, Entity, EntityId, SingletonEntity, WindowId};
|
||||
|
||||
use crate::util::bindings::{BindingGroup, CustomAction};
|
||||
|
||||
use super::WorkspaceAction;
|
||||
|
||||
pub fn init(app: &mut AppContext) {
|
||||
use warpui::keymap::macros::*;
|
||||
|
||||
app.register_editable_bindings(vec![
|
||||
EditableBinding::new(
|
||||
"workspace:disable_terminal_input_syncing",
|
||||
"Stop Synchronizing Any Panes",
|
||||
WorkspaceAction::DisableTerminalInputSync,
|
||||
)
|
||||
.with_context_predicate(id!("Workspace"))
|
||||
.with_key_binding("alt-cmd-shift-I")
|
||||
.with_group(BindingGroup::Settings.as_str())
|
||||
.with_custom_action(CustomAction::DisableSyncTerminalInputs),
|
||||
EditableBinding::new(
|
||||
"workspace:toggle_sync_terminal_inputs_in_tab",
|
||||
"Toggle Synchronizing All Panes in Current Tab",
|
||||
WorkspaceAction::ToggleSyncTerminalInputsInTab,
|
||||
)
|
||||
.with_context_predicate(id!("Workspace"))
|
||||
.with_group(BindingGroup::Settings.as_str())
|
||||
.with_custom_action(CustomAction::ToggleSyncTerminalInputsInCurrentTab),
|
||||
EditableBinding::new(
|
||||
"workspace:toggle_sync_all_terminal_inputs_in_all_tabs",
|
||||
"Toggle Synchronizing All Panes in All Tabs",
|
||||
WorkspaceAction::ToggleSyncAllTerminalInputsInAllTabs,
|
||||
)
|
||||
.with_context_predicate(id!("Workspace"))
|
||||
.with_group(BindingGroup::Settings.as_str())
|
||||
.with_custom_action(CustomAction::ToggleSyncAllTerminalInputsInAllTabs),
|
||||
]);
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
enum SyncedPanes {
|
||||
All,
|
||||
AllPanesInPaneGroups { pane_group_ids: HashSet<EntityId> },
|
||||
}
|
||||
|
||||
/// Stores state for syncing inputs across terminals.
|
||||
/// Note: we sync input editors with themselves and
|
||||
/// alt-screen/long-running commands with themselves
|
||||
pub struct SyncedInputState {
|
||||
sync_state_by_window: HashMap<WindowId, Option<SyncedPanes>>,
|
||||
}
|
||||
|
||||
impl Entity for SyncedInputState {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl SingletonEntity for SyncedInputState {}
|
||||
|
||||
impl Default for SyncedInputState {
|
||||
fn default() -> Self {
|
||||
SyncedInputState::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl SyncedInputState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
sync_state_by_window: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mock() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
|
||||
pub fn toggle_sync_all_terminal_inputs_in_all_tabs(&mut self, window_id: WindowId) {
|
||||
let new_sync_state = match self.sync_state_by_window.get(&window_id).unwrap_or(&None) {
|
||||
Some(SyncedPanes::All) => None,
|
||||
_ => Some(SyncedPanes::All),
|
||||
};
|
||||
|
||||
self.sync_state_by_window.insert(window_id, new_sync_state);
|
||||
}
|
||||
|
||||
pub fn toggle_sync_terminal_inputs_in_tab(
|
||||
&mut self,
|
||||
tab_id: EntityId,
|
||||
all_tab_ids: impl Iterator<Item = EntityId>,
|
||||
pane_group_count: usize,
|
||||
window_id: WindowId,
|
||||
) {
|
||||
let new_state = match self.sync_state_by_window.get(&window_id).unwrap_or(&None) {
|
||||
None => {
|
||||
let mut synced_tabs = HashSet::new();
|
||||
synced_tabs.insert(tab_id);
|
||||
|
||||
Some(SyncedPanes::AllPanesInPaneGroups {
|
||||
pane_group_ids: synced_tabs,
|
||||
})
|
||||
}
|
||||
Some(SyncedPanes::All) => {
|
||||
let mut synced_tabs = HashSet::from_iter(all_tab_ids);
|
||||
synced_tabs.remove(&tab_id);
|
||||
|
||||
Self::normalized_synced_panes(synced_tabs, pane_group_count)
|
||||
}
|
||||
Some(SyncedPanes::AllPanesInPaneGroups {
|
||||
pane_group_ids: tab_ids,
|
||||
}) => {
|
||||
let mut synced_tabs = tab_ids.clone();
|
||||
if synced_tabs.contains(&tab_id) {
|
||||
// Tab is already synced so toggle should un-sync it.
|
||||
synced_tabs.remove(&tab_id);
|
||||
} else {
|
||||
// Tab wasn't already synced so toggle should sync it.
|
||||
synced_tabs.insert(tab_id);
|
||||
}
|
||||
|
||||
Self::normalized_synced_panes(synced_tabs, pane_group_count)
|
||||
}
|
||||
};
|
||||
|
||||
self.sync_state_by_window.insert(window_id, new_state);
|
||||
}
|
||||
|
||||
/// Given a set of `synced_pane_group_ids` and the total count of pane groups in a window, return the normalized SyncedPane variant to reduce ambiguity. For example, if `synced_pane_group_ids` is an empty HashSet, the normalized representation should be None.
|
||||
fn normalized_synced_panes(
|
||||
synced_pane_group_ids: HashSet<EntityId>,
|
||||
pane_group_count: usize,
|
||||
) -> Option<SyncedPanes> {
|
||||
match synced_pane_group_ids.len() {
|
||||
0 => None,
|
||||
i if i == pane_group_count => Some(SyncedPanes::All),
|
||||
_ => Some(SyncedPanes::AllPanesInPaneGroups {
|
||||
pane_group_ids: synced_pane_group_ids,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn disable_sync_terminal_inputs(&mut self, window_id: WindowId) {
|
||||
self.sync_state_by_window.insert(window_id, None);
|
||||
}
|
||||
|
||||
fn get_state(&self, window_id: WindowId) -> Option<&SyncedPanes> {
|
||||
self.sync_state_by_window
|
||||
.get(&window_id)
|
||||
.and_then(|state| state.as_ref())
|
||||
}
|
||||
|
||||
pub fn is_syncing_any_inputs(&self, window_id: WindowId) -> bool {
|
||||
self.get_state(window_id).is_some()
|
||||
}
|
||||
|
||||
pub fn is_syncing_all_inputs(&self, window_id: WindowId) -> bool {
|
||||
matches!(self.get_state(window_id), Some(SyncedPanes::All))
|
||||
}
|
||||
|
||||
/// Returns true if sync mode is all panes in a set of pane group ids and
|
||||
/// the specified pane group id is in that set.
|
||||
/// Returns false otherwise -- notably, even when all panes are synced.
|
||||
/// Useful when we need to know the exact sync state, not just sync.
|
||||
pub fn is_syncing_all_panes_in_pane_group(
|
||||
&self,
|
||||
window_id: WindowId,
|
||||
pane_group_id: EntityId,
|
||||
) -> bool {
|
||||
match self.sync_state_by_window.get(&window_id).unwrap_or(&None) {
|
||||
Some(SyncedPanes::AllPanesInPaneGroups {
|
||||
pane_group_ids: tab_ids,
|
||||
}) => tab_ids.contains(&pane_group_id),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
/// Returns true if we're in any state that should sync this pane group.
|
||||
pub fn should_sync_this_pane_group(
|
||||
&self,
|
||||
pane_group_id: EntityId,
|
||||
window_id: WindowId,
|
||||
) -> bool {
|
||||
match self.sync_state_by_window.get(&window_id).unwrap_or(&None) {
|
||||
Some(SyncedPanes::All) => true,
|
||||
Some(SyncedPanes::AllPanesInPaneGroups {
|
||||
pane_group_ids: tab_ids,
|
||||
}) => tab_ids.contains(&pane_group_id),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use settings::{
|
||||
macros::define_settings_group, RespectUserSyncSetting, SupportedPlatforms, SyncToCloud,
|
||||
};
|
||||
use warp_core::ui::theme::AnsiColorIdentifier;
|
||||
|
||||
#[derive(
|
||||
Default,
|
||||
Debug,
|
||||
serde::Serialize,
|
||||
serde::Deserialize,
|
||||
PartialEq,
|
||||
Copy,
|
||||
Clone,
|
||||
schemars::JsonSchema,
|
||||
settings_value::SettingsValue,
|
||||
)]
|
||||
#[schemars(
|
||||
description = "Where new tabs are placed in the tab bar.",
|
||||
rename_all = "snake_case"
|
||||
)]
|
||||
pub enum NewTabPlacement {
|
||||
#[default]
|
||||
AfterCurrentTab,
|
||||
AfterAllTabs,
|
||||
}
|
||||
|
||||
settings::macros::implement_setting_for_enum!(
|
||||
NewTabPlacement,
|
||||
TabSettings,
|
||||
SupportedPlatforms::ALL,
|
||||
SyncToCloud::Never,
|
||||
private: false,
|
||||
toml_path: "general.new_tab_placement",
|
||||
description: "Where new tabs are placed in the tab bar.",
|
||||
);
|
||||
|
||||
#[derive(
|
||||
Default,
|
||||
Debug,
|
||||
serde::Serialize,
|
||||
serde::Deserialize,
|
||||
PartialEq,
|
||||
Copy,
|
||||
Clone,
|
||||
schemars::JsonSchema,
|
||||
settings_value::SettingsValue,
|
||||
)]
|
||||
#[schemars(
|
||||
description = "Position of the close button on tabs.",
|
||||
rename_all = "snake_case"
|
||||
)]
|
||||
pub enum TabCloseButtonPosition {
|
||||
#[default]
|
||||
Right,
|
||||
Left,
|
||||
}
|
||||
|
||||
settings::macros::implement_setting_for_enum!(
|
||||
TabCloseButtonPosition,
|
||||
TabSettings,
|
||||
SupportedPlatforms::ALL,
|
||||
SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "appearance.tabs.tab_close_button_position",
|
||||
description: "Position of the close button on tabs.",
|
||||
);
|
||||
|
||||
/// Visibility options for workspace decorations like the tab bar.
|
||||
#[derive(
|
||||
Clone,
|
||||
Copy,
|
||||
Debug,
|
||||
Default,
|
||||
Eq,
|
||||
PartialEq,
|
||||
serde::Serialize,
|
||||
serde::Deserialize,
|
||||
schemars::JsonSchema,
|
||||
settings_value::SettingsValue,
|
||||
)]
|
||||
#[schemars(
|
||||
description = "When workspace decorations such as the tab bar are visible.",
|
||||
rename_all = "snake_case"
|
||||
)]
|
||||
pub enum WorkspaceDecorationVisibility {
|
||||
/// Always show workspace decorations.
|
||||
AlwaysShow,
|
||||
/// Hide workspace decorations if fullscreen.
|
||||
#[default]
|
||||
HideFullscreen,
|
||||
/// Only show workspace decorations on hover.
|
||||
OnHover,
|
||||
}
|
||||
|
||||
settings::macros::implement_setting_for_enum!(
|
||||
WorkspaceDecorationVisibility,
|
||||
TabSettings,
|
||||
SupportedPlatforms::ALL,
|
||||
SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "appearance.tabs.workspace_decoration_visibility",
|
||||
description: "When workspace decorations such as the tab bar are visible.",
|
||||
);
|
||||
|
||||
impl WorkspaceDecorationVisibility {
|
||||
/// Choose a visibility setting that's logically opposite from this one.
|
||||
pub fn toggled(self) -> Self {
|
||||
// If we add other variants, there should still be logical opposites for each. For example,
|
||||
// toggling from any form of hidden workspace decorations should re-enable them.
|
||||
match self {
|
||||
WorkspaceDecorationVisibility::AlwaysShow => WorkspaceDecorationVisibility::OnHover,
|
||||
WorkspaceDecorationVisibility::OnHover => WorkspaceDecorationVisibility::HideFullscreen,
|
||||
WorkspaceDecorationVisibility::HideFullscreen => WorkspaceDecorationVisibility::OnHover,
|
||||
}
|
||||
}
|
||||
|
||||
/// True if this is a setting where workspace decorations are hidden by default.
|
||||
pub fn hides_decorations_by_default(self) -> bool {
|
||||
matches!(self, WorkspaceDecorationVisibility::OnHover,)
|
||||
}
|
||||
|
||||
/// True if *window* decorations should be shown.
|
||||
pub fn show_window_decorations(self) -> bool {
|
||||
!matches!(self, WorkspaceDecorationVisibility::OnHover)
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents the color state for a directory entry in the tab-color settings.
|
||||
#[derive(
|
||||
Debug,
|
||||
Clone,
|
||||
Copy,
|
||||
PartialEq,
|
||||
Eq,
|
||||
serde::Serialize,
|
||||
serde::Deserialize,
|
||||
schemars::JsonSchema,
|
||||
settings_value::SettingsValue,
|
||||
)]
|
||||
#[schemars(
|
||||
description = "Color assignment state for a directory's tab.",
|
||||
rename_all = "snake_case"
|
||||
)]
|
||||
pub enum DirectoryTabColor {
|
||||
/// User explicitly removed this directory. Retained for backwards compatibility with settings files written by older versions.
|
||||
#[schemars(description = "The directory was explicitly removed from tab coloring.")]
|
||||
Suppressed,
|
||||
/// Directory is tracked but has no assigned color.
|
||||
#[schemars(description = "The directory is tracked but has no assigned color.")]
|
||||
Unassigned,
|
||||
/// Directory is tracked with a specific color.
|
||||
#[schemars(description = "The directory is assigned a specific color.")]
|
||||
Color(AnsiColorIdentifier),
|
||||
}
|
||||
|
||||
impl DirectoryTabColor {
|
||||
pub(crate) fn ansi_color(self) -> Option<AnsiColorIdentifier> {
|
||||
match self {
|
||||
DirectoryTabColor::Color(c) => Some(c),
|
||||
DirectoryTabColor::Suppressed | DirectoryTabColor::Unassigned => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// User-configured directory→color mappings for tab coloring.
|
||||
///
|
||||
/// Keys are directory paths (as strings). Values indicate the color state:
|
||||
/// - `Suppressed`: directory was explicitly removed by the user via the per-row X button.
|
||||
/// Retained so `color_for_directory` can shadow broader prefix matches, and for
|
||||
/// backwards compatibility with settings files written by older versions.
|
||||
/// - `Unassigned`: directory is tracked but has no specific color.
|
||||
/// - `Color(c)`: directory is tracked with the given color.
|
||||
#[derive(
|
||||
Default,
|
||||
Debug,
|
||||
Clone,
|
||||
serde::Serialize,
|
||||
serde::Deserialize,
|
||||
PartialEq,
|
||||
Eq,
|
||||
schemars::JsonSchema,
|
||||
settings_value::SettingsValue,
|
||||
)]
|
||||
#[schemars(description = "Mapping of directory paths to their tab color assignments.")]
|
||||
pub struct DirectoryTabColors(pub(crate) HashMap<String, DirectoryTabColor>);
|
||||
|
||||
settings::macros::implement_setting_for_enum!(
|
||||
DirectoryTabColors,
|
||||
TabSettings,
|
||||
SupportedPlatforms::ALL,
|
||||
SyncToCloud::Never,
|
||||
private: false,
|
||||
toml_path: "appearance.tabs.directory_tab_colors",
|
||||
max_table_depth: 0,
|
||||
description: "Mapping of directory paths to their tab color assignments.",
|
||||
feature_flag: warp_core::features::FeatureFlag::DirectoryTabColors,
|
||||
);
|
||||
|
||||
impl DirectoryTabColors {
|
||||
/// Returns the configured tab color for a directory using longest-prefix matching.
|
||||
/// Returns `None` if no configured directory is a prefix of `dir`.
|
||||
pub fn color_for_directory(&self, dir: &Path) -> Option<DirectoryTabColor> {
|
||||
let canonical_dir = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf());
|
||||
self.0
|
||||
.iter()
|
||||
.filter_map(|(configured_path, color)| {
|
||||
let configured = Path::new(configured_path);
|
||||
match color {
|
||||
DirectoryTabColor::Suppressed => None,
|
||||
_ => canonical_dir
|
||||
.starts_with(configured)
|
||||
.then_some((configured, *color)),
|
||||
}
|
||||
})
|
||||
.max_by_key(|(configured, _)| configured.as_os_str().len())
|
||||
.map(|(_, color)| color)
|
||||
}
|
||||
|
||||
/// Returns a new value with the given directory's color updated.
|
||||
pub fn with_color(&self, path: &Path, color: DirectoryTabColor) -> Self {
|
||||
let mut map = self.0.clone();
|
||||
|
||||
let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
|
||||
map.insert(canonical.to_string_lossy().to_string(), color);
|
||||
Self(map)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Clone,
|
||||
Debug,
|
||||
Default,
|
||||
serde::Serialize,
|
||||
serde::Deserialize,
|
||||
PartialEq,
|
||||
Eq,
|
||||
schemars::JsonSchema,
|
||||
settings_value::SettingsValue,
|
||||
)]
|
||||
#[schemars(
|
||||
description = "Configuration for the header toolbar chips in the vertical tab panel header.",
|
||||
rename_all = "snake_case"
|
||||
)]
|
||||
pub enum HeaderToolbarChipSelection {
|
||||
#[default]
|
||||
Default,
|
||||
Custom {
|
||||
left: Vec<super::header_toolbar_item::HeaderToolbarItemKind>,
|
||||
right: Vec<super::header_toolbar_item::HeaderToolbarItemKind>,
|
||||
},
|
||||
}
|
||||
|
||||
impl HeaderToolbarChipSelection {
|
||||
pub fn left_items(&self) -> Vec<super::header_toolbar_item::HeaderToolbarItemKind> {
|
||||
use super::header_toolbar_item::HeaderToolbarItemKind;
|
||||
match self {
|
||||
Self::Default => HeaderToolbarItemKind::default_left(),
|
||||
Self::Custom { left, .. } => left.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn right_items(&self) -> Vec<super::header_toolbar_item::HeaderToolbarItemKind> {
|
||||
use super::header_toolbar_item::HeaderToolbarItemKind;
|
||||
match self {
|
||||
Self::Default => HeaderToolbarItemKind::default_right(),
|
||||
Self::Custom { right, .. } => right.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
settings::macros::implement_setting_for_enum!(
|
||||
HeaderToolbarChipSelection,
|
||||
TabSettings,
|
||||
SupportedPlatforms::ALL,
|
||||
SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "appearance.tabs.header_toolbar_chip_selection",
|
||||
description: "Configuration for the header toolbar chips in the vertical tab panel header.",
|
||||
);
|
||||
|
||||
#[derive(
|
||||
Default,
|
||||
Debug,
|
||||
serde::Serialize,
|
||||
serde::Deserialize,
|
||||
PartialEq,
|
||||
Copy,
|
||||
Clone,
|
||||
schemars::JsonSchema,
|
||||
settings_value::SettingsValue,
|
||||
)]
|
||||
#[schemars(
|
||||
description = "Display mode for the vertical tab bar.",
|
||||
rename_all = "snake_case"
|
||||
)]
|
||||
pub enum VerticalTabsViewMode {
|
||||
#[default]
|
||||
Compact,
|
||||
Expanded,
|
||||
}
|
||||
|
||||
settings::macros::implement_setting_for_enum!(
|
||||
VerticalTabsViewMode,
|
||||
TabSettings,
|
||||
SupportedPlatforms::ALL,
|
||||
SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "appearance.vertical_tabs.view_mode",
|
||||
description: "Display mode for the vertical tab bar.",
|
||||
);
|
||||
|
||||
#[derive(
|
||||
Default,
|
||||
Debug,
|
||||
serde::Serialize,
|
||||
serde::Deserialize,
|
||||
PartialEq,
|
||||
Copy,
|
||||
Clone,
|
||||
schemars::JsonSchema,
|
||||
settings_value::SettingsValue,
|
||||
)]
|
||||
#[schemars(
|
||||
description = "Granularity of rows displayed in the vertical tabs panel.",
|
||||
rename_all = "snake_case"
|
||||
)]
|
||||
pub enum VerticalTabsDisplayGranularity {
|
||||
#[default]
|
||||
Panes,
|
||||
Tabs,
|
||||
}
|
||||
|
||||
settings::macros::implement_setting_for_enum!(
|
||||
VerticalTabsDisplayGranularity,
|
||||
TabSettings,
|
||||
SupportedPlatforms::ALL,
|
||||
SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "appearance.vertical_tabs.display_granularity",
|
||||
description: "Granularity of rows displayed in the vertical tabs panel.",
|
||||
);
|
||||
|
||||
#[derive(
|
||||
Default,
|
||||
Debug,
|
||||
serde::Serialize,
|
||||
serde::Deserialize,
|
||||
PartialEq,
|
||||
Copy,
|
||||
Clone,
|
||||
schemars::JsonSchema,
|
||||
settings_value::SettingsValue,
|
||||
)]
|
||||
#[schemars(
|
||||
description = "Tab item display mode in vertical tabs.",
|
||||
rename_all = "snake_case"
|
||||
)]
|
||||
pub enum VerticalTabsTabItemMode {
|
||||
#[default]
|
||||
FocusedSession,
|
||||
Summary,
|
||||
}
|
||||
|
||||
settings::macros::implement_setting_for_enum!(
|
||||
VerticalTabsTabItemMode,
|
||||
TabSettings,
|
||||
SupportedPlatforms::ALL,
|
||||
SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "appearance.vertical_tabs.tab_item_mode",
|
||||
description: "Tab item display mode in vertical tabs.",
|
||||
);
|
||||
|
||||
#[derive(
|
||||
Default,
|
||||
Debug,
|
||||
serde::Serialize,
|
||||
serde::Deserialize,
|
||||
PartialEq,
|
||||
Copy,
|
||||
Clone,
|
||||
schemars::JsonSchema,
|
||||
settings_value::SettingsValue,
|
||||
)]
|
||||
#[schemars(
|
||||
description = "Primary information displayed on vertical tabs.",
|
||||
rename_all = "snake_case"
|
||||
)]
|
||||
pub enum VerticalTabsPrimaryInfo {
|
||||
#[default]
|
||||
Command,
|
||||
WorkingDirectory,
|
||||
Branch,
|
||||
}
|
||||
|
||||
settings::macros::implement_setting_for_enum!(
|
||||
VerticalTabsPrimaryInfo,
|
||||
TabSettings,
|
||||
SupportedPlatforms::ALL,
|
||||
SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "appearance.vertical_tabs.primary_info",
|
||||
description: "The primary information displayed on vertical tabs.",
|
||||
);
|
||||
|
||||
#[derive(
|
||||
Default,
|
||||
Debug,
|
||||
serde::Serialize,
|
||||
serde::Deserialize,
|
||||
PartialEq,
|
||||
Copy,
|
||||
Clone,
|
||||
schemars::JsonSchema,
|
||||
settings_value::SettingsValue,
|
||||
)]
|
||||
#[schemars(
|
||||
description = "Subtitle shown on compact vertical tabs.",
|
||||
rename_all = "snake_case"
|
||||
)]
|
||||
pub enum VerticalTabsCompactSubtitle {
|
||||
#[default]
|
||||
Branch,
|
||||
WorkingDirectory,
|
||||
Command,
|
||||
}
|
||||
|
||||
settings::macros::implement_setting_for_enum!(
|
||||
VerticalTabsCompactSubtitle,
|
||||
TabSettings,
|
||||
SupportedPlatforms::ALL,
|
||||
SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "appearance.vertical_tabs.compact_subtitle",
|
||||
description: "Subtitle shown on compact vertical tabs.",
|
||||
);
|
||||
|
||||
define_settings_group!(TabSettings, settings: [
|
||||
show_indicators: ShowIndicatorsButton {
|
||||
type: bool,
|
||||
default: true,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "appearance.tabs.show_indicators_button",
|
||||
description: "Whether to show activity indicators on tabs.",
|
||||
},
|
||||
show_code_review_button: ShowCodeReviewButton {
|
||||
type: bool,
|
||||
default: true,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "code.editor.show_code_review_button",
|
||||
description: "Whether to show the code review button on tabs.",
|
||||
},
|
||||
show_code_review_diff_stats: ShowCodeReviewDiffStats {
|
||||
type: bool,
|
||||
default: true,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "code.editor.show_code_review_diff_stats",
|
||||
description: "Whether to show lines added/removed counts on the code review button.",
|
||||
},
|
||||
preserve_active_tab_color: PreserveActiveTabColor {
|
||||
type: bool,
|
||||
default: false,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "appearance.tabs.preserve_active_tab_color",
|
||||
description: "Whether to preserve the active tab's color when switching tabs.",
|
||||
},
|
||||
use_vertical_tabs: UseVerticalTabs {
|
||||
type: bool,
|
||||
default: false,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "appearance.vertical_tabs.enabled",
|
||||
description: "Whether to display tabs vertically instead of horizontally.",
|
||||
},
|
||||
use_latest_user_prompt_as_conversation_title_in_tab_names: UseLatestUserPromptAsConversationTitleInTabNames {
|
||||
type: bool,
|
||||
default: false,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "appearance.vertical_tabs.use_latest_prompt_as_title",
|
||||
description: "Whether vertical tab names for agent conversations use the latest user prompt.",
|
||||
},
|
||||
vertical_tabs_display_granularity: VerticalTabsDisplayGranularity,
|
||||
vertical_tabs_tab_item_mode: VerticalTabsTabItemMode,
|
||||
vertical_tabs_view_mode: VerticalTabsViewMode,
|
||||
vertical_tabs_primary_info: VerticalTabsPrimaryInfo,
|
||||
vertical_tabs_compact_subtitle: VerticalTabsCompactSubtitle,
|
||||
vertical_tabs_show_pr_link: VerticalTabsShowPrLink {
|
||||
type: bool,
|
||||
default: true,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "appearance.vertical_tabs.show_pr_link",
|
||||
description: "Whether to show PR links on vertical tabs.",
|
||||
},
|
||||
vertical_tabs_show_diff_stats: VerticalTabsShowDiffStats {
|
||||
type: bool,
|
||||
default: true,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "appearance.vertical_tabs.show_diff_stats",
|
||||
description: "Whether to show diff stats on vertical tabs.",
|
||||
},
|
||||
vertical_tabs_show_details_on_hover: VerticalTabsShowDetailsOnHover {
|
||||
type: bool,
|
||||
default: true,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "appearance.vertical_tabs.show_details_on_hover",
|
||||
description: "Whether to show a details sidecar when hovering over a vertical tab.",
|
||||
},
|
||||
header_toolbar_chip_selection: HeaderToolbarChipSelection,
|
||||
new_tab_placement: NewTabPlacement,
|
||||
workspace_decoration_visibility: WorkspaceDecorationVisibility,
|
||||
close_button_position: TabCloseButtonPosition,
|
||||
directory_tab_colors: DirectoryTabColors,
|
||||
]);
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tab_settings_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,31 @@
|
||||
use super::*;
|
||||
use crate::test_util::settings::initialize_settings_for_tests;
|
||||
use settings::Setting;
|
||||
use warpui::{App, SingletonEntity};
|
||||
|
||||
#[test]
|
||||
fn use_latest_user_prompt_as_conversation_title_in_tab_names_defaults_to_false() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_settings_for_tests(&mut app);
|
||||
|
||||
TabSettings::handle(&app).read(&app, |settings, _ctx| {
|
||||
assert!(!*settings.use_latest_user_prompt_as_conversation_title_in_tab_names);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn use_latest_user_prompt_as_conversation_title_in_tab_names_uses_vertical_tabs_path() {
|
||||
assert_eq!(
|
||||
UseLatestUserPromptAsConversationTitleInTabNames::toml_path(),
|
||||
Some("appearance.vertical_tabs.use_latest_prompt_as_title")
|
||||
);
|
||||
assert_eq!(
|
||||
UseLatestUserPromptAsConversationTitleInTabNames::hierarchy(),
|
||||
Some("appearance.vertical_tabs")
|
||||
);
|
||||
assert_eq!(
|
||||
UseLatestUserPromptAsConversationTitleInTabNames::toml_key(),
|
||||
"use_latest_prompt_as_title"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
use warpui::{Entity, ModelContext, SingletonEntity, WindowId};
|
||||
|
||||
use crate::{
|
||||
view_components::{DismissibleToast, ToastType},
|
||||
workspace::WorkspaceAction,
|
||||
};
|
||||
|
||||
/// A global model that provides an interface to open a workspace-level
|
||||
/// toast. This allows callers to add a toast from any context that has
|
||||
/// access to the AppContext.
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub struct ToastStack;
|
||||
|
||||
impl From<ToastType> for DismissibleToast<WorkspaceAction> {
|
||||
fn from(value: ToastType) -> Self {
|
||||
match value {
|
||||
ToastType::CloudObjectNotFound => {
|
||||
DismissibleToast::error(String::from("Resource not found or access denied"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ToastStack {
|
||||
/// Adds an ephemeral toast to the Workspace in the window identified by `window_id`.
|
||||
pub fn add_ephemeral_toast(
|
||||
&mut self,
|
||||
toast: DismissibleToast<WorkspaceAction>,
|
||||
window_id: WindowId,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
ctx.emit(ToastStackEvent::AddEphemeralToast { window_id, toast });
|
||||
}
|
||||
|
||||
/// Adds a persistent toast to the Workspace in the window identified by `window_id`.
|
||||
pub fn add_persistent_toast(
|
||||
&mut self,
|
||||
toast: DismissibleToast<WorkspaceAction>,
|
||||
window_id: WindowId,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
ctx.emit(ToastStackEvent::AddPersistentToast { window_id, toast });
|
||||
}
|
||||
|
||||
pub fn add_ephemeral_toast_by_type(
|
||||
&mut self,
|
||||
toast_type: ToastType,
|
||||
window_id: WindowId,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let toast: DismissibleToast<WorkspaceAction> = toast_type.into();
|
||||
ctx.emit(ToastStackEvent::AddEphemeralToast { window_id, toast });
|
||||
}
|
||||
|
||||
pub fn remove_toast_by_identifier(
|
||||
&mut self,
|
||||
identifier: String,
|
||||
window_id: WindowId,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
ctx.emit(ToastStackEvent::RemoveToast {
|
||||
window_id,
|
||||
identifier,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::enum_variant_names)]
|
||||
pub enum ToastStackEvent {
|
||||
AddEphemeralToast {
|
||||
/// The window for which this event is for.
|
||||
window_id: WindowId,
|
||||
toast: DismissibleToast<WorkspaceAction>,
|
||||
},
|
||||
AddPersistentToast {
|
||||
/// The window for which this event is for.
|
||||
window_id: WindowId,
|
||||
toast: DismissibleToast<WorkspaceAction>,
|
||||
},
|
||||
RemoveToast {
|
||||
/// The window for which this event is for.
|
||||
window_id: WindowId,
|
||||
identifier: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl Entity for ToastStack {
|
||||
type Event = ToastStackEvent;
|
||||
}
|
||||
|
||||
impl SingletonEntity for ToastStack {}
|
||||
@@ -0,0 +1,386 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use warpui::{
|
||||
elements::MouseStateHandle, AppContext, EntityId, SingletonEntity, ViewContext, ViewHandle,
|
||||
WindowId,
|
||||
};
|
||||
|
||||
use super::OneTimeModalModel;
|
||||
use crate::window_settings::WindowSettings;
|
||||
use crate::{
|
||||
appearance::Appearance, pane_group::PaneId, terminal::TerminalView, workspace::Workspace,
|
||||
};
|
||||
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
/// What composes a pane (i.e. the pane group and the pane itself).
|
||||
pub struct PaneViewLocator {
|
||||
pub pane_group_id: EntityId,
|
||||
pub pane_id: PaneId,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct WorkspaceMouseStates {
|
||||
pub(super) new_tab_button: MouseStateHandle,
|
||||
pub(super) new_tab_menu: MouseStateHandle,
|
||||
pub(super) new_tab: MouseStateHandle,
|
||||
pub(super) overflow_button: MouseStateHandle,
|
||||
pub(super) banner_button: MouseStateHandle,
|
||||
pub(super) banner_secondary_button: MouseStateHandle,
|
||||
pub(super) more_info_banner_button: MouseStateHandle,
|
||||
pub(super) resource_center_icon: MouseStateHandle,
|
||||
pub(super) ai_tab_bar_button: MouseStateHandle,
|
||||
pub(super) agent_management_view_button: MouseStateHandle,
|
||||
pub(super) left_panel_icon: MouseStateHandle,
|
||||
pub(super) settings_icon: MouseStateHandle,
|
||||
pub(super) dismiss_banner_button: MouseStateHandle,
|
||||
pub(super) sign_in_button: MouseStateHandle,
|
||||
pub(super) sign_up_button: MouseStateHandle,
|
||||
pub(super) offline_icon: MouseStateHandle,
|
||||
pub(super) avatar_icon: MouseStateHandle,
|
||||
pub(super) header_dimming: MouseStateHandle,
|
||||
pub(super) right_panel_icon: MouseStateHandle,
|
||||
pub(super) notifications_mailbox: MouseStateHandle,
|
||||
pub(super) session_config_tab_config_chip_close: MouseStateHandle,
|
||||
pub(super) tools_panel_icon: MouseStateHandle,
|
||||
pub(super) title_bar_search_bar: MouseStateHandle,
|
||||
#[cfg(target_family = "wasm")]
|
||||
pub(super) warp_logo: MouseStateHandle,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum WelcomeTipsViewState {
|
||||
Unavailable,
|
||||
Available { is_popup_open: bool },
|
||||
}
|
||||
|
||||
impl WelcomeTipsViewState {
|
||||
pub fn is_popup_open(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
WelcomeTipsViewState::Available {
|
||||
is_popup_open: true,
|
||||
..
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
pub fn close_popup(&mut self) {
|
||||
if let WelcomeTipsViewState::Available {
|
||||
ref mut is_popup_open,
|
||||
..
|
||||
} = self
|
||||
{
|
||||
*is_popup_open = false;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toggle_popup(&mut self) {
|
||||
if let WelcomeTipsViewState::Available {
|
||||
ref mut is_popup_open,
|
||||
..
|
||||
} = self
|
||||
{
|
||||
*is_popup_open = !*is_popup_open;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO change this struct to enum (as we can only have 1 of them set to true at a time)
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct WorkspaceState {
|
||||
pub is_palette_open: bool,
|
||||
pub is_ctrl_tab_palette_open: bool,
|
||||
pub is_theme_chooser_open: bool,
|
||||
pub is_theme_creator_modal_open: bool,
|
||||
pub is_theme_deletion_modal_open: bool,
|
||||
pub is_changelog_modal_open: bool,
|
||||
pub is_tab_being_dragged: bool,
|
||||
pub is_reward_modal_open: bool,
|
||||
pub is_launch_config_save_modal_open: bool,
|
||||
pub is_resource_center_open: bool,
|
||||
pub is_command_search_open: bool,
|
||||
pub is_warp_drive_open: bool,
|
||||
pub is_ai_assistant_panel_open: bool,
|
||||
pub is_agent_management_popup_open: bool,
|
||||
pub is_auth_override_modal_open: bool,
|
||||
pub is_require_login_modal_open: bool,
|
||||
pub is_workflow_modal_open: bool,
|
||||
pub is_prompt_editor_open: bool,
|
||||
pub is_agent_toolbar_editor_open: bool,
|
||||
pub is_header_toolbar_editor_open: bool,
|
||||
pub is_import_modal_open: bool,
|
||||
pub is_close_session_confirmation_dialog_open: bool,
|
||||
pub is_rewind_confirmation_dialog_open: bool,
|
||||
pub is_delete_conversation_confirmation_dialog_open: bool,
|
||||
pub is_native_quit_modal_open: bool,
|
||||
pub is_shared_objects_creation_denied_modal_open: bool,
|
||||
pub is_suggested_agent_mode_workflow_modal_open: bool,
|
||||
pub is_suggested_rule_modal_open: bool,
|
||||
pub is_enable_auto_reload_modal_open: bool,
|
||||
pub is_notification_mailbox_open: bool,
|
||||
pub is_agent_management_view_open: bool,
|
||||
pub is_codex_modal_open: bool,
|
||||
pub is_cloud_agent_capacity_modal_open: bool,
|
||||
pub is_free_tier_limit_hit_modal_open: bool,
|
||||
pub is_tab_config_params_modal_open: bool,
|
||||
pub is_session_config_modal_open: bool,
|
||||
pub is_new_worktree_modal_open: bool,
|
||||
pub is_remove_tab_config_dialog_open: bool,
|
||||
/// Whether the transcript details panel is open (WASM only, for conversation transcript viewing).
|
||||
pub is_transcript_details_panel_open: bool,
|
||||
tab_being_renamed: Option<usize>, // The index of the tab being renamed
|
||||
pane_being_renamed: Option<PaneViewLocator>,
|
||||
}
|
||||
|
||||
impl WorkspaceState {
|
||||
pub fn is_any_non_terminal_view_open(&self, app: &AppContext) -> bool {
|
||||
self.is_any_modal_open(app)
|
||||
|| self.is_theme_chooser_open
|
||||
|| self.is_ai_assistant_panel_open
|
||||
|| self.is_workflow_modal_open
|
||||
|| self.is_warp_drive_open
|
||||
}
|
||||
|
||||
pub fn is_any_non_palette_modal_open(&self, app: &AppContext) -> bool {
|
||||
self.is_theme_creator_modal_open
|
||||
|| self.is_theme_deletion_modal_open
|
||||
|| self.is_changelog_modal_open
|
||||
|| self.tab_being_renamed.is_some()
|
||||
|| self.pane_being_renamed.is_some()
|
||||
|| self.is_reward_modal_open
|
||||
|| self.is_launch_config_save_modal_open
|
||||
|| self.is_command_search_open
|
||||
|| self.is_prompt_editor_open
|
||||
|| self.is_agent_toolbar_editor_open
|
||||
|| self.is_header_toolbar_editor_open
|
||||
|| self.is_agent_management_popup_open
|
||||
|| self.is_import_modal_open
|
||||
|| self.is_shared_objects_creation_denied_modal_open
|
||||
|| self.is_suggested_rule_modal_open
|
||||
|| self.is_suggested_agent_mode_workflow_modal_open
|
||||
|| self.is_enable_auto_reload_modal_open
|
||||
|| self.is_codex_modal_open
|
||||
|| self.is_cloud_agent_capacity_modal_open
|
||||
|| self.is_free_tier_limit_hit_modal_open
|
||||
|| self.is_tab_config_params_modal_open
|
||||
|| self.is_session_config_modal_open
|
||||
|| self.is_new_worktree_modal_open
|
||||
|| self.is_remove_tab_config_dialog_open
|
||||
|| {
|
||||
let one_time_modal = OneTimeModalModel::as_ref(app);
|
||||
one_time_modal.is_oz_launch_modal_open()
|
||||
|| one_time_modal.is_build_plan_migration_modal_open()
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether any modal (sitting over terminal views) is open.
|
||||
pub fn is_any_modal_open(&self, app: &AppContext) -> bool {
|
||||
self.is_any_non_palette_modal_open(app)
|
||||
|| self.is_palette_open
|
||||
|| self.is_ctrl_tab_palette_open
|
||||
}
|
||||
|
||||
pub fn close_all_modals(&mut self) {
|
||||
self.is_palette_open = false;
|
||||
self.is_ctrl_tab_palette_open = false;
|
||||
self.is_theme_creator_modal_open = false;
|
||||
self.is_theme_deletion_modal_open = false;
|
||||
self.is_changelog_modal_open = false;
|
||||
self.tab_being_renamed = None;
|
||||
self.pane_being_renamed = None;
|
||||
self.is_reward_modal_open = false;
|
||||
self.is_launch_config_save_modal_open = false;
|
||||
self.is_command_search_open = false;
|
||||
self.is_workflow_modal_open = false;
|
||||
self.is_prompt_editor_open = false;
|
||||
self.is_agent_toolbar_editor_open = false;
|
||||
self.is_header_toolbar_editor_open = false;
|
||||
self.is_import_modal_open = false;
|
||||
self.is_shared_objects_creation_denied_modal_open = false;
|
||||
self.is_auth_override_modal_open = false;
|
||||
self.is_require_login_modal_open = false;
|
||||
self.is_suggested_rule_modal_open = false;
|
||||
self.is_suggested_agent_mode_workflow_modal_open = false;
|
||||
self.is_enable_auto_reload_modal_open = false;
|
||||
self.is_codex_modal_open = false;
|
||||
self.is_cloud_agent_capacity_modal_open = false;
|
||||
self.is_free_tier_limit_hit_modal_open = false;
|
||||
self.is_tab_config_params_modal_open = false;
|
||||
self.is_session_config_modal_open = false;
|
||||
self.is_new_worktree_modal_open = false;
|
||||
self.is_remove_tab_config_dialog_open = false;
|
||||
}
|
||||
|
||||
pub fn is_right_panel_open(&self) -> bool {
|
||||
self.is_resource_center_open || self.is_ai_assistant_panel_open
|
||||
}
|
||||
|
||||
pub fn is_left_panel_open(&self) -> bool {
|
||||
self.is_theme_chooser_open
|
||||
}
|
||||
|
||||
pub fn close_all_left_panels(&mut self) {
|
||||
self.is_warp_drive_open = false;
|
||||
self.is_theme_chooser_open = false;
|
||||
}
|
||||
|
||||
pub fn is_tab_being_renamed(&self) -> bool {
|
||||
self.tab_being_renamed.is_some()
|
||||
}
|
||||
|
||||
pub fn set_tab_being_renamed(&mut self, index: usize) {
|
||||
self.tab_being_renamed = Some(index);
|
||||
self.pane_being_renamed = None;
|
||||
}
|
||||
|
||||
pub fn clear_tab_being_renamed(&mut self) {
|
||||
self.tab_being_renamed = None;
|
||||
}
|
||||
|
||||
pub fn tab_being_renamed(&self) -> Option<usize> {
|
||||
self.tab_being_renamed
|
||||
}
|
||||
|
||||
pub fn is_pane_being_renamed(&self, pane: PaneViewLocator) -> bool {
|
||||
self.pane_being_renamed == Some(pane)
|
||||
}
|
||||
|
||||
pub fn is_any_pane_being_renamed(&self) -> bool {
|
||||
self.pane_being_renamed.is_some()
|
||||
}
|
||||
|
||||
pub fn set_pane_being_renamed(&mut self, pane: PaneViewLocator) {
|
||||
self.pane_being_renamed = Some(pane);
|
||||
self.tab_being_renamed = None;
|
||||
}
|
||||
|
||||
pub fn clear_pane_being_renamed(&mut self) {
|
||||
self.pane_being_renamed = None;
|
||||
}
|
||||
|
||||
pub fn pane_being_renamed(&self) -> Option<PaneViewLocator> {
|
||||
self.pane_being_renamed
|
||||
}
|
||||
}
|
||||
|
||||
/// Used to represent left and right movement for tabs in WorkspaceActions
|
||||
#[derive(PartialEq, Eq, Clone, Serialize, Deserialize, Copy)]
|
||||
pub enum TabMovement {
|
||||
Left,
|
||||
Right,
|
||||
}
|
||||
|
||||
/// Fallback behavior for when a terminal input is needed, but none are available.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum TerminalSessionFallbackBehavior {
|
||||
/// Never open a new terminal session; fail if there is not an active session with no running
|
||||
/// command.
|
||||
RequireExisting,
|
||||
/// Open a new terminal session if and only if there is no active session. If the active
|
||||
/// session is busy, fail.
|
||||
#[default]
|
||||
OpenIfNone,
|
||||
/// Open a new terminal session if there is active session OR if the active session is busy.
|
||||
OpenIfNeeded,
|
||||
}
|
||||
|
||||
/// Given a [`WindowId`], see if its [`Workspace`] contains an active [`TerminalView`] and return
|
||||
/// that.
|
||||
///
|
||||
/// Note that "active" is not the same as "focused" in Warp's pane management.
|
||||
pub fn active_terminal_in_window<T, F>(
|
||||
window_id: WindowId,
|
||||
ctx: &mut AppContext,
|
||||
update: F,
|
||||
) -> Option<T>
|
||||
where
|
||||
F: FnOnce(&mut TerminalView, &mut ViewContext<TerminalView>) -> T,
|
||||
{
|
||||
ctx.views_of_type::<Workspace>(window_id)
|
||||
.as_ref()
|
||||
.and_then(|v| v.first())
|
||||
.and_then(|handle| {
|
||||
handle.update(ctx, |workspace, w_ctx| {
|
||||
workspace
|
||||
.active_tab_pane_group()
|
||||
.update(w_ctx, |active_group, a_ctx| {
|
||||
active_group
|
||||
.active_session_view(a_ctx)
|
||||
.map(|terminal| terminal.update(a_ctx, update))
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Check if two terminal views are in the same tab pane group.
|
||||
///
|
||||
/// Returns true if both terminal views are in the same tab, false otherwise.
|
||||
pub fn is_terminal_view_in_same_tab(
|
||||
terminal_view_id_1: &EntityId,
|
||||
terminal_view_id_2: &EntityId,
|
||||
app: &AppContext,
|
||||
) -> bool {
|
||||
if terminal_view_id_1 == terminal_view_id_2 {
|
||||
return true;
|
||||
}
|
||||
let Some(active_window) = app.windows().active_window() else {
|
||||
return false;
|
||||
};
|
||||
let Some(workspace) = app
|
||||
.views_of_type::<Workspace>(active_window)
|
||||
.and_then(|views| views.first().cloned())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let workspace = workspace.as_ref(app);
|
||||
workspace
|
||||
.list_tab_pane_groups(app)
|
||||
.into_iter()
|
||||
.any(|tab_pane_group| {
|
||||
tab_pane_group.terminal_ids.contains(terminal_view_id_1)
|
||||
&& tab_pane_group.terminal_ids.contains(terminal_view_id_2)
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the active terminal session view in the active tab. This is used as the target for any
|
||||
/// selections in the adjacent editor.
|
||||
pub fn get_context_target_terminal_view(
|
||||
window_id: WindowId,
|
||||
ctx: &AppContext,
|
||||
) -> Option<ViewHandle<TerminalView>> {
|
||||
ctx.views_of_type::<Workspace>(window_id)
|
||||
.as_ref()
|
||||
.and_then(|v| v.first())
|
||||
.and_then(|handle| {
|
||||
handle.read(ctx, |workspace, w_ctx| {
|
||||
workspace
|
||||
.active_tab_pane_group()
|
||||
.read(w_ctx, |active_group, a_ctx| {
|
||||
active_group.active_session_view(a_ctx)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_terminal_background_fill(
|
||||
window_id: WindowId,
|
||||
app: &AppContext,
|
||||
) -> warpui::elements::Fill {
|
||||
let theme = Appearance::as_ref(app).theme();
|
||||
let terminal_opacity = get_terminal_background_opacity(window_id, app);
|
||||
theme.background().with_opacity(terminal_opacity).into()
|
||||
}
|
||||
|
||||
fn get_terminal_background_opacity(window_id: WindowId, app: &AppContext) -> u8 {
|
||||
let theme = Appearance::as_ref(app).theme();
|
||||
let background_opacity = WindowSettings::as_ref(app)
|
||||
.background_opacity
|
||||
.effective_opacity(window_id, app);
|
||||
|
||||
if let Some(img) = theme.background_image() {
|
||||
let opacity_ratio = background_opacity as f32 / 100.;
|
||||
// Scale the overlay opacity with the background opacity ratio.
|
||||
(((100 - img.opacity) as f32) * opacity_ratio) as u8
|
||||
} else {
|
||||
background_opacity
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,869 @@
|
||||
use crate::pricing::{PricingInfoModel, PricingInfoModelEvent};
|
||||
use crate::terminal::general_settings::GeneralSettings;
|
||||
use crate::ui_components::blended_colors;
|
||||
use crate::view_components::{Dropdown, DropdownEvent, DropdownItem, ToastFlavor};
|
||||
use crate::workspaces::user_workspaces::{UserWorkspaces, UserWorkspacesEvent};
|
||||
use crate::workspaces::workspace::CustomerType;
|
||||
use asset_macro::bundled_or_fetched_asset;
|
||||
use itertools::Itertools;
|
||||
use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
|
||||
use pathfinder_color::ColorU;
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use settings::Setting as _;
|
||||
use thousands::Separable;
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
use warp_core::ui::theme::Fill;
|
||||
use warp_graphql::billing::{AddonCreditsOption, StripeSubscriptionPlan};
|
||||
use warpui::elements::{
|
||||
Align, Border, CacheOption, ChildAnchor, ChildView, ConstrainedBox, Container, CornerRadius,
|
||||
CrossAxisAlignment, DropShadow, Flex, FormattedTextElement, HighlightedHyperlink, Image,
|
||||
MainAxisAlignment, MainAxisSize, MouseStateHandle, OffsetPositioning, ParentAnchor,
|
||||
ParentElement, ParentOffsetBounds, Radius, Shrinkable, Stack,
|
||||
};
|
||||
use warpui::fonts::{FamilyId, Weight};
|
||||
use warpui::platform::Cursor;
|
||||
use warpui::ui_components::button::ButtonVariant;
|
||||
use warpui::ui_components::components::{UiComponent, UiComponentStyles};
|
||||
use warpui::{
|
||||
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
|
||||
};
|
||||
|
||||
const BUTTON_DIAMETER: f32 = 20.;
|
||||
const DROPDOWN_WIDTH: f32 = 160.;
|
||||
const MODAL_HEIGHT: f32 = 540.;
|
||||
const MODAL_WIDTH: f32 = 876.;
|
||||
const LEFT_PANEL_WIDTH: f32 = 333.;
|
||||
const CORNER_RADIUS: f32 = 20.;
|
||||
const PANEL_PADDING: f32 = 24.;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Copy)]
|
||||
pub enum BuildPlanMigrationModalViewAction {
|
||||
SelectReloadDenomination(usize),
|
||||
// true => "enabled", false => "disabled"
|
||||
EnableAutoReloadToggled(bool),
|
||||
GetStartedClicked,
|
||||
Close,
|
||||
OpenUrl(&'static str),
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct StateHandles {
|
||||
close_button: MouseStateHandle,
|
||||
upgrade_button: MouseStateHandle,
|
||||
auto_reload_checkbox: MouseStateHandle,
|
||||
}
|
||||
|
||||
pub struct BuildPlanMigrationModal {
|
||||
state_handles: StateHandles,
|
||||
selected_addon_credits_option: usize,
|
||||
addon_credits_options: Vec<AddonCreditsOption>,
|
||||
is_updating: bool,
|
||||
is_dropdown_expanded: bool,
|
||||
auto_reload_enabled: bool,
|
||||
reload_denominations_dropdown: ViewHandle<Dropdown<BuildPlanMigrationModalViewAction>>,
|
||||
}
|
||||
|
||||
impl BuildPlanMigrationModal {
|
||||
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
|
||||
ctx.subscribe_to_model(
|
||||
&PricingInfoModel::handle(ctx),
|
||||
|me, _, event, ctx| match event {
|
||||
PricingInfoModelEvent::PricingInfoUpdated => {
|
||||
me.update_addon_credits_options(ctx);
|
||||
ctx.notify();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
ctx.subscribe_to_model(&UserWorkspaces::handle(ctx), |me, _handle, event, ctx| {
|
||||
me.handle_workspaces_event(event, ctx);
|
||||
});
|
||||
|
||||
let reload_denominations_dropdown = ctx.add_typed_action_view(|ctx| {
|
||||
let mut dropdown = Dropdown::new(ctx);
|
||||
dropdown.set_top_bar_max_width(DROPDOWN_WIDTH);
|
||||
dropdown.set_menu_width(DROPDOWN_WIDTH, ctx);
|
||||
dropdown
|
||||
});
|
||||
|
||||
ctx.subscribe_to_view(
|
||||
&reload_denominations_dropdown,
|
||||
|me, _, event, ctx| match event {
|
||||
DropdownEvent::ToggleExpanded => {
|
||||
me.is_dropdown_expanded = !me.is_dropdown_expanded;
|
||||
ctx.notify();
|
||||
}
|
||||
DropdownEvent::Close => {
|
||||
me.is_dropdown_expanded = false;
|
||||
ctx.notify();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
let mut me = BuildPlanMigrationModal {
|
||||
state_handles: Default::default(),
|
||||
selected_addon_credits_option: 0,
|
||||
addon_credits_options: Default::default(),
|
||||
is_updating: false,
|
||||
is_dropdown_expanded: false,
|
||||
auto_reload_enabled: false,
|
||||
reload_denominations_dropdown,
|
||||
};
|
||||
me.update_addon_credits_options(ctx);
|
||||
me.refresh_addon_credits_settings(ctx);
|
||||
me
|
||||
}
|
||||
|
||||
fn refresh_addon_credits_settings(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
let Some(workspace) = UserWorkspaces::as_ref(ctx).current_workspace() else {
|
||||
return;
|
||||
};
|
||||
let addon_credits_settings = &workspace.settings.addon_credits_settings;
|
||||
self.auto_reload_enabled = addon_credits_settings.auto_reload_enabled;
|
||||
self.selected_addon_credits_option = addon_credits_settings
|
||||
.selected_auto_reload_credit_denomination
|
||||
.and_then(|amount| {
|
||||
self.addon_credits_options
|
||||
.iter()
|
||||
.find_position(|option| option.credits == amount)
|
||||
})
|
||||
.map_or(0, |pair| pair.0);
|
||||
// Update dropdown to reflect the refreshed selection
|
||||
self.reload_denominations_dropdown
|
||||
.update(ctx, |dropdown, ctx| {
|
||||
dropdown.set_selected_by_index(self.selected_addon_credits_option, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
fn update_addon_credits_options(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.addon_credits_options = PricingInfoModel::as_ref(ctx)
|
||||
.addon_credits_options()
|
||||
.map(|opts| opts.to_vec())
|
||||
.unwrap_or_default();
|
||||
// Sync the selected denomination after options are updated
|
||||
self.sync_selected_denomination(ctx);
|
||||
// Populate dropdown after syncing selection so it shows the correct item
|
||||
self.populate_reload_denomination_dropdown(ctx);
|
||||
}
|
||||
|
||||
fn sync_selected_denomination(&mut self, ctx: &ViewContext<Self>) {
|
||||
let Some(workspace) = UserWorkspaces::as_ref(ctx).current_workspace() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let addon_credits_settings = &workspace.settings.addon_credits_settings;
|
||||
// Sync the auto-reload enabled flag
|
||||
self.auto_reload_enabled = addon_credits_settings.auto_reload_enabled;
|
||||
|
||||
if let Some(selected_amount) =
|
||||
addon_credits_settings.selected_auto_reload_credit_denomination
|
||||
{
|
||||
// Find the index of the option that matches the selected amount
|
||||
if let Some((index, _)) = self
|
||||
.addon_credits_options
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(_, option)| option.credits == selected_amount)
|
||||
{
|
||||
self.selected_addon_credits_option = index;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_workspaces_event(
|
||||
&mut self,
|
||||
event: &UserWorkspacesEvent,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
match event {
|
||||
UserWorkspacesEvent::UpdateWorkspaceSettingsSuccess => {
|
||||
if self.is_updating {
|
||||
// Close modal on success when we initiated the update
|
||||
self.is_updating = false;
|
||||
self.update_addon_credits_options(ctx);
|
||||
Self::mark_modal_dismissed(ctx);
|
||||
ctx.emit(BuildPlanMigrationModalEvent::Close);
|
||||
} else {
|
||||
// External update - refresh our state to stay in sync
|
||||
self.refresh_addon_credits_settings(ctx);
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
UserWorkspacesEvent::UpdateWorkspaceSettingsRejected(_err) => {
|
||||
self.is_updating = false;
|
||||
ctx.emit(BuildPlanMigrationModalEvent::ShowToast {
|
||||
message: "Failed to enable auto-reload. Please try updating your settings in Billing & usage.".to_string(),
|
||||
flavor: ToastFlavor::Error,
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn mark_modal_dismissed(ctx: &mut ViewContext<Self>) {
|
||||
let general_settings = GeneralSettings::handle(ctx);
|
||||
general_settings.update(ctx, |settings, ctx| {
|
||||
if let Err(e) = settings
|
||||
.build_plan_migration_modal_dismissed
|
||||
.set_value(true, ctx)
|
||||
{
|
||||
log::warn!("Failed to set build plan migration modal dismissed setting: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn populate_reload_denomination_dropdown(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.reload_denominations_dropdown
|
||||
.update(ctx, |dropdown, ctx| {
|
||||
dropdown.set_items(
|
||||
self.addon_credits_options
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, option)| {
|
||||
DropdownItem::new(
|
||||
format!(
|
||||
"${} / {} credits",
|
||||
option.price_usd_cents / 100,
|
||||
option.credits.separate_with_commas(),
|
||||
),
|
||||
BuildPlanMigrationModalViewAction::SelectReloadDenomination(i),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
ctx,
|
||||
);
|
||||
// Set the selected item to match the current selection
|
||||
dropdown.set_selected_by_index(self.selected_addon_credits_option, ctx);
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn render_auto_reload_controls(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
let check_color = theme.background().into_solid();
|
||||
|
||||
let auto_reload_enabled = self.auto_reload_enabled;
|
||||
let checkbox = appearance
|
||||
.ui_builder()
|
||||
.checkbox(
|
||||
self.state_handles.auto_reload_checkbox.clone(),
|
||||
Some(appearance.ui_font_size()),
|
||||
)
|
||||
.check(auto_reload_enabled)
|
||||
.with_style(UiComponentStyles {
|
||||
font_color: Some(check_color),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(
|
||||
BuildPlanMigrationModalViewAction::EnableAutoReloadToggled(
|
||||
!auto_reload_enabled,
|
||||
),
|
||||
)
|
||||
})
|
||||
.finish();
|
||||
|
||||
let label = FormattedTextElement::from_str("Auto-reload", appearance.ui_font_family(), 12.)
|
||||
.with_color(blended_colors::text_sub(
|
||||
theme,
|
||||
blended_colors::neutral_4(theme),
|
||||
))
|
||||
.finish();
|
||||
|
||||
let checkbox_row = Flex::row()
|
||||
.with_child(checkbox)
|
||||
.with_child(label)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.finish();
|
||||
|
||||
let dropdown = if self.auto_reload_enabled {
|
||||
ChildView::new(&self.reload_denominations_dropdown).finish()
|
||||
} else {
|
||||
// Match dropdown height to prevent layout shift (dropdown is typically ~28-32px)
|
||||
ConstrainedBox::new(warpui::elements::Empty::new().finish())
|
||||
.with_width(DROPDOWN_WIDTH)
|
||||
.with_height(28.)
|
||||
.finish()
|
||||
};
|
||||
|
||||
Flex::row()
|
||||
.with_child(
|
||||
Container::new(checkbox_row)
|
||||
.with_vertical_margin(8.)
|
||||
.with_margin_right(4.)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(dropdown)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_spacing(8.)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_get_started_button(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
let button_text = if self.is_updating {
|
||||
"Saving...".to_string()
|
||||
} else {
|
||||
"Get Started".to_string()
|
||||
};
|
||||
|
||||
let button_font_color = self.is_updating.then_some(
|
||||
appearance
|
||||
.theme()
|
||||
.disabled_text_color(appearance.theme().surface_3())
|
||||
.into(),
|
||||
);
|
||||
let button_bg_color = self
|
||||
.is_updating
|
||||
.then_some(appearance.theme().surface_3().into());
|
||||
let button_border = self
|
||||
.is_updating
|
||||
.then_some(ColorU::transparent_black().into());
|
||||
|
||||
let mut button = appearance
|
||||
.ui_builder()
|
||||
.button(
|
||||
ButtonVariant::Accent,
|
||||
self.state_handles.upgrade_button.clone(),
|
||||
)
|
||||
.with_style(UiComponentStyles {
|
||||
font_size: Some(12.),
|
||||
height: Some(28.),
|
||||
width: Some(96.),
|
||||
font_color: button_font_color,
|
||||
background: button_bg_color,
|
||||
border_color: button_border,
|
||||
..Default::default()
|
||||
})
|
||||
.with_centered_text_label(button_text)
|
||||
.build()
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(BuildPlanMigrationModalViewAction::GetStartedClicked)
|
||||
});
|
||||
|
||||
if self.is_updating {
|
||||
button = button.disable();
|
||||
}
|
||||
button.finish()
|
||||
}
|
||||
|
||||
fn render_right_panel_content(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
|
||||
let title = Self::create_text(
|
||||
"Use auto-reload to never miss a beat.".to_string(),
|
||||
appearance.ui_font_family(),
|
||||
16.,
|
||||
blended_colors::text_main(theme, blended_colors::neutral_2(theme)),
|
||||
Some(Weight::Bold),
|
||||
);
|
||||
|
||||
let description = Self::create_text(
|
||||
"Auto-reload will automatically purchase credits at your selected rate when your account balance reaches 100 credits. Your monthly spend limit is set at your legacy plan's monthly cost and can be updated in Settings > Billing & usage.".to_string(),
|
||||
appearance.ui_font_family(),
|
||||
14.,
|
||||
blended_colors::text_sub(theme, blended_colors::neutral_4(theme)),
|
||||
None,
|
||||
);
|
||||
|
||||
Flex::column()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||
.with_child(
|
||||
Shrinkable::new(
|
||||
1.,
|
||||
Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
||||
.with_child(Container::new(title).with_margin_bottom(12.).finish())
|
||||
.with_child(Container::new(description).with_margin_bottom(16.).finish())
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
Container::new(
|
||||
Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_child(self.render_auto_reload_controls(appearance))
|
||||
.with_child(self.render_get_started_button(appearance))
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_right_panel(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
|
||||
let image = ConstrainedBox::new(
|
||||
Image::new(
|
||||
bundled_or_fetched_asset!("png/build_spiral.png"),
|
||||
CacheOption::BySize,
|
||||
)
|
||||
.cover()
|
||||
.with_corner_radius(CornerRadius::with_top_right(Radius::Pixels(CORNER_RADIUS)))
|
||||
.finish(),
|
||||
)
|
||||
.with_width(543.)
|
||||
.with_height(335.)
|
||||
.finish();
|
||||
|
||||
let content_panel = Shrinkable::new(
|
||||
1.,
|
||||
Container::new(self.render_right_panel_content(appearance))
|
||||
.with_uniform_padding(PANEL_PADDING)
|
||||
.with_background(blended_colors::neutral_2(theme))
|
||||
.with_border(Border::left(1.).with_border_color(blended_colors::neutral_4(theme)))
|
||||
.with_corner_radius(CornerRadius::with_bottom_right(Radius::Pixels(
|
||||
CORNER_RADIUS,
|
||||
)))
|
||||
.finish(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
Flex::column()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_child(image)
|
||||
.with_child(content_panel)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for BuildPlanMigrationModal {
|
||||
type Event = BuildPlanMigrationModalEvent;
|
||||
}
|
||||
|
||||
const BULLET_WIDTH: f32 = 12.;
|
||||
|
||||
impl BuildPlanMigrationModal {
|
||||
fn create_bullet_item(
|
||||
text: String,
|
||||
font_family: FamilyId,
|
||||
font_size: f32,
|
||||
color: ColorU,
|
||||
) -> Box<dyn Element> {
|
||||
let bullet = FormattedTextElement::from_str("•", font_family, font_size)
|
||||
.with_color(color)
|
||||
.with_weight(Weight::Bold)
|
||||
.finish();
|
||||
|
||||
let text_content = FormattedTextElement::from_str(text, font_family, font_size)
|
||||
.with_color(color)
|
||||
.finish();
|
||||
|
||||
Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
||||
.with_child(
|
||||
ConstrainedBox::new(bullet)
|
||||
.with_width(BULLET_WIDTH)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(Shrinkable::new(1., text_content).finish())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl BuildPlanMigrationModal {
|
||||
fn create_text(
|
||||
text: String,
|
||||
font_family: FamilyId,
|
||||
font_size: f32,
|
||||
color: ColorU,
|
||||
weight: Option<Weight>,
|
||||
) -> Box<dyn Element> {
|
||||
let mut element =
|
||||
FormattedTextElement::from_str(text, font_family, font_size).with_color(color);
|
||||
if let Some(weight) = weight {
|
||||
element = element.with_weight(weight);
|
||||
}
|
||||
element.finish()
|
||||
}
|
||||
|
||||
fn render_left_panel(&self, appearance: &Appearance, app: &AppContext) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
let font_family = appearance.ui_font_family();
|
||||
let text_color = blended_colors::text_sub(theme, blended_colors::neutral_2(theme));
|
||||
|
||||
// Check if any service agreement has type Business
|
||||
let is_business = UserWorkspaces::as_ref(app)
|
||||
.current_workspace()
|
||||
.map(|workspace| workspace.billing_metadata.customer_type == CustomerType::Business)
|
||||
.unwrap_or(false);
|
||||
|
||||
let plan_pricing = PricingInfoModel::as_ref(app).plan_pricing(if is_business {
|
||||
&StripeSubscriptionPlan::BuildBusiness
|
||||
} else {
|
||||
&StripeSubscriptionPlan::Build
|
||||
});
|
||||
let base_credits_limit = plan_pricing.and_then(|p| p.request_limit).unwrap_or(1500);
|
||||
// (monthly price cents, monthly price cents for annual)
|
||||
let base_plan_prices = plan_pricing
|
||||
.map(|p| {
|
||||
(
|
||||
p.monthly_plan_price_per_month_usd_cents,
|
||||
p.yearly_plan_price_per_month_usd_cents,
|
||||
)
|
||||
})
|
||||
.unwrap_or((2000, 1800));
|
||||
|
||||
let title_text = if is_business {
|
||||
"Welcome to the New Business Plan"
|
||||
} else {
|
||||
"Welcome to Warp Build"
|
||||
};
|
||||
|
||||
let title = Self::create_text(
|
||||
title_text.to_string(),
|
||||
font_family,
|
||||
24.,
|
||||
blended_colors::text_main(theme, blended_colors::neutral_2(theme)),
|
||||
Some(Weight::Bold),
|
||||
);
|
||||
|
||||
let intro_text = if is_business {
|
||||
"Your workspace has been updated to the new Warp Business Plan as the legacy Business plan is sunset."
|
||||
} else {
|
||||
"Your workspace has been updated to the Warp Build Plan as the legacy Pro, Turbo, and Lightspeed plans are sunset."
|
||||
};
|
||||
|
||||
let intro = Self::create_text(intro_text.to_string(), font_family, 14., text_color, None);
|
||||
|
||||
let pricing_header = Self::create_text(
|
||||
if is_business {
|
||||
"The new Business plan is a primarily usage-based plan, starting at:"
|
||||
} else {
|
||||
"Warp Build is a primarily usage-based plan, starting at:"
|
||||
}
|
||||
.to_string(),
|
||||
font_family,
|
||||
14.,
|
||||
text_color,
|
||||
None,
|
||||
);
|
||||
|
||||
let price_monthly = Self::create_bullet_item(
|
||||
format!("${} per user per month", base_plan_prices.0 / 100),
|
||||
font_family,
|
||||
14.,
|
||||
text_color,
|
||||
);
|
||||
|
||||
let price_annual = Self::create_bullet_item(
|
||||
format!(
|
||||
"${} per user per month for annual plans",
|
||||
base_plan_prices.1 / 100
|
||||
),
|
||||
font_family,
|
||||
14.,
|
||||
text_color,
|
||||
);
|
||||
|
||||
let features_header = Self::create_text(
|
||||
if is_business {
|
||||
"The new Business plan comes with:"
|
||||
} else {
|
||||
"Build comes with:"
|
||||
}
|
||||
.to_string(),
|
||||
font_family,
|
||||
14.,
|
||||
text_color,
|
||||
None,
|
||||
);
|
||||
|
||||
let base_credits = Self::create_bullet_item(
|
||||
format!(
|
||||
"{} base credits per month",
|
||||
base_credits_limit.separate_with_commas()
|
||||
),
|
||||
font_family,
|
||||
14.,
|
||||
text_color,
|
||||
);
|
||||
|
||||
let reload_credits = Self::create_bullet_item(
|
||||
"Access to Reload credits and volume-based discounts".to_string(),
|
||||
font_family,
|
||||
14.,
|
||||
text_color,
|
||||
);
|
||||
|
||||
let byok = Self::create_bullet_item(
|
||||
"Bring your own API key".to_string(),
|
||||
font_family,
|
||||
14.,
|
||||
text_color,
|
||||
);
|
||||
|
||||
let mut features_list = Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
||||
.with_child(base_credits)
|
||||
.with_child(reload_credits)
|
||||
.with_child(byok);
|
||||
|
||||
if is_business {
|
||||
let sso = Self::create_bullet_item(
|
||||
"SAML-based SSO".to_string(),
|
||||
font_family,
|
||||
14.,
|
||||
text_color,
|
||||
);
|
||||
features_list.add_child(sso);
|
||||
|
||||
let zdr = Self::create_bullet_item(
|
||||
"Automatically enforced team-wide Zero Data Retention".to_string(),
|
||||
font_family,
|
||||
14.,
|
||||
text_color,
|
||||
);
|
||||
features_list.add_child(zdr);
|
||||
}
|
||||
|
||||
let and_more =
|
||||
Self::create_bullet_item("And more...".to_string(), font_family, 14., text_color);
|
||||
features_list.add_child(and_more);
|
||||
|
||||
let learn_more_fragments = vec![
|
||||
FormattedTextFragment::plain_text("Learn more on our "),
|
||||
FormattedTextFragment::hyperlink("pricing page", "https://www.warp.dev/pricing"),
|
||||
FormattedTextFragment::plain_text("."),
|
||||
];
|
||||
let learn_more = Container::new(
|
||||
FormattedTextElement::new(
|
||||
FormattedText::new([FormattedTextLine::Line(learn_more_fragments)]),
|
||||
14.,
|
||||
font_family,
|
||||
font_family,
|
||||
text_color,
|
||||
HighlightedHyperlink::default(),
|
||||
)
|
||||
.with_hyperlink_font_color(appearance.theme().accent().into_solid())
|
||||
.register_default_click_handlers(|_url, ctx, _| {
|
||||
ctx.dispatch_typed_action(BuildPlanMigrationModalViewAction::OpenUrl(
|
||||
"https://www.warp.dev/pricing",
|
||||
));
|
||||
})
|
||||
.finish(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
Container::new(
|
||||
Flex::column()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
||||
.with_child(Container::new(title).with_margin_bottom(12.).finish())
|
||||
.with_child(Container::new(intro).with_margin_bottom(16.).finish())
|
||||
.with_child(
|
||||
Container::new(pricing_header)
|
||||
.with_margin_bottom(8.)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(price_monthly)
|
||||
.with_child(
|
||||
Container::new(price_annual)
|
||||
.with_margin_bottom(16.)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
Container::new(features_header)
|
||||
.with_margin_bottom(8.)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
Container::new(features_list.finish())
|
||||
.with_margin_bottom(16.)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(Container::new(learn_more).finish())
|
||||
.finish(),
|
||||
)
|
||||
.with_background_color(blended_colors::neutral_1(theme))
|
||||
.with_corner_radius(CornerRadius::with_left(Radius::Pixels(CORNER_RADIUS)))
|
||||
.with_uniform_padding(PANEL_PADDING)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_close_button(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
appearance
|
||||
.ui_builder()
|
||||
.close_button(BUTTON_DIAMETER, self.state_handles.close_button.clone())
|
||||
.with_style(UiComponentStyles {
|
||||
font_color: Some(ColorU::white()),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.on_click(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(BuildPlanMigrationModalViewAction::Close)
|
||||
})
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl View for BuildPlanMigrationModal {
|
||||
fn ui_name() -> &'static str {
|
||||
"BuildPlanMigrationModal"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
|
||||
let left_panel = self.render_left_panel(appearance, app);
|
||||
let close_button = self.render_close_button(appearance);
|
||||
|
||||
let right_panel_width = MODAL_WIDTH - LEFT_PANEL_WIDTH;
|
||||
let mut modal = Stack::new();
|
||||
modal.add_child(
|
||||
Container::new(
|
||||
ConstrainedBox::new(
|
||||
Flex::row()
|
||||
.with_child(
|
||||
ConstrainedBox::new(left_panel)
|
||||
.with_width(LEFT_PANEL_WIDTH)
|
||||
.with_height(MODAL_HEIGHT)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
ConstrainedBox::new(self.render_right_panel(app))
|
||||
.with_width(right_panel_width)
|
||||
.with_height(MODAL_HEIGHT)
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_width(MODAL_WIDTH)
|
||||
.with_height(MODAL_HEIGHT)
|
||||
.finish(),
|
||||
)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(CORNER_RADIUS)))
|
||||
.with_border(Border::all(1.).with_border_fill(theme.outline()))
|
||||
.with_drop_shadow(DropShadow::default())
|
||||
.finish(),
|
||||
);
|
||||
modal.add_positioned_child(
|
||||
close_button,
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(-14., 14.),
|
||||
ParentOffsetBounds::ParentByPosition,
|
||||
ParentAnchor::TopRight,
|
||||
ChildAnchor::TopRight,
|
||||
),
|
||||
);
|
||||
|
||||
// Stack needed so that modal can get bounds information,
|
||||
// specifically to ensure no overlap with the window's traffic lights
|
||||
let mut stack = Stack::new();
|
||||
stack.add_positioned_child(
|
||||
modal.finish(),
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(0., 0.),
|
||||
ParentOffsetBounds::WindowByPosition,
|
||||
ParentAnchor::Center,
|
||||
ChildAnchor::Center,
|
||||
),
|
||||
);
|
||||
|
||||
Container::new(Align::new(stack.finish()).finish())
|
||||
.with_background(Fill::Solid(ColorU::new(97, 97, 97, 255)).with_opacity(50))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for BuildPlanMigrationModal {
|
||||
type Action = BuildPlanMigrationModalViewAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
BuildPlanMigrationModalViewAction::SelectReloadDenomination(index) => {
|
||||
self.selected_addon_credits_option = *index;
|
||||
ctx.notify();
|
||||
}
|
||||
BuildPlanMigrationModalViewAction::GetStartedClicked => {
|
||||
// Get current team UID and workspace data
|
||||
let workspaces = UserWorkspaces::as_ref(ctx);
|
||||
let Some(team_uid) = workspaces.current_team_uid() else {
|
||||
ctx.emit(BuildPlanMigrationModalEvent::ShowToast {
|
||||
message: "Oops, something went wrong; your team data could not be found."
|
||||
.to_string(),
|
||||
flavor: ToastFlavor::Error,
|
||||
});
|
||||
return;
|
||||
};
|
||||
|
||||
// Get current monthly spend limit before any mutable borrows
|
||||
let current_monthly_spend_limit = workspaces
|
||||
.current_workspace()
|
||||
.and_then(|ws| ws.settings.addon_credits_settings.max_monthly_spend_cents);
|
||||
|
||||
// Set loading state
|
||||
self.is_updating = true;
|
||||
ctx.notify();
|
||||
|
||||
// Determine selected denomination (only if auto-reload is enabled)
|
||||
let selected_denomination = if self.auto_reload_enabled {
|
||||
self.addon_credits_options
|
||||
.get(self.selected_addon_credits_option)
|
||||
.map(|option| option.credits)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Determine if we need to update the monthly spend limit
|
||||
// If the selected denomination price is greater than the current limit, increase the limit
|
||||
let new_monthly_spend_limit = if self.auto_reload_enabled {
|
||||
self.addon_credits_options
|
||||
.get(self.selected_addon_credits_option)
|
||||
.and_then(|option| {
|
||||
let selected_price = option.price_usd_cents;
|
||||
match current_monthly_spend_limit {
|
||||
Some(current_limit) if selected_price > current_limit => {
|
||||
Some(selected_price)
|
||||
}
|
||||
None => Some(selected_price),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Call API to update auto-reload settings
|
||||
UserWorkspaces::handle(ctx).update(ctx, |user_workspaces, ctx| {
|
||||
user_workspaces.update_addon_credits_settings(
|
||||
team_uid,
|
||||
Some(self.auto_reload_enabled),
|
||||
new_monthly_spend_limit,
|
||||
selected_denomination,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
}
|
||||
BuildPlanMigrationModalViewAction::Close => {
|
||||
Self::mark_modal_dismissed(ctx);
|
||||
ctx.emit(BuildPlanMigrationModalEvent::Close);
|
||||
}
|
||||
BuildPlanMigrationModalViewAction::EnableAutoReloadToggled(enabled) => {
|
||||
self.auto_reload_enabled = *enabled;
|
||||
ctx.notify();
|
||||
}
|
||||
BuildPlanMigrationModalViewAction::OpenUrl(url) => {
|
||||
ctx.open_url(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum BuildPlanMigrationModalEvent {
|
||||
Close,
|
||||
ShowToast {
|
||||
message: String,
|
||||
flavor: ToastFlavor,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
use crate::auth::AuthStateProvider;
|
||||
use crate::pricing::PricingInfoModel;
|
||||
use crate::ui_components::blended_colors;
|
||||
use crate::ui_components::icons::Icon;
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
use crate::workspaces::workspace::CustomerType;
|
||||
use asset_macro::bundled_or_fetched_asset;
|
||||
use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
|
||||
use pathfinder_color::ColorU;
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use thousands::Separable;
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
use warp_core::ui::theme::Fill;
|
||||
use warp_graphql::billing::StripeSubscriptionPlan;
|
||||
use warpui::elements::{
|
||||
Align, CacheOption, ChildAnchor, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment,
|
||||
DropShadow, Expanded, Flex, FormattedTextElement, HighlightedHyperlink, Image,
|
||||
MainAxisAlignment, MainAxisSize, MouseStateHandle, OffsetPositioning, ParentAnchor,
|
||||
ParentElement, ParentOffsetBounds, Radius, Stack,
|
||||
};
|
||||
use warpui::fonts::Weight;
|
||||
use warpui::keymap::FixedBinding;
|
||||
use warpui::platform::Cursor;
|
||||
use warpui::ui_components::button::ButtonVariant;
|
||||
use warpui::ui_components::components::{UiComponent, UiComponentStyles};
|
||||
use warpui::{AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext};
|
||||
|
||||
use crate::send_telemetry_from_ctx;
|
||||
use crate::TelemetryEvent;
|
||||
|
||||
const MODAL_WIDTH: f32 = 360.;
|
||||
const MODAL_HEIGHT: f32 = 532.;
|
||||
const COMPACT_MODAL_HEIGHT: f32 = 360.;
|
||||
const HEADER_HEIGHT: f32 = 92.;
|
||||
const BUTTON_DIAMETER: f32 = 20.;
|
||||
const BILLING_AND_USAGE_URL: &str = "warp://settings/billing_and_usage";
|
||||
|
||||
#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)]
|
||||
pub enum CloudAgentCapacityModalVariant {
|
||||
#[default]
|
||||
ConcurrentLimit,
|
||||
OutOfCredits,
|
||||
}
|
||||
|
||||
pub fn init(app: &mut AppContext) {
|
||||
use warpui::keymap::macros::*;
|
||||
|
||||
app.register_fixed_bindings([FixedBinding::new(
|
||||
"escape",
|
||||
CloudAgentCapacityModalAction::Close,
|
||||
id!("CloudAgentCapacityModal"),
|
||||
)]);
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct StateHandles {
|
||||
close_button: MouseStateHandle,
|
||||
upgrade_button: MouseStateHandle,
|
||||
}
|
||||
|
||||
pub struct CloudAgentCapacityModal {
|
||||
state_handles: StateHandles,
|
||||
variant: CloudAgentCapacityModalVariant,
|
||||
}
|
||||
|
||||
impl CloudAgentCapacityModal {
|
||||
pub fn new() -> Self {
|
||||
CloudAgentCapacityModal {
|
||||
state_handles: Default::default(),
|
||||
variant: CloudAgentCapacityModalVariant::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_variant(&mut self, variant: CloudAgentCapacityModalVariant) {
|
||||
self.variant = variant;
|
||||
}
|
||||
|
||||
fn get_upgrade_url(ctx: &ViewContext<Self>) -> Option<String> {
|
||||
let auth_state = AuthStateProvider::handle(ctx).as_ref(ctx).get();
|
||||
if let Some(team) = UserWorkspaces::handle(ctx).as_ref(ctx).current_team() {
|
||||
return Some(UserWorkspaces::upgrade_link_for_team(team.uid));
|
||||
}
|
||||
|
||||
let user_id = auth_state.user_id().unwrap_or_default();
|
||||
Some(UserWorkspaces::upgrade_link(user_id))
|
||||
}
|
||||
|
||||
fn can_upgrade(customer_type: CustomerType, variant: CloudAgentCapacityModalVariant) -> bool {
|
||||
match variant {
|
||||
CloudAgentCapacityModalVariant::ConcurrentLimit => !matches!(
|
||||
customer_type,
|
||||
CustomerType::Business | CustomerType::Enterprise
|
||||
),
|
||||
CloudAgentCapacityModalVariant::OutOfCredits => {
|
||||
matches!(customer_type, CustomerType::Free | CustomerType::Unknown)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn should_show_cta(
|
||||
customer_type: CustomerType,
|
||||
variant: CloudAgentCapacityModalVariant,
|
||||
) -> bool {
|
||||
matches!(variant, CloudAgentCapacityModalVariant::OutOfCredits)
|
||||
|| Self::can_upgrade(customer_type, variant)
|
||||
}
|
||||
|
||||
fn cta_url(&self, ctx: &ViewContext<Self>) -> Option<String> {
|
||||
let customer_type = UserWorkspaces::handle(ctx)
|
||||
.as_ref(ctx)
|
||||
.current_workspace()
|
||||
.map(|workspace| workspace.billing_metadata.customer_type)
|
||||
.unwrap_or(CustomerType::Free);
|
||||
if !Self::should_show_cta(customer_type, self.variant) {
|
||||
return None;
|
||||
}
|
||||
if Self::can_upgrade(customer_type, self.variant) {
|
||||
Self::get_upgrade_url(ctx)
|
||||
} else {
|
||||
Some(BILLING_AND_USAGE_URL.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn render_content(&self, customer_type: CustomerType, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::handle(app).as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
let neutral_bg = blended_colors::neutral_1(theme);
|
||||
let (title_text, mut explanation_text) = match self.variant {
|
||||
CloudAgentCapacityModalVariant::ConcurrentLimit => (
|
||||
"Concurrent cloud agent limit reached",
|
||||
"This cloud run is queued because your team has reached the maximum number of concurrent cloud agents. It will start automatically when another cloud run finishes.".to_string(),
|
||||
),
|
||||
CloudAgentCapacityModalVariant::OutOfCredits => (
|
||||
"You're out of AI credits",
|
||||
"This cloud run stopped because your team has used all available AI credits for the current billing period.".to_string(),
|
||||
),
|
||||
};
|
||||
|
||||
// Title
|
||||
let title = FormattedTextElement::from_str(title_text, appearance.ui_font_family(), 24.)
|
||||
.with_color(blended_colors::text_main(theme, neutral_bg))
|
||||
.with_weight(Weight::Bold)
|
||||
.finish();
|
||||
|
||||
// Explanation.
|
||||
let can_upgrade = Self::can_upgrade(customer_type, self.variant);
|
||||
let show_cta = Self::should_show_cta(customer_type, self.variant);
|
||||
if can_upgrade {
|
||||
let upgrade_suffix = match self.variant {
|
||||
CloudAgentCapacityModalVariant::ConcurrentLimit => {
|
||||
" Upgrade your plan for more concurrent cloud agents."
|
||||
}
|
||||
CloudAgentCapacityModalVariant::OutOfCredits => {
|
||||
" Upgrade your plan to continue running cloud agents."
|
||||
}
|
||||
};
|
||||
explanation_text.push_str(upgrade_suffix);
|
||||
}
|
||||
let subtitle =
|
||||
FormattedTextElement::from_str(explanation_text, appearance.ui_font_family(), 14.)
|
||||
.with_color(blended_colors::text_sub(theme, neutral_bg))
|
||||
.finish();
|
||||
|
||||
let mut content = Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
||||
.with_child(Container::new(title).with_margin_bottom(12.).finish())
|
||||
.with_child(Container::new(subtitle).with_margin_bottom(16.).finish());
|
||||
|
||||
if can_upgrade {
|
||||
let (target_plan, agent_multiplier, extra_benefits) = match customer_type {
|
||||
CustomerType::Build | CustomerType::BuildMax => {
|
||||
(StripeSubscriptionPlan::BuildBusiness, "2x", vec!["SSO"])
|
||||
}
|
||||
// Free tier or a legacy plan.
|
||||
_ => (StripeSubscriptionPlan::Build, "5x", vec![]),
|
||||
};
|
||||
|
||||
let plan_pricing = PricingInfoModel::handle(app)
|
||||
.as_ref(app)
|
||||
.plan_pricing(&target_plan);
|
||||
|
||||
// Pricing text based on plan type and actual pricing
|
||||
let pricing_text = if customer_type == CustomerType::Free {
|
||||
if let Some(pricing) = plan_pricing {
|
||||
let price = pricing.yearly_plan_price_per_month_usd_cents / 100;
|
||||
format!(
|
||||
"Paid plans start at ${price}/month and include everything in your free trial plus:"
|
||||
)
|
||||
} else {
|
||||
"Paid plans include everything in your free trial plus:".to_string()
|
||||
}
|
||||
} else if let Some(pricing) = plan_pricing {
|
||||
let price = pricing.yearly_plan_price_per_month_usd_cents / 100;
|
||||
format!(
|
||||
"The Business plan starts at ${price}/month and includes everything on your current plan plus:"
|
||||
)
|
||||
} else {
|
||||
"The Business plan includes everything on your current plan plus:".to_string()
|
||||
};
|
||||
|
||||
let pricing = FormattedTextElement::new(
|
||||
FormattedText::new([FormattedTextLine::Line(vec![
|
||||
FormattedTextFragment::plain_text(pricing_text),
|
||||
])]),
|
||||
14.,
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_family(),
|
||||
blended_colors::text_sub(theme, neutral_bg),
|
||||
HighlightedHyperlink::default(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
// Credits text from plan pricing
|
||||
let credits_text = if let Some(limit) = plan_pricing.and_then(|plan| plan.request_limit)
|
||||
{
|
||||
format!("{} AI credits per month", limit.separate_with_commas())
|
||||
} else {
|
||||
"Extended AI credits per month".to_string()
|
||||
};
|
||||
|
||||
// Benefits list based on plan type
|
||||
let mut benefits = vec![
|
||||
format!("{} the number of concurrent cloud agents", agent_multiplier),
|
||||
credits_text,
|
||||
"Bring your own API key".to_string(),
|
||||
];
|
||||
for extra in extra_benefits {
|
||||
benefits.push(extra.to_string());
|
||||
}
|
||||
|
||||
let mut benefits_column =
|
||||
Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Start);
|
||||
|
||||
for benefit in benefits {
|
||||
let benefit_formatted = FormattedText::new([FormattedTextLine::Line(vec![
|
||||
FormattedTextFragment::plain_text(benefit),
|
||||
])]);
|
||||
benefits_column.add_child(
|
||||
Container::new(
|
||||
Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(
|
||||
Container::new(
|
||||
ConstrainedBox::new(
|
||||
Icon::CheckCircleBroken
|
||||
.to_warpui_icon(Fill::Solid(theme.ansi_fg_green()))
|
||||
.finish(),
|
||||
)
|
||||
.with_width(14.)
|
||||
.with_height(14.)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_right(4.)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
FormattedTextElement::new(
|
||||
benefit_formatted,
|
||||
14.,
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_family(),
|
||||
blended_colors::text_sub(theme, neutral_bg),
|
||||
HighlightedHyperlink::default(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_bottom(8.)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
content.add_child(Container::new(pricing).with_margin_bottom(8.).finish());
|
||||
content.add_child(benefits_column.finish());
|
||||
}
|
||||
|
||||
let content = content.finish();
|
||||
let cta_button = if show_cta {
|
||||
let cta_button_label = if can_upgrade {
|
||||
"Upgrade plan"
|
||||
} else {
|
||||
"Open billing"
|
||||
};
|
||||
Some(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.button(
|
||||
ButtonVariant::Accent,
|
||||
self.state_handles.upgrade_button.clone(),
|
||||
)
|
||||
.with_style(UiComponentStyles {
|
||||
font_size: Some(14.),
|
||||
height: Some(32.),
|
||||
width: Some(296.),
|
||||
..Default::default()
|
||||
})
|
||||
.with_centered_text_label(cta_button_label.to_string())
|
||||
.build()
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(CloudAgentCapacityModalAction::Upgrade)
|
||||
})
|
||||
.finish(),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Main content layout
|
||||
let layout = if let Some(cta_button) = cta_button {
|
||||
Flex::column()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
||||
.with_child(content)
|
||||
.with_child(Align::new(cta_button).bottom_left().finish())
|
||||
.finish()
|
||||
} else {
|
||||
Flex::column()
|
||||
.with_main_axis_size(MainAxisSize::Min)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
||||
.with_child(content)
|
||||
.finish()
|
||||
};
|
||||
Container::new(layout).with_uniform_padding(32.).finish()
|
||||
}
|
||||
|
||||
fn render_header() -> Box<dyn Element> {
|
||||
ConstrainedBox::new(
|
||||
Image::new(
|
||||
bundled_or_fetched_asset!("png/concurrency_limit_header.png"),
|
||||
CacheOption::BySize,
|
||||
)
|
||||
.cover()
|
||||
.with_corner_radius(CornerRadius::with_top(Radius::Pixels(10.)))
|
||||
.finish(),
|
||||
)
|
||||
.with_width(MODAL_WIDTH)
|
||||
.with_height(HEADER_HEIGHT)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for CloudAgentCapacityModal {
|
||||
type Event = CloudAgentCapacityModalEvent;
|
||||
}
|
||||
|
||||
impl View for CloudAgentCapacityModal {
|
||||
fn ui_name() -> &'static str {
|
||||
"CloudAgentCapacityModal"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::handle(app).as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
|
||||
let close_button = appearance
|
||||
.ui_builder()
|
||||
.close_button(BUTTON_DIAMETER, self.state_handles.close_button.clone())
|
||||
.build()
|
||||
.on_click(|ctx, _, _| ctx.dispatch_typed_action(CloudAgentCapacityModalAction::Close))
|
||||
.finish();
|
||||
|
||||
let customer_type = UserWorkspaces::as_ref(app)
|
||||
.current_workspace()
|
||||
.map(|workspace| workspace.billing_metadata.customer_type)
|
||||
.unwrap_or(CustomerType::Free);
|
||||
let can_upgrade = Self::can_upgrade(customer_type, self.variant);
|
||||
|
||||
let mut modal = Stack::new();
|
||||
modal.add_child(
|
||||
Container::new(
|
||||
ConstrainedBox::new(
|
||||
Flex::column()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_child(Self::render_header())
|
||||
.with_child(
|
||||
Expanded::new(1., self.render_content(customer_type, app)).finish(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_width(MODAL_WIDTH)
|
||||
.with_height(if can_upgrade {
|
||||
MODAL_HEIGHT
|
||||
} else {
|
||||
COMPACT_MODAL_HEIGHT
|
||||
})
|
||||
.finish(),
|
||||
)
|
||||
.with_background_color(blended_colors::neutral_1(theme))
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(10.)))
|
||||
.with_drop_shadow(DropShadow::default())
|
||||
.finish(),
|
||||
);
|
||||
modal.add_positioned_child(
|
||||
close_button,
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(-8., 8.),
|
||||
ParentOffsetBounds::ParentByPosition,
|
||||
ParentAnchor::TopRight,
|
||||
ChildAnchor::TopRight,
|
||||
),
|
||||
);
|
||||
|
||||
let mut stack = Stack::new();
|
||||
stack.add_positioned_child(
|
||||
modal.finish(),
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(0., 0.),
|
||||
ParentOffsetBounds::WindowByPosition,
|
||||
ParentAnchor::Center,
|
||||
ChildAnchor::Center,
|
||||
),
|
||||
);
|
||||
|
||||
// Semi-transparent backdrop overlay
|
||||
Container::new(Align::new(stack.finish()).finish())
|
||||
.with_background(Fill::Solid(ColorU::new(97, 97, 97, 255)).with_opacity(50))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for CloudAgentCapacityModal {
|
||||
type Action = CloudAgentCapacityModalAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
CloudAgentCapacityModalAction::Close => {
|
||||
send_telemetry_from_ctx!(TelemetryEvent::CloudAgentCapacityModalDismissed, ctx);
|
||||
ctx.emit(CloudAgentCapacityModalEvent::Close);
|
||||
}
|
||||
CloudAgentCapacityModalAction::Upgrade => {
|
||||
if let Some(upgrade_url) = self.cta_url(ctx) {
|
||||
send_telemetry_from_ctx!(
|
||||
TelemetryEvent::CloudAgentCapacityModalUpgradeClicked,
|
||||
ctx
|
||||
);
|
||||
ctx.open_url(&upgrade_url);
|
||||
ctx.emit(CloudAgentCapacityModalEvent::Close);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub enum CloudAgentCapacityModalEvent {
|
||||
Close,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum CloudAgentCapacityModalAction {
|
||||
Close,
|
||||
Upgrade,
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
use crate::ui_components::blended_colors;
|
||||
use crate::ui_components::icons::Icon;
|
||||
use crate::view_components::action_button::{ActionButton, ActionButtonTheme};
|
||||
use asset_macro::bundled_or_fetched_asset;
|
||||
use pathfinder_color::ColorU;
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
use warp_core::ui::theme::Fill;
|
||||
use warpui::elements::{
|
||||
Align, Border, CacheOption, ChildAnchor, ConstrainedBox, Container, CornerRadius,
|
||||
CrossAxisAlignment, DropShadow, Flex, FormattedTextElement, Image, MainAxisAlignment,
|
||||
MainAxisSize, MouseStateHandle, OffsetPositioning, ParentAnchor, ParentElement,
|
||||
ParentOffsetBounds, Radius, Stack, Text,
|
||||
};
|
||||
use warpui::fonts::Weight;
|
||||
use warpui::keymap::FixedBinding;
|
||||
use warpui::presenter::ChildView;
|
||||
use warpui::ui_components::components::UiComponent;
|
||||
use warpui::{
|
||||
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
|
||||
};
|
||||
|
||||
/// White button theme for the Codex modal CTA.
|
||||
struct WhiteButtonTheme;
|
||||
|
||||
impl ActionButtonTheme for WhiteButtonTheme {
|
||||
fn background(&self, hovered: bool, _appearance: &Appearance) -> Option<Fill> {
|
||||
if hovered {
|
||||
Some(Fill::Solid(ColorU::new(230, 230, 230, 255)))
|
||||
} else {
|
||||
Some(Fill::Solid(ColorU::new(255, 255, 255, 255)))
|
||||
}
|
||||
}
|
||||
|
||||
fn text_color(
|
||||
&self,
|
||||
_hovered: bool,
|
||||
_background: Option<Fill>,
|
||||
_appearance: &Appearance,
|
||||
) -> ColorU {
|
||||
ColorU::new(0, 0, 0, 255)
|
||||
}
|
||||
}
|
||||
|
||||
const BUTTON_DIAMETER: f32 = 20.;
|
||||
const MODAL_HEIGHT: f32 = 395.;
|
||||
const LEFT_PANEL_WIDTH: f32 = 330.;
|
||||
const RIGHT_PANEL_WIDTH: f32 = 325.;
|
||||
|
||||
pub fn init(app: &mut AppContext) {
|
||||
use warpui::keymap::macros::*;
|
||||
|
||||
app.register_fixed_bindings([FixedBinding::new(
|
||||
"escape",
|
||||
CodexModalAction::Close,
|
||||
id!("CodexModal"),
|
||||
)]);
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct StateHandles {
|
||||
close_button: MouseStateHandle,
|
||||
}
|
||||
|
||||
pub struct CodexModal {
|
||||
state_handles: StateHandles,
|
||||
cta_button: ViewHandle<ActionButton>,
|
||||
}
|
||||
|
||||
impl CodexModal {
|
||||
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
|
||||
let cta_button = ctx.add_view(|_| {
|
||||
ActionButton::new("Use latest codex model", WhiteButtonTheme)
|
||||
.with_icon(Icon::OpenAILogo)
|
||||
.with_full_width(true)
|
||||
.on_click(|ctx| {
|
||||
ctx.dispatch_typed_action(CodexModalAction::UseCodex);
|
||||
})
|
||||
});
|
||||
|
||||
CodexModal {
|
||||
state_handles: Default::default(),
|
||||
cta_button,
|
||||
}
|
||||
}
|
||||
|
||||
fn render_new_badge(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
let theme = appearance.theme();
|
||||
// Magenta/pink color for the badge
|
||||
let magenta: ColorU = theme.terminal_colors().normal.magenta.into();
|
||||
Container::new(
|
||||
Text::new("New", appearance.ui_font_family(), 12.)
|
||||
.with_color(magenta)
|
||||
.finish(),
|
||||
)
|
||||
.with_vertical_padding(4.)
|
||||
.with_horizontal_padding(10.)
|
||||
.with_background(Fill::Solid(magenta).with_opacity(15))
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(12.)))
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_left_panel(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
|
||||
// "New" badge
|
||||
let new_badge = self.render_new_badge(appearance);
|
||||
|
||||
// Title
|
||||
let title = FormattedTextElement::from_str(
|
||||
"Use Codex models in Warp",
|
||||
appearance.ui_font_family(),
|
||||
24.,
|
||||
)
|
||||
.with_color(blended_colors::text_main(
|
||||
theme,
|
||||
blended_colors::neutral_1(theme),
|
||||
))
|
||||
.with_weight(Weight::Bold)
|
||||
.finish();
|
||||
|
||||
// Description - first paragraph
|
||||
let description_1 = FormattedTextElement::from_str(
|
||||
"Codex is OpenAI's most advanced agentic coding model for real-world engineering.",
|
||||
appearance.ui_font_family(),
|
||||
14.,
|
||||
)
|
||||
.with_color(blended_colors::text_sub(
|
||||
theme,
|
||||
blended_colors::neutral_1(theme),
|
||||
))
|
||||
.finish();
|
||||
|
||||
// Description - second paragraph
|
||||
let description_2 = FormattedTextElement::from_str(
|
||||
"Use Codex directly in Oz and leverage \
|
||||
features like in-app code review, agent session sharing and file editing.",
|
||||
appearance.ui_font_family(),
|
||||
14.,
|
||||
)
|
||||
.with_color(blended_colors::text_sub(
|
||||
theme,
|
||||
blended_colors::neutral_1(theme),
|
||||
))
|
||||
.finish();
|
||||
|
||||
// Left panel content
|
||||
Container::new(
|
||||
Flex::column()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
||||
.with_child(
|
||||
Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
||||
.with_child(Container::new(new_badge).with_margin_bottom(16.).finish())
|
||||
.with_child(Container::new(title).with_margin_bottom(16.).finish())
|
||||
.with_child(
|
||||
Container::new(description_1)
|
||||
.with_margin_bottom(12.)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(description_2)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(ChildView::new(&self.cta_button).finish())
|
||||
.finish(),
|
||||
)
|
||||
.with_background_color(blended_colors::neutral_1(theme))
|
||||
.with_corner_radius(CornerRadius::with_left(Radius::Pixels(10.)))
|
||||
.with_uniform_padding(24.)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_right_panel(&self) -> Box<dyn Element> {
|
||||
ConstrainedBox::new(
|
||||
Image::new(
|
||||
bundled_or_fetched_asset!("png/codex_integration.png"),
|
||||
CacheOption::BySize,
|
||||
)
|
||||
.with_corner_radius(CornerRadius::with_right(Radius::Pixels(10.)))
|
||||
.finish(),
|
||||
)
|
||||
.with_width(RIGHT_PANEL_WIDTH)
|
||||
.with_height(MODAL_HEIGHT)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for CodexModal {
|
||||
type Event = CodexModalEvent;
|
||||
}
|
||||
|
||||
impl View for CodexModal {
|
||||
fn ui_name() -> &'static str {
|
||||
"CodexModal"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
|
||||
// Close button
|
||||
let close_button = appearance
|
||||
.ui_builder()
|
||||
.close_button(BUTTON_DIAMETER, self.state_handles.close_button.clone())
|
||||
.build()
|
||||
.on_click(|ctx, _, _| ctx.dispatch_typed_action(CodexModalAction::Close))
|
||||
.finish();
|
||||
|
||||
// Modal with two panels
|
||||
let mut modal = Stack::new();
|
||||
modal.add_child(
|
||||
Container::new(
|
||||
ConstrainedBox::new(
|
||||
Flex::row()
|
||||
.with_child(
|
||||
ConstrainedBox::new(self.render_left_panel(app))
|
||||
.with_width(LEFT_PANEL_WIDTH)
|
||||
.with_height(MODAL_HEIGHT)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(self.render_right_panel())
|
||||
.finish(),
|
||||
)
|
||||
.with_width(LEFT_PANEL_WIDTH + RIGHT_PANEL_WIDTH)
|
||||
.with_height(MODAL_HEIGHT)
|
||||
.finish(),
|
||||
)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(10.)))
|
||||
.with_border(Border::all(1.).with_border_fill(theme.outline()))
|
||||
.with_drop_shadow(DropShadow::default())
|
||||
.finish(),
|
||||
);
|
||||
modal.add_positioned_child(
|
||||
close_button,
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(-8., 8.),
|
||||
ParentOffsetBounds::ParentByPosition,
|
||||
ParentAnchor::TopRight,
|
||||
ChildAnchor::TopRight,
|
||||
),
|
||||
);
|
||||
|
||||
// Center the modal in the window
|
||||
let mut stack = Stack::new();
|
||||
stack.add_positioned_child(
|
||||
modal.finish(),
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(0., 0.),
|
||||
ParentOffsetBounds::WindowByPosition,
|
||||
ParentAnchor::Center,
|
||||
ChildAnchor::Center,
|
||||
),
|
||||
);
|
||||
|
||||
// Background overlay
|
||||
Container::new(Align::new(stack.finish()).finish())
|
||||
.with_background(Fill::Solid(ColorU::new(97, 97, 97, 255)).with_opacity(50))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for CodexModal {
|
||||
type Action = CodexModalAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
CodexModalAction::Close => {
|
||||
ctx.emit(CodexModalEvent::Close);
|
||||
}
|
||||
CodexModalAction::UseCodex => {
|
||||
ctx.emit(CodexModalEvent::UseCodex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub enum CodexModalEvent {
|
||||
Close,
|
||||
UseCodex,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub enum CodexModalAction {
|
||||
Close,
|
||||
UseCodex,
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
use crate::ai::active_agent_views_model::ActiveAgentViewsModel;
|
||||
use crate::ai::active_agent_views_model::ConversationOrTaskId;
|
||||
use crate::ai::agent_conversations_model::ConversationOrTask;
|
||||
use crate::ai::conversation_status_ui::{render_status_element, STATUS_ELEMENT_PADDING};
|
||||
use crate::appearance::Appearance;
|
||||
use crate::drive::sharing::dialog::SharingDialog;
|
||||
use crate::menu::Menu;
|
||||
use crate::ui_components::icons::Icon;
|
||||
use crate::ui_components::menu_button::{icon_button_with_context_menu, MenuDirection};
|
||||
use crate::util::time_format::format_approx_duration_from_now_utc;
|
||||
use crate::util::truncation::truncate_from_end;
|
||||
use crate::workspace::view::conversation_list::view::ConversationListViewAction;
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use warp_core::ui::color::coloru_with_opacity;
|
||||
use warp_core::ui::theme::color::internal_colors;
|
||||
use warp_util::path::user_friendly_path;
|
||||
use warpui::elements::{
|
||||
AnchorPair, ChildAnchor, ChildView, ConstrainedBox, Container, CornerRadius,
|
||||
CrossAxisAlignment, DispatchEventResult, Element, EventHandler, Flex, Highlight, Hoverable,
|
||||
MainAxisAlignment, MainAxisSize, MouseInBehavior, MouseStateHandle, OffsetPositioning,
|
||||
OffsetType, ParentAnchor, ParentElement, ParentOffsetBounds, PositionedElementOffsetBounds,
|
||||
PositioningAxis, Radius, SavePosition, Shrinkable, Stack, Text, XAxisAnchor, YAxisAnchor,
|
||||
};
|
||||
use warpui::fonts::{Properties, Weight};
|
||||
use warpui::platform::Cursor;
|
||||
use warpui::text_layout::TextStyle;
|
||||
use warpui::ui_components::components::{UiComponent, UiComponentStyles};
|
||||
use warpui::{AppContext, SingletonEntity, ViewHandle};
|
||||
|
||||
/// Maximum length for tooltip text before truncation
|
||||
const MAX_TOOLTIP_LENGTH: usize = 80;
|
||||
|
||||
/// Spacing between icon and title
|
||||
const ICON_SPACING: f32 = 4.;
|
||||
|
||||
/// Offset for the sharing dialog from the item row
|
||||
const DIALOG_OFFSET_PIXELS: f32 = -16.;
|
||||
|
||||
/// Generate a position ID for a conversation list item
|
||||
fn conversation_item_position_id(id: &ConversationOrTaskId) -> String {
|
||||
match id {
|
||||
ConversationOrTaskId::ConversationId(conv_id) => {
|
||||
format!("conversation_list_item_{conv_id}")
|
||||
}
|
||||
ConversationOrTaskId::TaskId(task_id) => format!("conversation_list_task_{task_id}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimum height for static list items (section headers, StartNewConversation).
|
||||
/// Ensures UniformList uses consistent item heights (and doesn't clip any items).
|
||||
pub const STATIC_ITEM_MIN_HEIGHT: f32 = 42.;
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct ItemState {
|
||||
pub mouse_state: MouseStateHandle,
|
||||
pub overflow_button_state: MouseStateHandle,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum OverflowMenuDisplay {
|
||||
Closed,
|
||||
/// Menu was opened from the kebab button.
|
||||
OpenAtKebab,
|
||||
/// Menu was opened from a right click (at the click position).
|
||||
OpenAtRightClickPosition,
|
||||
}
|
||||
|
||||
pub struct ItemProps<'a> {
|
||||
pub conversation: &'a ConversationOrTask<'a>,
|
||||
pub highlight_indices: Option<&'a Vec<usize>>,
|
||||
pub is_selected: bool,
|
||||
pub is_focused_conversation: bool,
|
||||
pub index: usize,
|
||||
pub state: &'a ItemState,
|
||||
pub overflow_menu: &'a ViewHandle<Menu<ConversationListViewAction>>,
|
||||
pub overflow_menu_display: OverflowMenuDisplay,
|
||||
pub conversation_id: ConversationOrTaskId,
|
||||
pub sharing_dialog: &'a ViewHandle<SharingDialog>,
|
||||
pub is_share_dialog_open: bool,
|
||||
pub list_position_id: &'a str,
|
||||
pub tooltip_opens_right: bool,
|
||||
}
|
||||
|
||||
pub struct StaticItemProps<'a> {
|
||||
pub is_selected: bool,
|
||||
pub index: usize,
|
||||
pub state: &'a ItemState,
|
||||
}
|
||||
|
||||
pub fn render_static_item(props: StaticItemProps<'_>, app: &AppContext) -> Box<dyn Element> {
|
||||
let StaticItemProps {
|
||||
is_selected,
|
||||
index,
|
||||
state,
|
||||
} = props;
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
|
||||
let icon_color = theme.main_text_color(theme.background());
|
||||
let icon = Container::new(
|
||||
ConstrainedBox::new(Icon::Plus.to_warpui_icon(icon_color).finish())
|
||||
.with_width(appearance.ui_font_size())
|
||||
.with_height(appearance.ui_font_size())
|
||||
.finish(),
|
||||
)
|
||||
.with_uniform_padding(STATUS_ELEMENT_PADDING)
|
||||
.with_background(coloru_with_opacity(icon_color.into(), 10))
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
|
||||
.finish();
|
||||
|
||||
let title_text = Text::new_inline(
|
||||
"New conversation",
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_size() + 2.,
|
||||
)
|
||||
.with_color(theme.main_text_color(theme.background()).into())
|
||||
.finish();
|
||||
|
||||
let row = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_spacing(ICON_SPACING)
|
||||
.with_child(icon)
|
||||
.with_child(title_text)
|
||||
.finish();
|
||||
|
||||
let hoverable = Hoverable::new(state.mouse_state.clone(), move |_| {
|
||||
let mut container = Container::new(row).with_horizontal_padding(12.);
|
||||
if is_selected {
|
||||
container = container.with_background(theme.surface_overlay_1());
|
||||
}
|
||||
container.finish()
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(ConversationListViewAction::NewConversationInNewTab);
|
||||
});
|
||||
|
||||
EventHandler::new(
|
||||
ConstrainedBox::new(hoverable.finish())
|
||||
.with_min_height(STATIC_ITEM_MIN_HEIGHT)
|
||||
.finish(),
|
||||
)
|
||||
.on_mouse_in(
|
||||
move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(ConversationListViewAction::SetSelectedIndex(index));
|
||||
DispatchEventResult::PropagateToParent
|
||||
},
|
||||
Some(MouseInBehavior {
|
||||
fire_on_synthetic_events: false,
|
||||
fire_when_covered: true,
|
||||
}),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
|
||||
pub fn render_item(props: ItemProps<'_>, app: &AppContext) -> Box<dyn Element> {
|
||||
let ItemProps {
|
||||
conversation,
|
||||
highlight_indices,
|
||||
is_selected,
|
||||
is_focused_conversation,
|
||||
index,
|
||||
state,
|
||||
overflow_menu,
|
||||
overflow_menu_display,
|
||||
conversation_id,
|
||||
sharing_dialog,
|
||||
is_share_dialog_open,
|
||||
list_position_id,
|
||||
tooltip_opens_right,
|
||||
} = props;
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
let ui_builder = appearance.ui_builder().clone();
|
||||
let font_family = appearance.ui_font_family();
|
||||
let font_size = appearance.ui_font_size();
|
||||
|
||||
let title_font_size = font_size + 2.;
|
||||
let mut title_text = Text::new_inline(conversation.title(app), font_family, title_font_size)
|
||||
.with_color(theme.main_text_color(theme.background()).into());
|
||||
|
||||
if let Some(indices) = highlight_indices {
|
||||
if !indices.is_empty() {
|
||||
let highlight = Highlight::new()
|
||||
.with_properties(Properties::default().weight(Weight::Bold))
|
||||
.with_text_style(
|
||||
TextStyle::new()
|
||||
.with_foreground_color(theme.main_text_color(theme.background()).into())
|
||||
.with_background_color(
|
||||
internal_colors::accent_overlay_3(theme).into_solid(),
|
||||
),
|
||||
);
|
||||
title_text = title_text.with_single_highlight(highlight, indices.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let status_element_size = font_size + STATUS_ELEMENT_PADDING * 2.;
|
||||
let icon_element: Box<dyn Element> = if conversation.is_ambient_agent_conversation() {
|
||||
ConstrainedBox::new(
|
||||
Icon::Cloud
|
||||
.to_warpui_icon(theme.sub_text_color(theme.background()))
|
||||
.finish(),
|
||||
)
|
||||
.with_width(status_element_size)
|
||||
.with_height(status_element_size)
|
||||
.finish()
|
||||
} else {
|
||||
render_status_element(&conversation.status(app), font_size, appearance)
|
||||
};
|
||||
|
||||
let icon_and_title_row = Shrinkable::new(
|
||||
1.0,
|
||||
Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_spacing(ICON_SPACING)
|
||||
.with_child(icon_element)
|
||||
.with_child(Shrinkable::new(1.0, title_text.finish()).finish())
|
||||
.finish(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
let timestamp = Text::new_inline(
|
||||
format_approx_duration_from_now_utc(conversation.last_updated()),
|
||||
font_family,
|
||||
font_size - 2.,
|
||||
)
|
||||
.with_color(theme.sub_text_color(theme.background()).into())
|
||||
.finish();
|
||||
|
||||
let bottom_row = if let Some(subtext) = format_item_subtext(conversation, app) {
|
||||
let subtext_element = Shrinkable::new(
|
||||
1.0,
|
||||
Text::new_inline(subtext, font_family, title_font_size - 2.)
|
||||
.with_color(theme.sub_text_color(theme.background()).into())
|
||||
.finish(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
Container::new(
|
||||
Flex::row()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::End)
|
||||
.with_child(subtext_element)
|
||||
.with_child(timestamp)
|
||||
.finish(),
|
||||
)
|
||||
.with_padding_left(status_element_size + ICON_SPACING)
|
||||
.finish()
|
||||
} else {
|
||||
// If no subtext, still show timestamp in the bottom row
|
||||
Container::new(
|
||||
Flex::row()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_main_axis_alignment(MainAxisAlignment::End)
|
||||
.with_child(timestamp)
|
||||
.finish(),
|
||||
)
|
||||
.with_padding_left(status_element_size + ICON_SPACING)
|
||||
.finish()
|
||||
};
|
||||
|
||||
let row = Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
||||
.with_child(icon_and_title_row)
|
||||
.with_child(bottom_row)
|
||||
.finish();
|
||||
|
||||
// Use shared logic from ConversationOrTask to determine open action
|
||||
let open_action = conversation.get_open_action(None, app);
|
||||
let title = conversation.title(app);
|
||||
let tooltip_text = truncate_from_end(&title, MAX_TOOLTIP_LENGTH);
|
||||
let overflow_button_state = state.overflow_button_state.clone();
|
||||
let hoverable = Hoverable::new(state.mouse_state.clone(), move |_| {
|
||||
let container = Container::new(row)
|
||||
.with_horizontal_padding(12.)
|
||||
.with_padding_top(8.);
|
||||
|
||||
let container = if is_focused_conversation {
|
||||
container.with_background(theme.surface_overlay_2())
|
||||
} else if is_selected || !matches!(overflow_menu_display, OverflowMenuDisplay::Closed) {
|
||||
container.with_background(theme.surface_overlay_1())
|
||||
} else {
|
||||
container
|
||||
};
|
||||
|
||||
let mut stack = Stack::new().with_child(container.finish());
|
||||
|
||||
// We show the overflow menu button when the item is selected, or the overflow menu is already open.
|
||||
if is_selected || !matches!(overflow_menu_display, OverflowMenuDisplay::Closed) {
|
||||
let button_style = UiComponentStyles::default()
|
||||
.set_background(theme.surface_2().into())
|
||||
.set_border_color(theme.surface_3().into());
|
||||
let menu_direction = if tooltip_opens_right {
|
||||
MenuDirection::Right
|
||||
} else {
|
||||
MenuDirection::Left
|
||||
};
|
||||
let overflow_button = icon_button_with_context_menu(
|
||||
Icon::DotsVertical,
|
||||
move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(ConversationListViewAction::ToggleOverflowMenu {
|
||||
conversation_id,
|
||||
position: None,
|
||||
});
|
||||
},
|
||||
overflow_button_state.clone(),
|
||||
overflow_menu,
|
||||
matches!(overflow_menu_display, OverflowMenuDisplay::OpenAtKebab),
|
||||
menu_direction,
|
||||
Some(Cursor::PointingHand),
|
||||
Some(button_style),
|
||||
appearance,
|
||||
);
|
||||
let (parent_anchor, child_anchor, offset_x) = if tooltip_opens_right {
|
||||
(ParentAnchor::TopRight, ChildAnchor::TopRight, -8.)
|
||||
} else {
|
||||
(ParentAnchor::TopLeft, ChildAnchor::TopLeft, 8.)
|
||||
};
|
||||
let overflow_offset = OffsetPositioning::offset_from_parent(
|
||||
vec2f(offset_x, 6.),
|
||||
ParentOffsetBounds::ParentByPosition,
|
||||
parent_anchor,
|
||||
child_anchor,
|
||||
);
|
||||
// Use add_positioned_child (not overlay) so button stays within item bounds
|
||||
stack.add_positioned_child(overflow_button.finish(), overflow_offset);
|
||||
}
|
||||
|
||||
// Hide the tooltip when the overflow menu is being shown so that they don't overlap.
|
||||
if is_selected && matches!(overflow_menu_display, OverflowMenuDisplay::Closed) {
|
||||
let tooltip = ui_builder.tool_tip(tooltip_text).build().finish();
|
||||
let (parent_anchor, child_anchor, offset_x) = if tooltip_opens_right {
|
||||
(ParentAnchor::MiddleRight, ChildAnchor::MiddleLeft, 4.)
|
||||
} else {
|
||||
(ParentAnchor::MiddleLeft, ChildAnchor::MiddleRight, -4.)
|
||||
};
|
||||
let tooltip_offset = OffsetPositioning::offset_from_parent(
|
||||
vec2f(offset_x, 0.),
|
||||
ParentOffsetBounds::WindowByPosition,
|
||||
parent_anchor,
|
||||
child_anchor,
|
||||
);
|
||||
stack.add_positioned_overlay_child(tooltip, tooltip_offset);
|
||||
}
|
||||
stack.finish()
|
||||
})
|
||||
.on_right_click({
|
||||
let list_position_id = list_position_id.to_string();
|
||||
move |ctx, _, position| {
|
||||
let Some(parent_bounds) = ctx.element_position_by_id(&list_position_id) else {
|
||||
log::warn!("Could not retreive the position of the conversation list for overflow menu display.");
|
||||
return;
|
||||
};
|
||||
|
||||
let offset = position - parent_bounds.origin();
|
||||
ctx.dispatch_typed_action(ConversationListViewAction::ToggleOverflowMenu {
|
||||
conversation_id,
|
||||
position: Some(offset),
|
||||
});
|
||||
}
|
||||
})
|
||||
.with_defer_events_to_children();
|
||||
|
||||
let hoverable_element = if open_action.is_some() {
|
||||
hoverable
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(ConversationListViewAction::OpenItem {
|
||||
id: conversation_id,
|
||||
});
|
||||
})
|
||||
.finish()
|
||||
} else {
|
||||
hoverable.finish()
|
||||
};
|
||||
|
||||
let event_handler = EventHandler::new(hoverable_element)
|
||||
.on_mouse_in(
|
||||
move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(ConversationListViewAction::SetSelectedIndex(index));
|
||||
DispatchEventResult::PropagateToParent
|
||||
},
|
||||
Some(MouseInBehavior {
|
||||
fire_on_synthetic_events: false,
|
||||
fire_when_covered: true,
|
||||
}),
|
||||
)
|
||||
.finish();
|
||||
|
||||
// Wrap in a stack to support the sharing dialog overlay
|
||||
let position_id = conversation_item_position_id(&conversation_id);
|
||||
let mut item_stack = Stack::new().with_child(event_handler);
|
||||
|
||||
// Add the sharing dialog as a positioned overlay when open for this item
|
||||
if is_share_dialog_open {
|
||||
// Position the dialog to the right of the item row
|
||||
item_stack.add_positioned_overlay_child(
|
||||
ChildView::new(sharing_dialog).finish(),
|
||||
OffsetPositioning::from_axes(
|
||||
PositioningAxis::relative_to_stack_child(
|
||||
&position_id,
|
||||
PositionedElementOffsetBounds::WindowBySize,
|
||||
OffsetType::Pixel(DIALOG_OFFSET_PIXELS),
|
||||
AnchorPair::new(XAxisAnchor::Right, XAxisAnchor::Left),
|
||||
),
|
||||
PositioningAxis::relative_to_stack_child(
|
||||
&position_id,
|
||||
PositionedElementOffsetBounds::WindowByPosition,
|
||||
OffsetType::Pixel(DIALOG_OFFSET_PIXELS),
|
||||
AnchorPair::new(YAxisAnchor::Middle, YAxisAnchor::Middle),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
SavePosition::new(item_stack.finish(), &position_id).finish()
|
||||
}
|
||||
|
||||
/// Returns the secondary label for a conversation list item:
|
||||
/// - For local conversations: the working directory.
|
||||
/// - For tasks: the source (Linear, Slack, CLI, etc.)
|
||||
fn format_item_subtext(conversation: &ConversationOrTask, app: &AppContext) -> Option<String> {
|
||||
match conversation {
|
||||
ConversationOrTask::Task(task) => {
|
||||
task.source.as_ref().map(|s| s.display_name().to_string())
|
||||
}
|
||||
ConversationOrTask::Conversation(metadata) => {
|
||||
// If this conversation is active (with an expanded agent view),
|
||||
// we use the terminal session's live working directory.
|
||||
let live_pwd = ActiveAgentViewsModel::as_ref(app)
|
||||
.get_active_session_for_conversation(metadata.nav_data.id, app)
|
||||
.and_then(|session| session.as_ref(app).current_working_directory().cloned());
|
||||
|
||||
let pwd = live_pwd.or_else(|| metadata.nav_data.initial_working_directory.clone());
|
||||
pwd.map(|pwd| {
|
||||
let home_dir = dirs::home_dir().and_then(|p| p.to_str().map(String::from));
|
||||
user_friendly_path(&pwd, home_dir.as_deref()).into_owned()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
mod item;
|
||||
pub mod view;
|
||||
mod view_model;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,194 @@
|
||||
use crate::ai::active_agent_views_model::ConversationOrTaskId;
|
||||
use crate::ai::agent_conversations_model::{
|
||||
AgentConversationsModel, AgentConversationsModelEvent, AgentManagementFilters, ArtifactFilter,
|
||||
ConversationOrTask, CreatedOnFilter, CreatorFilter, OwnerFilter, SessionStatus, SourceFilter,
|
||||
StatusFilter,
|
||||
};
|
||||
use fuzzy_match::match_indices_case_insensitive;
|
||||
use warpui::{AppContext, Entity, ModelContext, ModelHandle, SingletonEntity};
|
||||
|
||||
pub struct ConversationListViewModelEvent;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ConversationEntry {
|
||||
pub id: ConversationOrTaskId,
|
||||
pub highlight_indices: Vec<usize>,
|
||||
}
|
||||
|
||||
pub struct ConversationListViewModel {
|
||||
conversations_model: ModelHandle<AgentConversationsModel>,
|
||||
cached_conversation_or_task_ids: Vec<ConversationOrTaskId>,
|
||||
filtered_items: Vec<ConversationEntry>,
|
||||
search_query: String,
|
||||
}
|
||||
|
||||
impl Entity for ConversationListViewModel {
|
||||
type Event = ConversationListViewModelEvent;
|
||||
}
|
||||
|
||||
impl ConversationListViewModel {
|
||||
pub fn new(ctx: &mut ModelContext<Self>) -> Self {
|
||||
let conversations_model = AgentConversationsModel::handle(ctx);
|
||||
|
||||
ctx.subscribe_to_model(&conversations_model, |me, event, ctx| {
|
||||
match event {
|
||||
// These events change the set of items in the list, so we need
|
||||
// to rebuild the cached ID list.
|
||||
AgentConversationsModelEvent::ConversationsLoaded
|
||||
| AgentConversationsModelEvent::NewTasksReceived
|
||||
| AgentConversationsModelEvent::TasksUpdated
|
||||
| AgentConversationsModelEvent::TaskManuallyOpened => {
|
||||
me.refresh_cached_items(ctx);
|
||||
}
|
||||
// Status changes don't affect the set of IDs (status is read
|
||||
// at render time via get_item_by_id); just signal a re-render.
|
||||
AgentConversationsModelEvent::ConversationUpdated => {
|
||||
ctx.emit(ConversationListViewModelEvent);
|
||||
}
|
||||
// Artifact updates don't affect the conversation list
|
||||
AgentConversationsModelEvent::ConversationArtifactsUpdated { .. } => {}
|
||||
}
|
||||
});
|
||||
|
||||
let mut model = Self {
|
||||
conversations_model,
|
||||
cached_conversation_or_task_ids: Vec::new(),
|
||||
filtered_items: Vec::new(),
|
||||
search_query: String::new(),
|
||||
};
|
||||
model.refresh_cached_items(ctx);
|
||||
model
|
||||
}
|
||||
|
||||
/// Rebuilds the cached list of IDs from the current task/conversation set.
|
||||
///
|
||||
/// The cache stores only `ConversationOrTaskId`s; per-item fields like
|
||||
/// status, title, and last-updated are read fresh at render time via
|
||||
/// `get_item_by_id`. Callers should therefore avoid invoking this on
|
||||
/// events that only mutate per-item state (e.g. `ConversationUpdated`);
|
||||
/// emitting `ConversationListViewModelEvent` is sufficient there.
|
||||
fn refresh_cached_items(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
let model = self.conversations_model.as_ref(ctx);
|
||||
self.cached_conversation_or_task_ids = model
|
||||
.get_tasks_and_conversations(
|
||||
&AgentManagementFilters {
|
||||
owners: OwnerFilter::PersonalOnly,
|
||||
status: StatusFilter::All,
|
||||
source: SourceFilter::All,
|
||||
created_on: CreatedOnFilter::All,
|
||||
creator: CreatorFilter::All,
|
||||
artifact: ArtifactFilter::All,
|
||||
environment: Default::default(),
|
||||
harness: Default::default(),
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
// Expired and Unavailable ambient agent sessions can't be opened, so we filter them out.
|
||||
// Regular conversations have None session_status
|
||||
.filter(|item| {
|
||||
item.get_session_status()
|
||||
.is_none_or(|status| status == SessionStatus::Available)
|
||||
})
|
||||
// Only show user-initiated sources (Slack, Linear, Interactive) or tasks that have
|
||||
// been manually opened from the management page.
|
||||
.filter(|item| {
|
||||
let is_user_initiated = item.source().is_some_and(|s| s.is_user_initiated());
|
||||
let is_manually_opened = match item {
|
||||
ConversationOrTask::Task(task) => model.is_task_manually_opened(&task.task_id),
|
||||
ConversationOrTask::Conversation(_) => false,
|
||||
};
|
||||
is_user_initiated || is_manually_opened
|
||||
})
|
||||
.map(|item| match item {
|
||||
ConversationOrTask::Task(task) => ConversationOrTaskId::TaskId(task.task_id),
|
||||
ConversationOrTask::Conversation(conv) => {
|
||||
ConversationOrTaskId::ConversationId(conv.nav_data.id)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
self.apply_search_filter(ctx);
|
||||
ctx.emit(ConversationListViewModelEvent);
|
||||
}
|
||||
|
||||
pub fn set_search_query(&mut self, query: String, ctx: &mut ModelContext<Self>) {
|
||||
if query == self.search_query {
|
||||
return;
|
||||
}
|
||||
|
||||
self.search_query = query;
|
||||
self.apply_search_filter(ctx);
|
||||
ctx.emit(ConversationListViewModelEvent);
|
||||
}
|
||||
|
||||
fn apply_search_filter(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
let search_query = self.search_query.trim().to_lowercase();
|
||||
let conversations_model = self.conversations_model.as_ref(ctx);
|
||||
|
||||
if search_query.is_empty() {
|
||||
self.filtered_items = self
|
||||
.cached_conversation_or_task_ids
|
||||
.iter()
|
||||
.map(|id| ConversationEntry {
|
||||
id: *id,
|
||||
highlight_indices: vec![],
|
||||
})
|
||||
.collect();
|
||||
} else {
|
||||
let mut matched_items: Vec<(i64, ConversationEntry)> = self
|
||||
.cached_conversation_or_task_ids
|
||||
.iter()
|
||||
.filter_map(|id| {
|
||||
let item = match id {
|
||||
ConversationOrTaskId::TaskId(task_id) => {
|
||||
conversations_model.get_task(task_id)
|
||||
}
|
||||
ConversationOrTaskId::ConversationId(conv_id) => {
|
||||
conversations_model.get_conversation(conv_id)
|
||||
}
|
||||
}?;
|
||||
|
||||
match_indices_case_insensitive(&item.title(ctx), &search_query).map(|result| {
|
||||
(
|
||||
result.score,
|
||||
ConversationEntry {
|
||||
id: *id,
|
||||
highlight_indices: result.matched_indices,
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
matched_items.sort_by(|a, b| b.0.cmp(&a.0));
|
||||
self.filtered_items = matched_items.into_iter().map(|(_, item)| item).collect();
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the total number of conversations in the model before any filtering is applied.
|
||||
pub fn unfiltered_item_count(&self) -> usize {
|
||||
self.cached_conversation_or_task_ids.len()
|
||||
}
|
||||
|
||||
/// Returns the filtered items with their highlight indices.
|
||||
pub fn filtered_items(&self) -> &[ConversationEntry] {
|
||||
&self.filtered_items
|
||||
}
|
||||
|
||||
/// Look up a conversation or task by ID.
|
||||
pub fn get_item_by_id<'a>(
|
||||
&self,
|
||||
id: &ConversationOrTaskId,
|
||||
ctx: &'a AppContext,
|
||||
) -> Option<ConversationOrTask<'a>> {
|
||||
let model = self.conversations_model.as_ref(ctx);
|
||||
match id {
|
||||
ConversationOrTaskId::TaskId(task_id) => model.get_task(task_id),
|
||||
ConversationOrTaskId::ConversationId(conv_id) => model.get_conversation(conv_id),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn current_ids(&self) -> impl Iterator<Item = &ConversationOrTaskId> {
|
||||
self.filtered_items.iter().map(|item| &item.id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
use warp_cli::RecoveryMechanism;
|
||||
use warpui::{AppContext, SingletonEntity as _, ViewContext};
|
||||
|
||||
use crate::crash_recovery::CrashRecovery;
|
||||
|
||||
use super::{Workspace, WorkspaceBannerFields};
|
||||
|
||||
pub fn banner_metadata(ctx: &AppContext) -> Option<WorkspaceBannerFields> {
|
||||
let crash_recovery = CrashRecovery::as_ref(ctx);
|
||||
|
||||
let recovery_mechanism = crash_recovery.should_notify_user_about_crash()?;
|
||||
|
||||
match recovery_mechanism {
|
||||
#[cfg(target_os = "linux")]
|
||||
RecoveryMechanism::X11 => Some(WorkspaceBannerFields {
|
||||
banner_type: super::WorkspaceBanner::WaylandCrashRecovery,
|
||||
severity: super::BannerSeverity::Warning,
|
||||
heading: None,
|
||||
description: "We detected a crash during application startup, and adjusted your \
|
||||
settings to use Xwayland for windowing. This can result in blurry text if you \
|
||||
are using fractional scaling."
|
||||
.to_owned(),
|
||||
secondary_button: None,
|
||||
button: Some(super::WorkspaceBannerButtonDetails {
|
||||
text: "Learn More".to_owned(),
|
||||
action: super::WorkspaceAction::DismissWaylandCrashRecoveryBannerAndOpenLink,
|
||||
variant: super::BannerButtonVariant::Outlined,
|
||||
icon: None,
|
||||
more_info_button_action: None,
|
||||
}),
|
||||
}),
|
||||
// We're not showing anything to the user when we recover from a crash
|
||||
// by switching from preferring integrated to dedicated gpu due to the
|
||||
// fact that this recovery mechanism is only used when the user has not
|
||||
// explicitly set their preference.
|
||||
RecoveryMechanism::DedicatedGpu => None,
|
||||
// We don't show any information to the user for the disable OpenGL / force Vulkan recovery
|
||||
// mechanisms. These set of crashes occur before there is a visible window, so any
|
||||
// information surfaced to the user would be unactionable noise that the user would see on
|
||||
// every invocation of Warp.
|
||||
RecoveryMechanism::DisableOpenGL | RecoveryMechanism::ForceVulkan => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(all(enable_crash_recovery, not(target_os = "linux")), allow(unused))]
|
||||
pub fn dismiss_workspace_banner(ctx: &mut ViewContext<Workspace>) {
|
||||
CrashRecovery::handle(ctx).update(ctx, |crash_recovery, ctx| {
|
||||
crash_recovery.handle_user_acknowledged_crash(ctx);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
use crate::ai::{AIRequestUsageModel, AIRequestUsageModelEvent};
|
||||
use crate::auth::AuthStateProvider;
|
||||
use crate::pricing::{PricingInfoModel, PricingInfoModelEvent};
|
||||
use crate::ui_components::blended_colors;
|
||||
use crate::ui_components::icons::Icon;
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
use crate::TelemetryEvent;
|
||||
use asset_macro::bundled_or_fetched_asset;
|
||||
use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
|
||||
use pathfinder_color::ColorU;
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use thousands::Separable;
|
||||
use warp_core::send_telemetry_from_ctx;
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
use warp_core::ui::theme::{Fill, WarpTheme};
|
||||
use warp_graphql::billing::{PlanPricing, StripeSubscriptionPlan};
|
||||
use warpui::elements::{
|
||||
Align, Border, CacheOption, ChildAnchor, ConstrainedBox, Container, CornerRadius,
|
||||
CrossAxisAlignment, DropShadow, Flex, FormattedTextElement, HighlightedHyperlink, Image,
|
||||
MainAxisAlignment, MainAxisSize, MouseStateHandle, OffsetPositioning, ParentAnchor,
|
||||
ParentElement, ParentOffsetBounds, Radius, Stack,
|
||||
};
|
||||
use warpui::fonts::Weight;
|
||||
use warpui::keymap::FixedBinding;
|
||||
use warpui::platform::Cursor;
|
||||
use warpui::ui_components::button::ButtonVariant;
|
||||
use warpui::ui_components::components::{UiComponent, UiComponentStyles};
|
||||
use warpui::{AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext};
|
||||
|
||||
const BUTTON_DIAMETER: f32 = 20.;
|
||||
const MODAL_HEIGHT: f32 = 440.;
|
||||
const LEFT_PANEL_WIDTH: f32 = 360.;
|
||||
const RIGHT_PANEL_WIDTH: f32 = 360.;
|
||||
|
||||
pub fn init(app: &mut AppContext) {
|
||||
use warpui::keymap::macros::*;
|
||||
|
||||
app.register_fixed_bindings([FixedBinding::new(
|
||||
"escape",
|
||||
FreeTierLimitHitModalAction::Close,
|
||||
id!("FreeTierLimitHitModal"),
|
||||
)]);
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct StateHandles {
|
||||
close_button: MouseStateHandle,
|
||||
upgrade_button: MouseStateHandle,
|
||||
}
|
||||
|
||||
pub struct FreeTierLimitHitModal {
|
||||
state_handles: StateHandles,
|
||||
}
|
||||
|
||||
impl FreeTierLimitHitModal {
|
||||
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
|
||||
ctx.subscribe_to_model(
|
||||
&PricingInfoModel::handle(ctx),
|
||||
|_, _, event, ctx| match event {
|
||||
PricingInfoModelEvent::PricingInfoUpdated => {
|
||||
ctx.unsubscribe_to_model(&PricingInfoModel::handle(ctx));
|
||||
ctx.notify();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
ctx.subscribe_to_model(
|
||||
&AIRequestUsageModel::handle(ctx),
|
||||
|_, _, event, ctx| match event {
|
||||
AIRequestUsageModelEvent::RequestUsageUpdated => {
|
||||
ctx.emit(FreeTierLimitHitModalEvent::MaybeOpen);
|
||||
}
|
||||
AIRequestUsageModelEvent::RequestBonusRefunded { .. } => {}
|
||||
},
|
||||
);
|
||||
|
||||
FreeTierLimitHitModal {
|
||||
state_handles: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_upgrade_url(ctx: &ViewContext<Self>) -> String {
|
||||
let auth_state = AuthStateProvider::handle(ctx).as_ref(ctx).get();
|
||||
if let Some(team) = UserWorkspaces::handle(ctx).as_ref(ctx).current_team() {
|
||||
UserWorkspaces::upgrade_link_for_team(team.uid)
|
||||
} else {
|
||||
let user_id = auth_state.user_id().unwrap_or_default();
|
||||
UserWorkspaces::upgrade_link(user_id)
|
||||
}
|
||||
}
|
||||
|
||||
fn get_build_plan_details(app: &AppContext) -> Option<&PlanPricing> {
|
||||
let pricing_model = PricingInfoModel::handle(app).as_ref(app);
|
||||
pricing_model.plan_pricing(&StripeSubscriptionPlan::Build)
|
||||
}
|
||||
|
||||
fn render_checklist_item_dynamic(
|
||||
text: String,
|
||||
appearance: &Appearance,
|
||||
theme: &WarpTheme,
|
||||
) -> Box<dyn Element> {
|
||||
let formatted_text = FormattedText::new([FormattedTextLine::Line(vec![
|
||||
FormattedTextFragment::plain_text(text),
|
||||
])]);
|
||||
Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(
|
||||
Container::new(
|
||||
ConstrainedBox::new(
|
||||
Icon::CheckCircleBroken
|
||||
.to_warpui_icon(Fill::Solid(theme.ansi_fg_green()))
|
||||
.finish(),
|
||||
)
|
||||
.with_width(14.)
|
||||
.with_height(14.)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_right(4.)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
FormattedTextElement::new(
|
||||
formatted_text,
|
||||
14.,
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_family(),
|
||||
blended_colors::text_sub(theme, blended_colors::neutral_1(theme)),
|
||||
HighlightedHyperlink::default(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_left_panel(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::handle(app).as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
|
||||
Container::new(
|
||||
Flex::column()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
||||
.with_child(
|
||||
Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
||||
.with_child(
|
||||
Container::new(
|
||||
FormattedTextElement::from_str(
|
||||
"You’re out of credits",
|
||||
appearance.ui_font_family(),
|
||||
24.,
|
||||
)
|
||||
.with_color(blended_colors::text_main(
|
||||
theme,
|
||||
blended_colors::neutral_1(theme),
|
||||
))
|
||||
.with_weight(Weight::Bold)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_bottom(12.)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
Container::new(
|
||||
FormattedTextElement::from_str(
|
||||
"To continue using AI, please upgrade your plan.",
|
||||
appearance.ui_font_family(),
|
||||
14.,
|
||||
)
|
||||
.with_color(blended_colors::text_sub(
|
||||
theme,
|
||||
blended_colors::neutral_1(theme),
|
||||
))
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_bottom(16.)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
Container::new({
|
||||
let benefits_text = if let Some(plan) = Self::get_build_plan_details(app) {
|
||||
let price = plan.monthly_plan_price_per_month_usd_cents / 100;
|
||||
format!("The Build plan is ${price}/month which includes everything in the free tier plus:")
|
||||
} else {
|
||||
"The Build plan includes everything in the free tier plus:".to_string()
|
||||
};
|
||||
let formatted_text = FormattedText::new([FormattedTextLine::Line(vec![
|
||||
FormattedTextFragment::plain_text(benefits_text),
|
||||
])]);
|
||||
FormattedTextElement::new(
|
||||
formatted_text,
|
||||
14.,
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_family(),
|
||||
blended_colors::text_sub(theme, blended_colors::neutral_1(theme)),
|
||||
HighlightedHyperlink::default(),
|
||||
)
|
||||
.finish()
|
||||
})
|
||||
.with_margin_bottom(8.)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
Container::new({
|
||||
let credits_text = if let Some(plan) = Self::get_build_plan_details(app) {
|
||||
let limit = plan.request_limit.unwrap_or(1500);
|
||||
format!("{} Credits per month", limit.separate_with_commas())
|
||||
} else {
|
||||
"Extended Credits per month".to_string()
|
||||
};
|
||||
Self::render_checklist_item_dynamic(credits_text, appearance, theme)
|
||||
})
|
||||
.with_margin_bottom(8.)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
Container::new(
|
||||
Self::render_checklist_item_dynamic(
|
||||
"Access to frontier OpenAI, Anthropic, and Google models".to_string(),
|
||||
appearance,
|
||||
theme,
|
||||
)
|
||||
)
|
||||
.with_margin_bottom(8.)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
Container::new({
|
||||
let formatted_text = FormattedText::new([FormattedTextLine::Line(vec![
|
||||
FormattedTextFragment::plain_text("Access to "),
|
||||
FormattedTextFragment::hyperlink(
|
||||
"Reload Credits".to_string(),
|
||||
"https://docs.warp.dev/support-and-community/plans-and-billing/add-on-credits".to_string(),
|
||||
),
|
||||
])]);
|
||||
Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(
|
||||
Container::new(
|
||||
ConstrainedBox::new(
|
||||
Icon::CheckCircleBroken
|
||||
.to_warpui_icon(Fill::Solid(theme.ansi_fg_green()))
|
||||
.finish(),
|
||||
)
|
||||
.with_width(14.)
|
||||
.with_height(14.)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_right(4.)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
FormattedTextElement::new(
|
||||
formatted_text,
|
||||
14.,
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_family(),
|
||||
blended_colors::text_sub(theme, blended_colors::neutral_1(theme)),
|
||||
HighlightedHyperlink::default(),
|
||||
)
|
||||
.register_default_click_handlers(|url, ctx, _| {
|
||||
ctx.dispatch_typed_action(FreeTierLimitHitModalAction::OpenUrl(url.url.clone()));
|
||||
})
|
||||
.finish(),
|
||||
)
|
||||
.finish()
|
||||
})
|
||||
.with_margin_bottom(8.)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
Container::new({
|
||||
let formatted_text = FormattedText::new([FormattedTextLine::Line(vec![
|
||||
FormattedTextFragment::hyperlink(
|
||||
"Extended cloud agents access".to_string(),
|
||||
"https://www.warp.dev/oz".to_string(),
|
||||
),
|
||||
])]);
|
||||
Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(
|
||||
Container::new(
|
||||
ConstrainedBox::new(
|
||||
Icon::CheckCircleBroken
|
||||
.to_warpui_icon(Fill::Solid(theme.ansi_fg_green()))
|
||||
.finish(),
|
||||
)
|
||||
.with_width(14.)
|
||||
.with_height(14.)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_right(4.)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
FormattedTextElement::new(
|
||||
formatted_text,
|
||||
14.,
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_family(),
|
||||
blended_colors::text_sub(theme, blended_colors::neutral_1(theme)),
|
||||
HighlightedHyperlink::default(),
|
||||
)
|
||||
.register_default_click_handlers(|url, ctx, _| {
|
||||
ctx.dispatch_typed_action(FreeTierLimitHitModalAction::OpenUrl(url.url.clone()));
|
||||
})
|
||||
.finish(),
|
||||
)
|
||||
.finish()
|
||||
})
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
Align::new(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.button(
|
||||
ButtonVariant::Accent,
|
||||
self.state_handles.upgrade_button.clone(),
|
||||
)
|
||||
.with_style(UiComponentStyles {
|
||||
font_size: Some(14.),
|
||||
height: Some(32.),
|
||||
width: Some(296.),
|
||||
..Default::default()
|
||||
})
|
||||
.with_centered_text_label("Upgrade plan".to_string())
|
||||
.build()
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(FreeTierLimitHitModalAction::OpenUpgrade)
|
||||
})
|
||||
.finish(),
|
||||
)
|
||||
.bottom_left()
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_background_color(blended_colors::neutral_1(theme))
|
||||
.with_corner_radius(CornerRadius::with_left(Radius::Pixels(10.)))
|
||||
.with_uniform_padding(32.)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_right_panel(&self) -> Box<dyn Element> {
|
||||
ConstrainedBox::new(
|
||||
Image::new(
|
||||
bundled_or_fetched_asset!("png/free_tier_to_build.png"),
|
||||
CacheOption::BySize,
|
||||
)
|
||||
.with_corner_radius(CornerRadius::with_right(Radius::Pixels(10.)))
|
||||
.finish(),
|
||||
)
|
||||
.with_width(RIGHT_PANEL_WIDTH)
|
||||
.with_height(MODAL_HEIGHT)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for FreeTierLimitHitModal {
|
||||
type Event = FreeTierLimitHitModalEvent;
|
||||
}
|
||||
|
||||
impl View for FreeTierLimitHitModal {
|
||||
fn ui_name() -> &'static str {
|
||||
"FreeTierLimitHitModal"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::handle(app).as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
|
||||
let close_button = appearance
|
||||
.ui_builder()
|
||||
.close_button(BUTTON_DIAMETER, self.state_handles.close_button.clone())
|
||||
.build()
|
||||
.on_click(|ctx, _, _| ctx.dispatch_typed_action(FreeTierLimitHitModalAction::Close))
|
||||
.finish();
|
||||
|
||||
let mut modal = Stack::new();
|
||||
modal.add_child(
|
||||
Container::new(
|
||||
ConstrainedBox::new(
|
||||
Flex::row()
|
||||
.with_child(
|
||||
ConstrainedBox::new(self.render_left_panel(app))
|
||||
.with_width(LEFT_PANEL_WIDTH)
|
||||
.with_height(MODAL_HEIGHT)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(self.render_right_panel())
|
||||
.finish(),
|
||||
)
|
||||
.with_width(LEFT_PANEL_WIDTH + RIGHT_PANEL_WIDTH)
|
||||
.with_height(MODAL_HEIGHT)
|
||||
.finish(),
|
||||
)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(10.)))
|
||||
.with_border(Border::all(1.).with_border_fill(theme.outline()))
|
||||
.with_drop_shadow(DropShadow::default())
|
||||
.finish(),
|
||||
);
|
||||
modal.add_positioned_child(
|
||||
close_button,
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(-8., 8.),
|
||||
ParentOffsetBounds::ParentByPosition,
|
||||
ParentAnchor::TopRight,
|
||||
ChildAnchor::TopRight,
|
||||
),
|
||||
);
|
||||
|
||||
let mut stack = Stack::new();
|
||||
stack.add_positioned_child(
|
||||
modal.finish(),
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(0., 0.),
|
||||
ParentOffsetBounds::WindowByPosition,
|
||||
ParentAnchor::Center,
|
||||
ChildAnchor::Center,
|
||||
),
|
||||
);
|
||||
|
||||
Container::new(Align::new(stack.finish()).finish())
|
||||
.with_background(Fill::Solid(ColorU::new(97, 97, 97, 255)).with_opacity(50))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for FreeTierLimitHitModal {
|
||||
type Action = FreeTierLimitHitModalAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
FreeTierLimitHitModalAction::Close => {
|
||||
ctx.emit(FreeTierLimitHitModalEvent::Close);
|
||||
|
||||
send_telemetry_from_ctx!(TelemetryEvent::FreeTierLimitHitInterstitialClosed, ctx);
|
||||
}
|
||||
FreeTierLimitHitModalAction::OpenUpgrade => {
|
||||
let upgrade_url = Self::get_upgrade_url(ctx);
|
||||
ctx.open_url(&upgrade_url);
|
||||
ctx.emit(FreeTierLimitHitModalEvent::Close);
|
||||
|
||||
send_telemetry_from_ctx!(
|
||||
TelemetryEvent::FreeTierLimitHitInterstitialUpgradeButtonClicked,
|
||||
ctx
|
||||
);
|
||||
}
|
||||
FreeTierLimitHitModalAction::OpenUrl(url) => {
|
||||
ctx.open_url(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub enum FreeTierLimitHitModalEvent {
|
||||
MaybeOpen,
|
||||
Close,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum FreeTierLimitHitModalAction {
|
||||
Close,
|
||||
OpenUpgrade,
|
||||
OpenUrl(String),
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
pub struct SearchConfig {
|
||||
pub use_regex: bool,
|
||||
pub use_case_sensitivity: bool,
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_family = "wasm"), path = "model.rs")]
|
||||
#[cfg_attr(target_family = "wasm", path = "model_wasm.rs")]
|
||||
pub mod model;
|
||||
pub mod view;
|
||||
@@ -0,0 +1,255 @@
|
||||
use crate::workspace::view::global_search::view::GlobalSearchEvent;
|
||||
use crate::workspace::view::global_search::SearchConfig;
|
||||
use anyhow::Result;
|
||||
use futures::StreamExt as _;
|
||||
use instant::Instant;
|
||||
use num_traits::SaturatingSub;
|
||||
use regex::escape;
|
||||
use std::path::PathBuf;
|
||||
use string_offset::ByteOffset;
|
||||
use warp_ripgrep::search::{Match as RipgrepMatch, Submatch};
|
||||
use warpui::r#async::SpawnedFutureHandle;
|
||||
use warpui::{Entity, ModelContext, ModelSpawner};
|
||||
|
||||
const START_BATCH_AFTER_COUNT: usize = 50;
|
||||
const MAX_BATCH_SIZE: usize = 512;
|
||||
const MAX_BATCH_AGE_MS: u64 = 4000;
|
||||
|
||||
pub struct GlobalSearch {
|
||||
search_handle: Option<SpawnedFutureHandle>,
|
||||
// track the search ID so that we only show results for the current search
|
||||
next_search_id: u32,
|
||||
}
|
||||
|
||||
impl Entity for GlobalSearch {
|
||||
type Event = GlobalSearchEvent;
|
||||
}
|
||||
|
||||
async fn flush_batch(
|
||||
spawner: &ModelSpawner<GlobalSearch>,
|
||||
search_id: u32,
|
||||
batch: &mut Vec<RipgrepMatch>,
|
||||
) {
|
||||
if batch.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let items = std::mem::take(batch);
|
||||
|
||||
let _ = spawner
|
||||
.spawn(move |_me, ctx| {
|
||||
ctx.emit(GlobalSearchEvent::ProgressBatch { search_id, items });
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
impl GlobalSearch {
|
||||
pub fn new() -> Self {
|
||||
GlobalSearch {
|
||||
search_handle: None,
|
||||
next_search_id: 1,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn abort_search(&mut self) {
|
||||
if let Some(handle) = self.search_handle.take() {
|
||||
handle.abort();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run_search(
|
||||
&mut self,
|
||||
pattern: String,
|
||||
roots: Vec<PathBuf>,
|
||||
search_config: SearchConfig,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
if let Some(handle) = self.search_handle.take() {
|
||||
log::info!("GlobalSearch: aborting previous search");
|
||||
handle.abort();
|
||||
}
|
||||
|
||||
let search_id = self.next_search_id;
|
||||
self.next_search_id += 1;
|
||||
|
||||
ctx.emit(GlobalSearchEvent::Started { search_id });
|
||||
|
||||
let spawner = ctx.spawner();
|
||||
let effective_pattern = if search_config.use_regex {
|
||||
pattern
|
||||
} else {
|
||||
escape(&pattern)
|
||||
};
|
||||
let ignore_case = !search_config.use_case_sensitivity;
|
||||
let multiline = effective_pattern.contains('\n');
|
||||
|
||||
let handle = ctx.spawn(
|
||||
async move {
|
||||
Self::run_warp_ripgrep_cli(
|
||||
search_id,
|
||||
effective_pattern,
|
||||
roots,
|
||||
ignore_case,
|
||||
multiline,
|
||||
spawner,
|
||||
)
|
||||
.await
|
||||
},
|
||||
move |_, result, ctx| match result {
|
||||
Ok(total_match_count) => {
|
||||
ctx.emit(GlobalSearchEvent::Completed {
|
||||
search_id,
|
||||
total_match_count,
|
||||
});
|
||||
}
|
||||
Err(err) => {
|
||||
log::error!("GlobalSearch: warp_ripgrep CLI search failed or aborted: {err}");
|
||||
ctx.emit(GlobalSearchEvent::Failed {
|
||||
search_id,
|
||||
error: "Global search failed.".to_string(),
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
self.search_handle = Some(handle);
|
||||
}
|
||||
|
||||
async fn run_warp_ripgrep_cli(
|
||||
search_id: u32,
|
||||
pattern: String,
|
||||
roots: Vec<PathBuf>,
|
||||
ignore_case: bool,
|
||||
multiline: bool,
|
||||
spawner: ModelSpawner<GlobalSearch>,
|
||||
) -> Result<usize> {
|
||||
let roots_display: Vec<_> = roots.iter().map(|r| r.display().to_string()).collect();
|
||||
log::info!(
|
||||
"GlobalSearch: starting warp_ripgrep CLI search with pattern={pattern}, roots={:?}",
|
||||
roots_display
|
||||
);
|
||||
|
||||
let stream =
|
||||
warp_ripgrep::search::search_streaming(&[pattern], &roots, ignore_case, multiline)?;
|
||||
futures::pin_mut!(stream);
|
||||
|
||||
let mut total_match_count: usize = 0;
|
||||
let mut num_unbatched_emitted: usize = 0;
|
||||
let mut batch: Vec<RipgrepMatch> = Vec::new();
|
||||
let mut last_batch_flush_at = Instant::now();
|
||||
|
||||
while let Some(raw_match) = stream.next().await {
|
||||
// Expand each submatch into its own result row (matching
|
||||
// the old per-submatch behavior). Each row gets the line
|
||||
// text trimmed up to that particular submatch.
|
||||
for per_submatch in Self::expand_submatches(raw_match) {
|
||||
total_match_count += 1;
|
||||
|
||||
if num_unbatched_emitted < START_BATCH_AFTER_COUNT {
|
||||
num_unbatched_emitted += 1;
|
||||
|
||||
let _ = spawner
|
||||
.spawn(move |_me, ctx| {
|
||||
ctx.emit(GlobalSearchEvent::Progress {
|
||||
search_id,
|
||||
result: per_submatch,
|
||||
});
|
||||
})
|
||||
.await;
|
||||
} else {
|
||||
batch.push(per_submatch);
|
||||
|
||||
let too_big = batch.len() >= MAX_BATCH_SIZE;
|
||||
let too_old =
|
||||
last_batch_flush_at.elapsed().as_millis() >= MAX_BATCH_AGE_MS as u128;
|
||||
|
||||
if too_big || too_old {
|
||||
flush_batch(&spawner, search_id, &mut batch).await;
|
||||
last_batch_flush_at = Instant::now();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !batch.is_empty() {
|
||||
flush_batch(&spawner, search_id, &mut batch).await;
|
||||
}
|
||||
|
||||
Ok(total_match_count)
|
||||
}
|
||||
|
||||
/// Expand a single ripgrep match (which may contain multiple submatches
|
||||
/// on the same line) into one result per submatch. Each result gets the
|
||||
/// line text trimmed of leading whitespace up to that submatch.
|
||||
fn expand_submatches(m: RipgrepMatch) -> Vec<RipgrepMatch> {
|
||||
if m.submatches.len() <= 1 {
|
||||
return vec![Self::trim_leading_whitespace_for_submatch(
|
||||
&m.line_text,
|
||||
m.file_path,
|
||||
m.line_number,
|
||||
m.submatches.into_iter().next(),
|
||||
)];
|
||||
}
|
||||
|
||||
m.submatches
|
||||
.into_iter()
|
||||
.map(|sub| {
|
||||
Self::trim_leading_whitespace_for_submatch(
|
||||
&m.line_text,
|
||||
m.file_path.clone(),
|
||||
m.line_number,
|
||||
Some(sub),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Trim leading whitespace from a line up to the given submatch,
|
||||
/// adjusting the submatch offset accordingly.
|
||||
fn trim_leading_whitespace_for_submatch(
|
||||
original_line: &str,
|
||||
file_path: PathBuf,
|
||||
line_number: u32,
|
||||
submatch: Option<Submatch>,
|
||||
) -> RipgrepMatch {
|
||||
let submatch_start = submatch
|
||||
.as_ref()
|
||||
.map(|s| s.byte_start)
|
||||
.unwrap_or(ByteOffset::zero());
|
||||
|
||||
let mut leading_trimmed_bytes = ByteOffset::zero();
|
||||
for (byte_index, ch) in original_line.char_indices() {
|
||||
if byte_index >= submatch_start.as_usize() {
|
||||
break;
|
||||
}
|
||||
if !ch.is_ascii_whitespace() {
|
||||
break;
|
||||
}
|
||||
leading_trimmed_bytes += ch.len_utf8();
|
||||
}
|
||||
|
||||
let trimmed_line = original_line[leading_trimmed_bytes.as_usize()..].to_string();
|
||||
|
||||
let submatches = if let Some(sub) = submatch {
|
||||
vec![Submatch {
|
||||
byte_start: sub.byte_start.saturating_sub(&leading_trimmed_bytes),
|
||||
byte_end: sub.byte_end.saturating_sub(&leading_trimmed_bytes),
|
||||
}]
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
RipgrepMatch {
|
||||
file_path,
|
||||
line_number,
|
||||
line_text: trimmed_line,
|
||||
submatches,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for GlobalSearch {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::workspace::view::global_search::view::GlobalSearchEvent;
|
||||
use crate::workspace::view::global_search::SearchConfig;
|
||||
use warpui::{Entity, ModelContext};
|
||||
|
||||
pub struct GlobalSearch {}
|
||||
|
||||
impl Entity for GlobalSearch {
|
||||
type Event = GlobalSearchEvent;
|
||||
}
|
||||
|
||||
impl GlobalSearch {
|
||||
pub fn new() -> Self {
|
||||
GlobalSearch {}
|
||||
}
|
||||
|
||||
pub fn abort_search(&mut self) {}
|
||||
|
||||
pub fn run_search(
|
||||
&mut self,
|
||||
_pattern: String,
|
||||
_root: Vec<PathBuf>,
|
||||
_search_config: SearchConfig,
|
||||
_ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for GlobalSearch {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,79 @@
|
||||
use super::Slide;
|
||||
use crate::server::telemetry::TelemetryEvent;
|
||||
use std::rc::Rc;
|
||||
use warpui::ViewContext;
|
||||
|
||||
/// A callback function for custom CTA button actions.
|
||||
type CustomCallback<S> = Rc<dyn Fn(&mut ViewContext<super::LaunchModal<S>>)>;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct CTAButton<S: Slide> {
|
||||
pub label: String,
|
||||
pub action: CTAButtonAction<S>,
|
||||
#[allow(dead_code)]
|
||||
pub telemetry_event: Option<TelemetryEvent>,
|
||||
}
|
||||
|
||||
impl<S: Slide> CTAButton<S> {
|
||||
// Constructor methods
|
||||
pub fn next_slide(next: S, label: impl Into<String>) -> Self {
|
||||
Self {
|
||||
label: label.into(),
|
||||
action: CTAButtonAction::NextSlide(next),
|
||||
telemetry_event: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn close(label: impl Into<String>) -> Self {
|
||||
Self {
|
||||
label: label.into(),
|
||||
action: CTAButtonAction::Close,
|
||||
telemetry_event: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn open_url(label: impl Into<String>, url: impl Into<String>) -> Self {
|
||||
Self {
|
||||
label: label.into(),
|
||||
action: CTAButtonAction::OpenUrl(url.into()),
|
||||
telemetry_event: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn custom<F>(label: impl Into<String>, callback: F) -> Self
|
||||
where
|
||||
F: Fn(&mut ViewContext<super::LaunchModal<S>>) + 'static,
|
||||
{
|
||||
Self {
|
||||
label: label.into(),
|
||||
action: CTAButtonAction::Custom(Rc::new(callback)),
|
||||
telemetry_event: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn with_telemetry(mut self, event: TelemetryEvent) -> Self {
|
||||
self.telemetry_event = Some(event);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub enum CTAButtonAction<S: Slide> {
|
||||
NextSlide(S),
|
||||
Close,
|
||||
#[allow(dead_code)]
|
||||
OpenUrl(String),
|
||||
Custom(CustomCallback<S>),
|
||||
}
|
||||
|
||||
impl<S: Slide> Clone for CTAButtonAction<S> {
|
||||
fn clone(&self) -> Self {
|
||||
match self {
|
||||
CTAButtonAction::NextSlide(s) => CTAButtonAction::NextSlide(*s),
|
||||
CTAButtonAction::Close => CTAButtonAction::Close,
|
||||
CTAButtonAction::OpenUrl(url) => CTAButtonAction::OpenUrl(url.clone()),
|
||||
CTAButtonAction::Custom(f) => CTAButtonAction::Custom(f.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,746 @@
|
||||
// Specific slide implementations
|
||||
pub mod cta_button;
|
||||
pub mod oz_launch;
|
||||
|
||||
// Re-export slide types for convenience
|
||||
pub use oz_launch::OzLaunchSlide;
|
||||
|
||||
use crate::settings::PrivacySettings;
|
||||
use crate::ui_components::blended_colors;
|
||||
use crate::ui_components::icons::Icon;
|
||||
use crate::view_components::action_button::{ActionButton, PrimaryTheme, SecondaryTheme};
|
||||
use crate::workspace::view::launch_modal::cta_button::{CTAButton, CTAButtonAction};
|
||||
use markdown_parser::{parse_markdown, FormattedText, FormattedTextLine};
|
||||
use pathfinder_color::ColorU;
|
||||
use std::collections::HashMap;
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
use warp_core::ui::theme::Fill;
|
||||
use warpui::assets::asset_cache::AssetSource;
|
||||
use warpui::elements::{
|
||||
Align, Border, CacheOption, ChildAnchor, Clipped, ConstrainedBox, Container, CornerRadius,
|
||||
CrossAxisAlignment, DropShadow, Empty, Expanded, Flex, FormattedTextElement,
|
||||
HighlightedHyperlink, Hoverable, HyperlinkLens, Image, MainAxisAlignment, MainAxisSize,
|
||||
MouseStateHandle, OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, Radius,
|
||||
Shrinkable, SizeConstraintCondition, SizeConstraintSwitch, Stack,
|
||||
};
|
||||
use warpui::fonts::Weight;
|
||||
use warpui::keymap::FixedBinding;
|
||||
use warpui::platform::Cursor;
|
||||
use warpui::presenter::ChildView;
|
||||
use warpui::ui_components::components::UiComponent;
|
||||
use warpui::{
|
||||
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
|
||||
};
|
||||
|
||||
pub fn init<S: Slide>(app: &mut AppContext) {
|
||||
use warpui::keymap::macros::*;
|
||||
|
||||
app.register_fixed_bindings([
|
||||
FixedBinding::new("escape", LaunchModalAction::<S>::Close, id!("LaunchModal")),
|
||||
FixedBinding::new(
|
||||
"enter",
|
||||
LaunchModalAction::<S>::NextSlide,
|
||||
id!("LaunchModal"),
|
||||
),
|
||||
FixedBinding::new(
|
||||
"left",
|
||||
LaunchModalAction::<S>::PrevSlide,
|
||||
id!("LaunchModal"),
|
||||
),
|
||||
FixedBinding::new(
|
||||
"right",
|
||||
LaunchModalAction::<S>::NextSlide,
|
||||
id!("LaunchModal"),
|
||||
),
|
||||
FixedBinding::new("up", LaunchModalAction::<S>::PrevSlide, id!("LaunchModal")),
|
||||
FixedBinding::new(
|
||||
"down",
|
||||
LaunchModalAction::<S>::NextSlide,
|
||||
id!("LaunchModal"),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
/// Configuration for an optional checkbox displayed in the modal's control panel.
|
||||
pub struct CheckboxConfig {
|
||||
pub label: &'static str,
|
||||
pub description: &'static str,
|
||||
}
|
||||
|
||||
pub trait Slide:
|
||||
'static + Send + Sync + std::fmt::Debug + PartialEq + Eq + std::hash::Hash + Copy + Clone
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
fn modal_title(&self) -> String;
|
||||
fn modal_subtext_paragraphs(&self) -> Vec<FormattedTextLine>;
|
||||
fn first() -> Self;
|
||||
fn next(&self) -> Option<Self>;
|
||||
fn prev(&self) -> Option<Self>;
|
||||
fn display_text(&self) -> Option<&'static str>;
|
||||
fn short_label(&self) -> &'static str;
|
||||
fn title(&self) -> &'static str;
|
||||
fn title_icon(&self) -> Option<Icon>;
|
||||
fn content(&self) -> &'static str;
|
||||
fn image(&self) -> AssetSource;
|
||||
fn all() -> Vec<Self>;
|
||||
fn cta_button(&self) -> CTAButton<Self>;
|
||||
|
||||
/// Returns an optional secondary CTA button for the modal.
|
||||
/// When Some, a secondary button is rendered alongside the primary CTA.
|
||||
fn secondary_cta_button(&self) -> Option<CTAButton<Self>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Returns an optional checkbox configuration for the modal.
|
||||
/// When Some, a checkbox is rendered at the bottom of the control panel.
|
||||
fn checkbox_config(&self) -> Option<CheckboxConfig> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Returns whether the checkbox should be shown.
|
||||
/// This is checked in addition to checkbox_config() returning Some.
|
||||
fn should_show_checkbox(&self, _app: &AppContext) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Called when the modal is closed via the X button or esc or close CTA.
|
||||
/// Not called if closed via another CTA.
|
||||
fn on_close(&self, _ctx: &mut ViewContext<LaunchModal<Self>>) {}
|
||||
}
|
||||
|
||||
pub struct StateHandles<S: Slide> {
|
||||
pub close_button: MouseStateHandle,
|
||||
pub slides: HashMap<S, SlideStateHandles>,
|
||||
pub checkbox: MouseStateHandle,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct SlideStateHandles {
|
||||
mouse: MouseStateHandle,
|
||||
content_hyperlink: HighlightedHyperlink,
|
||||
}
|
||||
|
||||
impl<S: Slide> Default for StateHandles<S> {
|
||||
fn default() -> Self {
|
||||
let mut slide_handles = HashMap::new();
|
||||
for slide in S::all() {
|
||||
slide_handles.insert(slide, SlideStateHandles::default());
|
||||
}
|
||||
StateHandles {
|
||||
close_button: Default::default(),
|
||||
slides: slide_handles,
|
||||
checkbox: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct LaunchModal<S: Slide> {
|
||||
slide: S,
|
||||
next_button: ViewHandle<ActionButton>,
|
||||
secondary_button: ViewHandle<ActionButton>,
|
||||
state_handles: StateHandles<S>,
|
||||
}
|
||||
|
||||
impl<S: Slide> LaunchModal<S> {
|
||||
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
|
||||
let next_button = ctx.add_view(|_| ActionButton::new("", PrimaryTheme));
|
||||
let secondary_button = ctx.add_view(|_| ActionButton::new("", SecondaryTheme));
|
||||
|
||||
let mut me = LaunchModal {
|
||||
slide: S::first(),
|
||||
next_button,
|
||||
secondary_button,
|
||||
state_handles: Default::default(),
|
||||
};
|
||||
me.update_buttons_based_on_slide(ctx);
|
||||
me
|
||||
}
|
||||
|
||||
fn update_buttons_based_on_slide(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.next_button
|
||||
.update(ctx, |next_button, ctx| match self.slide.cta_button() {
|
||||
CTAButton {
|
||||
label,
|
||||
action: CTAButtonAction::NextSlide(next),
|
||||
..
|
||||
} => {
|
||||
next_button.set_label(label, ctx);
|
||||
next_button.set_on_click(
|
||||
move |ctx| ctx.dispatch_typed_action(LaunchModalAction::SelectSlide(next)),
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
CTAButton { label, .. } => {
|
||||
next_button.set_label(label, ctx);
|
||||
next_button.set_on_click(
|
||||
move |ctx| ctx.dispatch_typed_action(LaunchModalAction::<S>::Finish),
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Update secondary button if present.
|
||||
if let Some(secondary_cta) = self.slide.secondary_cta_button() {
|
||||
self.secondary_button
|
||||
.update(ctx, |secondary_button, ctx| match secondary_cta {
|
||||
CTAButton {
|
||||
label,
|
||||
action: CTAButtonAction::NextSlide(next),
|
||||
..
|
||||
} => {
|
||||
secondary_button.set_label(label, ctx);
|
||||
secondary_button.set_on_click(
|
||||
move |ctx| {
|
||||
ctx.dispatch_typed_action(LaunchModalAction::SelectSlide(next))
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
CTAButton { label, .. } => {
|
||||
secondary_button.set_label(label, ctx);
|
||||
secondary_button.set_on_click(
|
||||
move |ctx| {
|
||||
ctx.dispatch_typed_action(LaunchModalAction::<S>::FinishSecondary)
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn render_checkbox(&self, app: &AppContext) -> Option<Box<dyn Element>> {
|
||||
if !self.slide.should_show_checkbox(app) {
|
||||
return None;
|
||||
}
|
||||
let checkbox_config = self.slide.checkbox_config()?;
|
||||
let appearance = Appearance::handle(app).as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
|
||||
let is_checked = PrivacySettings::handle(app)
|
||||
.as_ref(app)
|
||||
.is_cloud_conversation_storage_enabled;
|
||||
|
||||
let checkbox = appearance
|
||||
.ui_builder()
|
||||
.checkbox(self.state_handles.checkbox.clone(), Some(10.5))
|
||||
.check(is_checked)
|
||||
.build()
|
||||
.on_click(|ctx, _, _| ctx.dispatch_typed_action(LaunchModalAction::<S>::ToggleCheckbox))
|
||||
.finish();
|
||||
|
||||
let label =
|
||||
FormattedTextElement::from_str(checkbox_config.label, appearance.ui_font_family(), 12.)
|
||||
.with_color(blended_colors::text_sub(
|
||||
theme,
|
||||
blended_colors::neutral_1(theme),
|
||||
))
|
||||
.finish();
|
||||
|
||||
let description = FormattedTextElement::from_str(
|
||||
checkbox_config.description,
|
||||
appearance.ui_font_family(),
|
||||
12.,
|
||||
)
|
||||
.with_color(blended_colors::text_disabled(
|
||||
theme,
|
||||
blended_colors::neutral_1(theme),
|
||||
))
|
||||
.finish();
|
||||
|
||||
Some(
|
||||
Container::new(
|
||||
Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
||||
.with_child(
|
||||
Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(checkbox)
|
||||
.with_child(Container::new(label).with_margin_left(4.).finish())
|
||||
.finish(),
|
||||
)
|
||||
.with_child(Container::new(description).with_margin_top(4.).finish())
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_top(24.)
|
||||
.finish(),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_slide_controls(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
|
||||
// Only show slide controls if there are multiple slides or if slides have display text
|
||||
let slides_with_display_text: Vec<_> = S::all()
|
||||
.into_iter()
|
||||
.filter_map(|slide| slide.display_text().map(|text| (slide, text)))
|
||||
.collect();
|
||||
|
||||
if slides_with_display_text.len() <= 1 {
|
||||
// For single-slide modals or slides without display text, return empty container
|
||||
return Container::new(Flex::column().finish()).finish();
|
||||
}
|
||||
|
||||
let mut column = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
|
||||
|
||||
for (i, (slide, display_text)) in slides_with_display_text.into_iter().enumerate() {
|
||||
let mut label =
|
||||
FormattedTextElement::from_str(display_text, appearance.ui_font_family(), 14.)
|
||||
.with_color(blended_colors::text_main(
|
||||
theme,
|
||||
blended_colors::neutral_1(theme),
|
||||
));
|
||||
if slide == self.slide {
|
||||
label = label.with_weight(Weight::Bold);
|
||||
}
|
||||
|
||||
let mut container = Container::new(Align::new(label.finish()).left().finish())
|
||||
.with_horizontal_padding(12.)
|
||||
.with_vertical_padding(8.)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
|
||||
.with_background(blended_colors::neutral_1(theme));
|
||||
|
||||
if slide == self.slide {
|
||||
container = container.with_background(blended_colors::fg_overlay_3(theme))
|
||||
}
|
||||
|
||||
if i < S::all().len() {
|
||||
container = container.with_margin_bottom(8.)
|
||||
}
|
||||
|
||||
column.add_child(if slide == self.slide {
|
||||
container.finish()
|
||||
} else {
|
||||
Hoverable::new(
|
||||
self.state_handles.slides[&slide].mouse.clone(),
|
||||
move |state| {
|
||||
if state.is_hovered() {
|
||||
container
|
||||
.with_background(blended_colors::fg_overlay_3(theme))
|
||||
.finish()
|
||||
} else {
|
||||
container.finish()
|
||||
}
|
||||
},
|
||||
)
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(LaunchModalAction::SelectSlide(slide))
|
||||
})
|
||||
.finish()
|
||||
});
|
||||
}
|
||||
|
||||
column.finish()
|
||||
}
|
||||
|
||||
fn render_current_slide(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
|
||||
let text_container = Flex::column()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||
.with_child(
|
||||
Shrinkable::new(
|
||||
1.,
|
||||
Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_child(
|
||||
Container::new({
|
||||
let text = FormattedTextElement::from_str(
|
||||
self.slide.title(),
|
||||
appearance.ui_font_family(),
|
||||
16.,
|
||||
)
|
||||
.with_color(blended_colors::text_main(
|
||||
theme,
|
||||
blended_colors::neutral_2(theme),
|
||||
))
|
||||
.with_weight(Weight::Bold)
|
||||
.finish();
|
||||
if let Some(icon) = self.slide.title_icon() {
|
||||
Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(text)
|
||||
.with_child(
|
||||
Container::new(
|
||||
ConstrainedBox::new(
|
||||
icon.to_warpui_icon(Fill::Solid(
|
||||
blended_colors::text_main(
|
||||
theme,
|
||||
blended_colors::neutral_2(theme),
|
||||
),
|
||||
))
|
||||
.finish(),
|
||||
)
|
||||
.with_width(16.)
|
||||
.with_height(16.)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_left(6.)
|
||||
// Agent icon's bounding box makes the icon look too
|
||||
// high relative to the text.
|
||||
.with_margin_top(-2.)
|
||||
.finish(),
|
||||
)
|
||||
.finish()
|
||||
} else {
|
||||
text
|
||||
}
|
||||
})
|
||||
.with_margin_bottom(8.)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
Container::new(
|
||||
Shrinkable::new(
|
||||
1.,
|
||||
FormattedTextElement::new(
|
||||
parse_markdown(self.slide.content()).unwrap(),
|
||||
14.,
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_family(),
|
||||
blended_colors::text_sub(
|
||||
theme,
|
||||
blended_colors::neutral_4(theme),
|
||||
),
|
||||
self.state_handles.slides[&self.slide]
|
||||
.content_hyperlink
|
||||
.clone(),
|
||||
)
|
||||
.with_hyperlink_font_color(theme.accent().into_solid())
|
||||
.register_default_click_handlers_with_action_support(
|
||||
|hyperlink_lens, _event, ctx| {
|
||||
if let HyperlinkLens::Url(url) = hyperlink_lens {
|
||||
ctx.open_url(url);
|
||||
}
|
||||
},
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_bottom(8.)
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
Align::new(
|
||||
Flex::row()
|
||||
.with_main_axis_alignment(MainAxisAlignment::End)
|
||||
.with_children(self.slide.secondary_cta_button().map(|_| {
|
||||
Container::new(ChildView::new(&self.secondary_button).finish())
|
||||
.with_margin_right(8.)
|
||||
.finish()
|
||||
}))
|
||||
.with_child(ChildView::new(&self.next_button).finish())
|
||||
.finish(),
|
||||
)
|
||||
.bottom_right()
|
||||
.finish(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
Flex::column()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_child(
|
||||
Clipped::new(
|
||||
ConstrainedBox::new(
|
||||
Image::new(self.slide.image(), CacheOption::Original)
|
||||
.with_corner_radius(CornerRadius::with_top_right(Radius::Pixels(10.)))
|
||||
.cover()
|
||||
.finish(),
|
||||
)
|
||||
.with_max_width(MAX_SLIDE_WIDTH)
|
||||
.with_min_height(100.)
|
||||
.with_max_height(MAX_IMAGE_HEIGHT)
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
Expanded::new(
|
||||
1.,
|
||||
Container::new(text_container)
|
||||
.with_uniform_padding(24.)
|
||||
.with_background(blended_colors::neutral_2(theme))
|
||||
.with_border(
|
||||
Border::left(1.).with_border_color(blended_colors::neutral_4(theme)),
|
||||
)
|
||||
.with_corner_radius(CornerRadius::with_bottom_right(Radius::Pixels(10.)))
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn handle_cta_button_action(&self, ctx: &mut ViewContext<Self>) {
|
||||
let cta_button = self.slide.cta_button();
|
||||
match cta_button.action {
|
||||
CTAButtonAction::NextSlide(_) => {}
|
||||
CTAButtonAction::Close => {
|
||||
self.slide.on_close(ctx);
|
||||
ctx.emit(LaunchModalEvent::Close);
|
||||
}
|
||||
CTAButtonAction::OpenUrl(url) => {
|
||||
ctx.open_url(&url);
|
||||
ctx.emit(LaunchModalEvent::Close);
|
||||
}
|
||||
CTAButtonAction::Custom(callback) => {
|
||||
callback(ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_secondary_cta_button_action(&self, ctx: &mut ViewContext<Self>) {
|
||||
let Some(cta_button) = self.slide.secondary_cta_button() else {
|
||||
return;
|
||||
};
|
||||
match cta_button.action {
|
||||
CTAButtonAction::NextSlide(_) => {}
|
||||
CTAButtonAction::Close => {
|
||||
self.slide.on_close(ctx);
|
||||
ctx.emit(LaunchModalEvent::Close);
|
||||
}
|
||||
CTAButtonAction::OpenUrl(url) => {
|
||||
ctx.open_url(&url);
|
||||
ctx.emit(LaunchModalEvent::Close);
|
||||
}
|
||||
CTAButtonAction::Custom(callback) => {
|
||||
callback(ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: Slide> Entity for LaunchModal<S> {
|
||||
type Event = LaunchModalEvent;
|
||||
}
|
||||
|
||||
// Modal dimension constants.
|
||||
const MAX_MODAL_WIDTH: f32 = 876.;
|
||||
const MIN_MODAL_HEIGHT: f32 = 300.;
|
||||
const MAX_MODAL_HEIGHT: f32 = 540.;
|
||||
const MAX_CONTROL_PANEL_WIDTH: f32 = 333.;
|
||||
const MIN_CONTROL_PANEL_WIDTH: f32 = 220.;
|
||||
const MAX_SLIDE_WIDTH: f32 = 543.;
|
||||
const MAX_IMAGE_HEIGHT: f32 = 355.;
|
||||
/// Minimum width below which the modal is hidden.
|
||||
const MIN_MODAL_WIDTH: f32 = 600.;
|
||||
|
||||
impl<S: Slide> View for LaunchModal<S> {
|
||||
fn ui_name() -> &'static str {
|
||||
"LaunchModal"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
const BUTTON_DIAMETER: f32 = 20.;
|
||||
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
|
||||
let control_panel = Container::new(
|
||||
Flex::column()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_child(
|
||||
Container::new(
|
||||
FormattedTextElement::from_str(
|
||||
self.slide.modal_title(),
|
||||
appearance.ui_font_family(),
|
||||
24.,
|
||||
)
|
||||
.with_color(blended_colors::text_main(
|
||||
theme,
|
||||
blended_colors::neutral_1(theme),
|
||||
))
|
||||
.with_weight(Weight::Bold)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_bottom(12.)
|
||||
.finish(),
|
||||
)
|
||||
.with_children(
|
||||
self.slide
|
||||
.modal_subtext_paragraphs()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, line)| {
|
||||
let is_last = index == self.slide.modal_subtext_paragraphs().len() - 1;
|
||||
|
||||
let text_element = FormattedTextElement::new(
|
||||
FormattedText::new([line.clone()]),
|
||||
14.,
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_family(),
|
||||
blended_colors::text_main(theme, blended_colors::neutral_1(theme)),
|
||||
Default::default(), // no hyperlink highlighting needed
|
||||
)
|
||||
.disable_mouse_interaction()
|
||||
.finish();
|
||||
|
||||
Container::new(text_element)
|
||||
.with_margin_bottom(if is_last { 40. } else { 8. })
|
||||
.finish()
|
||||
}),
|
||||
)
|
||||
.with_child(Expanded::new(1., self.render_slide_controls(app)).finish())
|
||||
.with_children(self.render_checkbox(app))
|
||||
.finish(),
|
||||
)
|
||||
.with_background_color(blended_colors::neutral_1(theme))
|
||||
.with_corner_radius(CornerRadius::with_left(Radius::Pixels(10.)))
|
||||
.with_uniform_padding(24.)
|
||||
.finish();
|
||||
|
||||
let close_button = appearance
|
||||
.ui_builder()
|
||||
.close_button(BUTTON_DIAMETER, self.state_handles.close_button.clone())
|
||||
.build()
|
||||
.on_click(|ctx, _, _| ctx.dispatch_typed_action(LaunchModalAction::<S>::Close))
|
||||
.finish();
|
||||
|
||||
let mut modal = Stack::new();
|
||||
modal.add_child(
|
||||
Container::new(
|
||||
ConstrainedBox::new(
|
||||
Flex::row()
|
||||
.with_child(
|
||||
Shrinkable::new(
|
||||
MAX_CONTROL_PANEL_WIDTH,
|
||||
ConstrainedBox::new(control_panel)
|
||||
.with_min_width(MIN_CONTROL_PANEL_WIDTH)
|
||||
.with_max_width(MAX_CONTROL_PANEL_WIDTH)
|
||||
.with_height(MAX_MODAL_HEIGHT)
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
Shrinkable::new(
|
||||
MAX_SLIDE_WIDTH,
|
||||
ConstrainedBox::new(self.render_current_slide(app))
|
||||
.with_max_width(MAX_SLIDE_WIDTH)
|
||||
.with_height(MAX_MODAL_HEIGHT)
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_max_width(MAX_MODAL_WIDTH)
|
||||
.with_min_height(MIN_MODAL_HEIGHT)
|
||||
.finish(),
|
||||
)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(10.)))
|
||||
.with_border(Border::all(1.).with_border_fill(theme.outline()))
|
||||
.with_drop_shadow(DropShadow::default())
|
||||
.finish(),
|
||||
);
|
||||
modal.add_positioned_child(
|
||||
close_button,
|
||||
OffsetPositioning::offset_from_parent(
|
||||
pathfinder_geometry::vector::vec2f(-8., 8.),
|
||||
ParentOffsetBounds::ParentByPosition,
|
||||
ParentAnchor::TopRight,
|
||||
ChildAnchor::TopRight,
|
||||
),
|
||||
);
|
||||
|
||||
// Stack needed so that modal can get bounds information,
|
||||
// specifically to ensure no overlap with the window's traffic lights.
|
||||
let mut stack = Stack::new();
|
||||
stack.add_positioned_child(
|
||||
modal.finish(),
|
||||
OffsetPositioning::offset_from_parent(
|
||||
pathfinder_geometry::vector::vec2f(0., 0.),
|
||||
ParentOffsetBounds::WindowByPosition,
|
||||
ParentAnchor::Center,
|
||||
ChildAnchor::Center,
|
||||
),
|
||||
);
|
||||
|
||||
// Hide the modal if the window is too narrow to display it properly.
|
||||
SizeConstraintSwitch::new(
|
||||
Container::new(Align::new(stack.finish()).finish())
|
||||
.with_background(Fill::Solid(ColorU::new(97, 97, 97, 255)).with_opacity(50))
|
||||
.finish(),
|
||||
[(
|
||||
SizeConstraintCondition::WidthLessThan(MIN_MODAL_WIDTH),
|
||||
Empty::new().finish(),
|
||||
)],
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: Slide> TypedActionView for LaunchModal<S> {
|
||||
type Action = LaunchModalAction<S>;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
LaunchModalAction::SelectSlide(slide) => {
|
||||
self.slide = *slide;
|
||||
self.update_buttons_based_on_slide(ctx);
|
||||
ctx.notify();
|
||||
}
|
||||
LaunchModalAction::NextSlide => {
|
||||
if let Some(next_slide) = self.slide.next() {
|
||||
self.slide = next_slide;
|
||||
self.update_buttons_based_on_slide(ctx);
|
||||
ctx.notify();
|
||||
} else {
|
||||
// If we're on the last slide, trigger the CTA button action.
|
||||
self.handle_cta_button_action(ctx);
|
||||
}
|
||||
}
|
||||
LaunchModalAction::PrevSlide => {
|
||||
if let Some(prev_slide) = self.slide.prev() {
|
||||
self.slide = prev_slide;
|
||||
self.update_buttons_based_on_slide(ctx);
|
||||
ctx.notify();
|
||||
}
|
||||
// If we're on the first slide, do nothing.
|
||||
}
|
||||
LaunchModalAction::Close => {
|
||||
self.slide.on_close(ctx);
|
||||
ctx.emit(LaunchModalEvent::Close);
|
||||
}
|
||||
LaunchModalAction::Finish => {
|
||||
self.handle_cta_button_action(ctx);
|
||||
}
|
||||
LaunchModalAction::FinishSecondary => {
|
||||
self.handle_secondary_cta_button_action(ctx);
|
||||
}
|
||||
LaunchModalAction::ToggleCheckbox => {
|
||||
ctx.emit(LaunchModalEvent::ToggleCheckbox);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub enum LaunchModalEvent {
|
||||
Close,
|
||||
ToggleCheckbox,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub enum LaunchModalAction<S: Slide> {
|
||||
SelectSlide(S),
|
||||
NextSlide,
|
||||
PrevSlide,
|
||||
Close,
|
||||
Finish,
|
||||
FinishSecondary,
|
||||
ToggleCheckbox,
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
use super::{CTAButton, CheckboxConfig, LaunchModalEvent, Slide};
|
||||
use crate::ai::ambient_agents::telemetry::{CloudAgentTelemetryEvent, CloudModeEntryPoint};
|
||||
use crate::terminal::view::OnboardingIntention;
|
||||
use crate::ui_components::icons::Icon;
|
||||
use crate::workspace::action::WorkspaceAction;
|
||||
use crate::workspace::view::OnboardingTutorial;
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
use crate::workspaces::workspace::{AdminEnablementSetting, UgcCollectionEnablementSetting};
|
||||
use asset_macro::bundled_or_fetched_asset;
|
||||
use markdown_parser::{FormattedTextFragment, FormattedTextLine};
|
||||
use warp_core::send_telemetry_from_ctx;
|
||||
use warpui::assets::asset_cache::AssetSource;
|
||||
use warpui::{AppContext, SingletonEntity};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum OzLaunchSlide {
|
||||
CloudAgents,
|
||||
AgentAutomations,
|
||||
AgentManagement,
|
||||
LaunchCredits,
|
||||
}
|
||||
|
||||
impl Slide for OzLaunchSlide {
|
||||
fn modal_title(&self) -> String {
|
||||
"Introducing Oz".to_string()
|
||||
}
|
||||
|
||||
fn modal_subtext_paragraphs(&self) -> Vec<FormattedTextLine> {
|
||||
vec![FormattedTextLine::Line(vec![
|
||||
FormattedTextFragment::plain_text(
|
||||
"Infinitely scalable coding agent — run in local sessions or in the cloud.",
|
||||
),
|
||||
])]
|
||||
}
|
||||
|
||||
fn first() -> Self {
|
||||
OzLaunchSlide::CloudAgents
|
||||
}
|
||||
|
||||
fn next(&self) -> Option<Self> {
|
||||
match self {
|
||||
OzLaunchSlide::CloudAgents => Some(OzLaunchSlide::AgentAutomations),
|
||||
OzLaunchSlide::AgentAutomations => Some(OzLaunchSlide::AgentManagement),
|
||||
OzLaunchSlide::AgentManagement => Some(OzLaunchSlide::LaunchCredits),
|
||||
OzLaunchSlide::LaunchCredits => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn prev(&self) -> Option<Self> {
|
||||
match self {
|
||||
OzLaunchSlide::CloudAgents => None,
|
||||
OzLaunchSlide::AgentAutomations => Some(OzLaunchSlide::CloudAgents),
|
||||
OzLaunchSlide::AgentManagement => Some(OzLaunchSlide::AgentAutomations),
|
||||
OzLaunchSlide::LaunchCredits => Some(OzLaunchSlide::AgentManagement),
|
||||
}
|
||||
}
|
||||
|
||||
fn display_text(&self) -> Option<&'static str> {
|
||||
Some(match self {
|
||||
OzLaunchSlide::CloudAgents => "Cloud agents",
|
||||
OzLaunchSlide::AgentAutomations => "Agent automations",
|
||||
OzLaunchSlide::AgentManagement => "Agent management",
|
||||
OzLaunchSlide::LaunchCredits => "A little gift",
|
||||
})
|
||||
}
|
||||
|
||||
fn short_label(&self) -> &'static str {
|
||||
match self {
|
||||
OzLaunchSlide::CloudAgents => "Cloud agents",
|
||||
OzLaunchSlide::AgentAutomations => "Agent automations",
|
||||
OzLaunchSlide::AgentManagement => "Agent management",
|
||||
OzLaunchSlide::LaunchCredits => "Launch credits",
|
||||
}
|
||||
}
|
||||
|
||||
fn title(&self) -> &'static str {
|
||||
match self {
|
||||
OzLaunchSlide::CloudAgents => "Break out of your laptop with cloud agents",
|
||||
OzLaunchSlide::AgentAutomations => {
|
||||
"Orchestrate agents, turning Skills into automations"
|
||||
}
|
||||
OzLaunchSlide::AgentManagement => "Track local and cloud agents seamlessly",
|
||||
OzLaunchSlide::LaunchCredits => {
|
||||
"1,000 free cloud agent credits when you upgrade to Warp Build"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn title_icon(&self) -> Option<Icon> {
|
||||
None
|
||||
}
|
||||
|
||||
fn content(&self) -> &'static str {
|
||||
match self {
|
||||
OzLaunchSlide::CloudAgents => {
|
||||
"Use cloud agents to run many agents in parallel, keep agents working when you close your laptop, or start agents programmatically. Plus, you can check on their work through the web."
|
||||
}
|
||||
OzLaunchSlide::AgentAutomations => {
|
||||
"Oz agents can be defined using the standard Skills format. You can use the built in scheduler to setup agents to run autonomously at set intervals, or use the Oz SDK or API to programmatically start and manage Oz agents."
|
||||
}
|
||||
OzLaunchSlide::AgentManagement => {
|
||||
"View all of your agents across local and cloud sessions in the Warp app or at [oz.warp.dev](https://oz.warp.dev). Join live agent sessions, continue tasks locally, and steer agents with one click."
|
||||
}
|
||||
OzLaunchSlide::LaunchCredits => {
|
||||
"Upgrade to Build this month and receive 1,000 extra credits to try using Oz. Credits are only eligible for Oz runs in Warp-hosted cloud environments."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn image(&self) -> AssetSource {
|
||||
// TODO: Replace with new images once provided.
|
||||
match self {
|
||||
OzLaunchSlide::CloudAgents => {
|
||||
bundled_or_fetched_asset!("png/oz_cloud_agents.png")
|
||||
}
|
||||
OzLaunchSlide::AgentAutomations => {
|
||||
bundled_or_fetched_asset!("png/oz_agent_automations.png")
|
||||
}
|
||||
OzLaunchSlide::AgentManagement => {
|
||||
bundled_or_fetched_asset!("png/oz_agent_management.png")
|
||||
}
|
||||
OzLaunchSlide::LaunchCredits => {
|
||||
bundled_or_fetched_asset!("png/oz_launch_credits.png")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn all() -> Vec<Self> {
|
||||
vec![
|
||||
OzLaunchSlide::CloudAgents,
|
||||
OzLaunchSlide::AgentAutomations,
|
||||
OzLaunchSlide::AgentManagement,
|
||||
OzLaunchSlide::LaunchCredits,
|
||||
]
|
||||
}
|
||||
|
||||
fn cta_button(&self) -> CTAButton<Self> {
|
||||
match self {
|
||||
OzLaunchSlide::CloudAgents
|
||||
| OzLaunchSlide::AgentAutomations
|
||||
| OzLaunchSlide::AgentManagement => {
|
||||
let next = self.next().expect("Non-final slides should have a next");
|
||||
CTAButton::next_slide(next, format!("Next: {}", next.short_label()))
|
||||
}
|
||||
OzLaunchSlide::LaunchCredits => CTAButton::custom("Try it out", |ctx| {
|
||||
send_telemetry_from_ctx!(
|
||||
CloudAgentTelemetryEvent::EnteredCloudMode {
|
||||
entry_point: CloudModeEntryPoint::OzLaunchModal,
|
||||
},
|
||||
ctx
|
||||
);
|
||||
ctx.emit(LaunchModalEvent::Close);
|
||||
ctx.dispatch_typed_action(&WorkspaceAction::StartAgentOnboardingTutorial(
|
||||
OnboardingTutorial::NoProject {
|
||||
intention: OnboardingIntention::AgentDrivenDevelopment,
|
||||
},
|
||||
));
|
||||
ctx.dispatch_typed_action(&WorkspaceAction::AddAmbientAgentTab);
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn secondary_cta_button(&self) -> Option<CTAButton<Self>> {
|
||||
match self {
|
||||
OzLaunchSlide::LaunchCredits => Some(CTAButton::close("Skip for now")),
|
||||
OzLaunchSlide::CloudAgents
|
||||
| OzLaunchSlide::AgentAutomations
|
||||
| OzLaunchSlide::AgentManagement => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn checkbox_config(&self) -> Option<CheckboxConfig> {
|
||||
Some(CheckboxConfig {
|
||||
label: "Sync conversations to cloud",
|
||||
description: "Agent conversations stored in the cloud can be shared with anyone with one click, and allow conversations to be continued across devices and on logout.",
|
||||
})
|
||||
}
|
||||
|
||||
fn should_show_checkbox(&self, app: &AppContext) -> bool {
|
||||
let cloud_storage_setting =
|
||||
UserWorkspaces::as_ref(app).get_cloud_conversation_storage_enablement_setting();
|
||||
let ugc_setting = UserWorkspaces::as_ref(app).get_ugc_collection_enablement_setting();
|
||||
|
||||
// Show checkbox only when user has control over cloud storage AND UGC is not force-enabled.
|
||||
matches!(
|
||||
cloud_storage_setting,
|
||||
AdminEnablementSetting::RespectUserSetting
|
||||
) && !matches!(ugc_setting, UgcCollectionEnablementSetting::Enable)
|
||||
}
|
||||
|
||||
fn on_close(&self, ctx: &mut warpui::ViewContext<super::LaunchModal<Self>>) {
|
||||
ctx.dispatch_typed_action(&WorkspaceAction::StartAgentOnboardingTutorial(
|
||||
OnboardingTutorial::NoProject {
|
||||
intention: OnboardingIntention::AgentDrivenDevelopment,
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init(app: &mut warpui::AppContext) {
|
||||
super::init::<OzLaunchSlide>(app);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,243 @@
|
||||
use crate::pane_group::{NewTerminalOptions, PanesLayout};
|
||||
use crate::settings::AISettings;
|
||||
use crate::terminal;
|
||||
use crate::terminal::view::{
|
||||
AgentOnboardingVersion, OnboardingIntention, OnboardingVersion, TerminalAction,
|
||||
};
|
||||
use crate::workspace::Workspace;
|
||||
use crate::FeatureFlag;
|
||||
use onboarding::{ProjectOnboardingSettings, SelectedSettings};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use warpui::{SingletonEntity as _, ViewContext};
|
||||
|
||||
/// Configuration for starting the agent onboarding tutorial.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum OnboardingTutorial {
|
||||
/// Start tutorial without a project context.
|
||||
NoProject { intention: OnboardingIntention },
|
||||
/// Start tutorial with a project path, but don't run init.
|
||||
Project {
|
||||
path: PathBuf,
|
||||
intention: OnboardingIntention,
|
||||
},
|
||||
/// Start tutorial with a project path and run init flow first.
|
||||
InitProject {
|
||||
path: PathBuf,
|
||||
intention: OnboardingIntention,
|
||||
},
|
||||
}
|
||||
|
||||
impl OnboardingTutorial {
|
||||
/// Extracts the onboarding intention from any tutorial variant.
|
||||
pub(crate) fn intention(&self) -> OnboardingIntention {
|
||||
match self {
|
||||
OnboardingTutorial::NoProject { intention }
|
||||
| OnboardingTutorial::Project { intention, .. }
|
||||
| OnboardingTutorial::InitProject { intention, .. } => *intention,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SelectedSettings> for OnboardingTutorial {
|
||||
fn from(settings: SelectedSettings) -> Self {
|
||||
match settings {
|
||||
SelectedSettings::AgentDrivenDevelopment {
|
||||
project_settings, ..
|
||||
} => match project_settings {
|
||||
ProjectOnboardingSettings::Project {
|
||||
selected_local_folder,
|
||||
initialize_projects_automatically,
|
||||
} => {
|
||||
let path = PathBuf::from(selected_local_folder);
|
||||
// When AgentView is enabled, /init comes at the end of the tutorial.
|
||||
if !FeatureFlag::AgentView.is_enabled() && initialize_projects_automatically {
|
||||
OnboardingTutorial::InitProject {
|
||||
path,
|
||||
intention: OnboardingIntention::AgentDrivenDevelopment,
|
||||
}
|
||||
} else {
|
||||
OnboardingTutorial::Project {
|
||||
path,
|
||||
intention: OnboardingIntention::AgentDrivenDevelopment,
|
||||
}
|
||||
}
|
||||
}
|
||||
ProjectOnboardingSettings::NoProject => OnboardingTutorial::NoProject {
|
||||
intention: OnboardingIntention::AgentDrivenDevelopment,
|
||||
},
|
||||
},
|
||||
SelectedSettings::Terminal { .. } => OnboardingTutorial::NoProject {
|
||||
intention: OnboardingIntention::Terminal,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Workspace {
|
||||
/// Start the agent onboarding tutorial.
|
||||
///
|
||||
/// Depending on the variant of `tutorial`, this will either:
|
||||
/// - `NoProject`: Start the tutorial immediately without any project context
|
||||
/// - `Project`: Change to the project directory and start the tutorial
|
||||
/// - `InitProject`: Open the repository, wait for init to complete, then start the tutorial
|
||||
pub(crate) fn start_agent_onboarding_tutorial(
|
||||
&mut self,
|
||||
tutorial: OnboardingTutorial,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
match tutorial {
|
||||
OnboardingTutorial::InitProject {
|
||||
ref path,
|
||||
intention,
|
||||
} => {
|
||||
// Open the repository - this will create a new terminal and trigger init
|
||||
let Some(path_str) = path.to_str() else {
|
||||
log::error!("Failed to convert path to string: {path:?}");
|
||||
return;
|
||||
};
|
||||
self.handle_open_repository(path_str, ctx);
|
||||
|
||||
// Subscribe to the terminal view to wait for init completion
|
||||
if let Some(terminal_view_handle) = self.active_session_view(ctx) {
|
||||
ctx.subscribe_to_view(
|
||||
&terminal_view_handle,
|
||||
move |me, terminal_view, event, ctx| {
|
||||
if let terminal::Event::OnboardingInitCompleted = event {
|
||||
// Init flow is complete, now start the tutorial
|
||||
me.dispatch_agent_onboarding_tutorial(true, intention, ctx);
|
||||
ctx.unsubscribe_to_view(&terminal_view);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
OnboardingTutorial::Project {
|
||||
ref path,
|
||||
intention,
|
||||
} => {
|
||||
// Create a new terminal in the project directory
|
||||
self.add_tab_with_pane_layout(
|
||||
PanesLayout::SingleTerminal(Box::new(NewTerminalOptions {
|
||||
initial_directory: Some(path.clone()),
|
||||
hide_homepage: true,
|
||||
..Default::default()
|
||||
})),
|
||||
Arc::new(HashMap::new()),
|
||||
None,
|
||||
ctx,
|
||||
);
|
||||
self.dispatch_tutorial_when_bootstrapped(true, intention, ctx);
|
||||
}
|
||||
OnboardingTutorial::NoProject { intention } => {
|
||||
self.dispatch_tutorial_when_bootstrapped(false, intention, ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispatch the onboarding tutorial after the terminal has finished bootstrapping.
|
||||
pub(crate) fn dispatch_tutorial_when_bootstrapped(
|
||||
&mut self,
|
||||
has_project: bool,
|
||||
intention: OnboardingIntention,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
// With new onboarding, skip the guided tour when AI is not enabled
|
||||
// (e.g. terminal-intent users or users who disabled AI).
|
||||
if FeatureFlag::OpenWarpNewSettingsModes.is_enabled()
|
||||
&& !*AISettings::as_ref(ctx).is_any_ai_enabled
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(terminal_view_handle) = self.active_session_view(ctx) else {
|
||||
log::warn!("No active terminal view for onboarding tutorial");
|
||||
return;
|
||||
};
|
||||
|
||||
let is_bootstrapped =
|
||||
terminal_view_handle.read(ctx, |view, _| view.is_login_shell_bootstrapped());
|
||||
|
||||
if is_bootstrapped {
|
||||
// Terminal is already bootstrapped, dispatch immediately
|
||||
self.dispatch_agent_onboarding_tutorial(has_project, intention, ctx);
|
||||
} else {
|
||||
// Wait for bootstrapping to complete
|
||||
ctx.subscribe_to_view(
|
||||
&terminal_view_handle,
|
||||
move |me, terminal_view, event, ctx| {
|
||||
if let terminal::Event::SessionBootstrapped = event {
|
||||
me.dispatch_agent_onboarding_tutorial(has_project, intention, ctx);
|
||||
ctx.unsubscribe_to_view(&terminal_view);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispatch the agent onboarding tutorial flow to the active terminal.
|
||||
fn dispatch_agent_onboarding_tutorial(
|
||||
&self,
|
||||
has_project: bool,
|
||||
intention: OnboardingIntention,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
let version = OnboardingVersion::Agent(if FeatureFlag::AgentView.is_enabled() {
|
||||
AgentOnboardingVersion::AgentModality {
|
||||
has_project,
|
||||
intention,
|
||||
}
|
||||
} else {
|
||||
AgentOnboardingVersion::UniversalInput { has_project }
|
||||
});
|
||||
self.dispatch_onboarding(TerminalAction::OnboardingFlow(version), ctx);
|
||||
}
|
||||
|
||||
/// Dispatch the onboarding tutorial after a pending command (e.g. worktree
|
||||
/// setup) finishes in the active terminal. Subscribes to
|
||||
/// `Event::PendingCommandCompleted` on the active terminal view.
|
||||
pub(crate) fn dispatch_tutorial_after_setup_commands(
|
||||
&mut self,
|
||||
intention: OnboardingIntention,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
let Some(terminal_view_handle) = self.active_session_view(ctx) else {
|
||||
log::warn!("No active terminal view for post-setup onboarding tutorial");
|
||||
return;
|
||||
};
|
||||
|
||||
// Suppress deferred agent view entry so setup commands run in
|
||||
// terminal mode and the tutorial starts in terminal mode.
|
||||
terminal_view_handle.update(ctx, |view, _| {
|
||||
view.clear_enter_agent_view_after_pending_commands();
|
||||
});
|
||||
let has_pending_command = terminal_view_handle.read(ctx, |view, ctx| {
|
||||
view.has_pending_command_or_awaiting_completion(ctx)
|
||||
});
|
||||
if !has_pending_command {
|
||||
self.dispatch_tutorial_when_bootstrapped(true, intention, ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.subscribe_to_view(
|
||||
&terminal_view_handle,
|
||||
move |me, terminal_view, event, ctx| {
|
||||
if let terminal::Event::PendingCommandCompleted = event {
|
||||
// Start the onboarding tutorial now that setup is done.
|
||||
// TODO(roland): We do have a directory in this case so we could consider passing has_project = true
|
||||
// which has an optional /init flow. But the behavior of /init needs to be revisited:
|
||||
// 1. Sends /init as a query which differs in behavior from /init slash command
|
||||
// 2. Sends /init even if not in a git repo - unclear if this should happen (depends on desired behavior from 1)
|
||||
// 3. With no free AI, /init will not work.
|
||||
me.dispatch_agent_onboarding_tutorial(false, intention, ctx);
|
||||
ctx.unsubscribe_to_view(&terminal_view);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn should_show_agent_onboarding(&self, _ctx: &mut ViewContext<Self>) -> bool {
|
||||
FeatureFlag::AgentOnboarding.is_enabled()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
mod view;
|
||||
|
||||
pub use view::{init, OpenWarpLaunchModal, OpenWarpLaunchModalEvent};
|
||||
@@ -0,0 +1,416 @@
|
||||
use markdown_parser::{
|
||||
FormattedText, FormattedTextFragment, FormattedTextLine, FormattedTextStyles, Hyperlink,
|
||||
};
|
||||
use pathfinder_color::ColorU;
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use warp_core::ui::theme::{phenomenon::PhenomenonStyle, Fill};
|
||||
use warpui::assets::asset_cache::AssetSource;
|
||||
use warpui::elements::{
|
||||
Align, CacheOption, ChildAnchor, ChildView, ConstrainedBox, Container, CornerRadius,
|
||||
CrossAxisAlignment, Expanded, Flex, FormattedTextElement, HighlightedHyperlink, Image,
|
||||
MainAxisSize, OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, Radius,
|
||||
Stack, Text,
|
||||
};
|
||||
use warpui::fonts::{Properties, Weight};
|
||||
use warpui::keymap::FixedBinding;
|
||||
use warpui::{
|
||||
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
|
||||
};
|
||||
|
||||
use crate::appearance::Appearance;
|
||||
use crate::ui_components::icons::Icon;
|
||||
use crate::view_components::action_button::{ActionButton, ActionButtonTheme, ButtonSize};
|
||||
|
||||
const MODAL_WIDTH: f32 = 420.;
|
||||
const HERO_HEIGHT: f32 = 92.;
|
||||
const HERO_IMAGE_PATH: &str = "async/png/onboarding/openwarp_launch_banner.png";
|
||||
const REPO_URL: &str = "https://github.com/warpdotdev/warp";
|
||||
const CONTRIBUTING_URL: &str = "https://github.com/warpdotdev/warp/blob/master/CONTRIBUTING.md";
|
||||
const OZ_URL: &str = "https://oz.warp.dev";
|
||||
|
||||
struct InlineLink {
|
||||
text: &'static str,
|
||||
url: &'static str,
|
||||
}
|
||||
|
||||
struct FeatureItem {
|
||||
icon: Icon,
|
||||
title: &'static str,
|
||||
description: &'static str,
|
||||
/// If set, the first occurrence of `text` in the description is rendered as a hyperlink.
|
||||
inline_link: Option<InlineLink>,
|
||||
}
|
||||
|
||||
const FEATURE_ITEMS: &[FeatureItem] = &[
|
||||
FeatureItem {
|
||||
icon: Icon::HeartHand,
|
||||
title: "Contribute",
|
||||
description: "Warp's client code is now open source. Get started by using the /feedback skill to open an issue, and follow the contribution guidelines here.",
|
||||
inline_link: Some(InlineLink {
|
||||
text: "here",
|
||||
url: CONTRIBUTING_URL,
|
||||
}),
|
||||
},
|
||||
FeatureItem {
|
||||
icon: Icon::Oz,
|
||||
title: "Open Automated Development",
|
||||
description: "The Warp repo is managed by an agent-first workflow powered by Oz, our cloud agent orchestration platform.",
|
||||
inline_link: Some(InlineLink {
|
||||
text: "Oz",
|
||||
url: OZ_URL,
|
||||
}),
|
||||
},
|
||||
FeatureItem {
|
||||
icon: Icon::MessageChatSquare,
|
||||
title: "Introducing 'auto (open-weights)'",
|
||||
description: "We've added a new auto model that picks the best open weight model for a task, like Kimi or MiniMax.",
|
||||
inline_link: None,
|
||||
},
|
||||
];
|
||||
|
||||
pub fn init(app: &mut AppContext) {
|
||||
use warpui::keymap::macros::*;
|
||||
|
||||
app.register_fixed_bindings([FixedBinding::new(
|
||||
"escape",
|
||||
OpenWarpLaunchModalAction::Close,
|
||||
id!(OpenWarpLaunchModal::ui_name()),
|
||||
)]);
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum OpenWarpLaunchModalAction {
|
||||
Close,
|
||||
VisitRepo,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum OpenWarpLaunchModalEvent {
|
||||
Close,
|
||||
}
|
||||
|
||||
struct CloseButtonTheme;
|
||||
|
||||
impl ActionButtonTheme for CloseButtonTheme {
|
||||
fn background(&self, hovered: bool, _appearance: &Appearance) -> Option<Fill> {
|
||||
if hovered {
|
||||
Some(Fill::Solid(PhenomenonStyle::modal_close_button_hover()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn text_color(
|
||||
&self,
|
||||
_hovered: bool,
|
||||
_background: Option<Fill>,
|
||||
_appearance: &Appearance,
|
||||
) -> ColorU {
|
||||
PhenomenonStyle::modal_close_button_text()
|
||||
}
|
||||
}
|
||||
|
||||
struct CtaButtonTheme;
|
||||
|
||||
impl ActionButtonTheme for CtaButtonTheme {
|
||||
fn background(&self, hovered: bool, _appearance: &Appearance) -> Option<Fill> {
|
||||
Some(PhenomenonStyle::modal_button_background_fill(hovered))
|
||||
}
|
||||
|
||||
fn text_color(
|
||||
&self,
|
||||
_hovered: bool,
|
||||
_background: Option<Fill>,
|
||||
_appearance: &Appearance,
|
||||
) -> ColorU {
|
||||
PhenomenonStyle::modal_button_text()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct OpenWarpLaunchModal {
|
||||
close_button: ViewHandle<ActionButton>,
|
||||
cta_button: ViewHandle<ActionButton>,
|
||||
}
|
||||
|
||||
impl OpenWarpLaunchModal {
|
||||
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
|
||||
let close_button = ctx.add_view(|_ctx| {
|
||||
ActionButton::new("", CloseButtonTheme)
|
||||
.with_icon(Icon::X)
|
||||
.with_size(ButtonSize::Small)
|
||||
.on_click(|ctx| ctx.dispatch_typed_action(OpenWarpLaunchModalAction::Close))
|
||||
});
|
||||
|
||||
let cta_button = ctx.add_view(|_ctx| {
|
||||
ActionButton::new("Visit the repo", CtaButtonTheme)
|
||||
.with_full_width(true)
|
||||
.on_click(|ctx| ctx.dispatch_typed_action(OpenWarpLaunchModalAction::VisitRepo))
|
||||
});
|
||||
|
||||
Self {
|
||||
close_button,
|
||||
cta_button,
|
||||
}
|
||||
}
|
||||
|
||||
fn render_hero(&self) -> Box<dyn Element> {
|
||||
let hero = ConstrainedBox::new(
|
||||
Image::new(
|
||||
AssetSource::Bundled {
|
||||
path: HERO_IMAGE_PATH,
|
||||
},
|
||||
CacheOption::Original,
|
||||
)
|
||||
.with_corner_radius(CornerRadius::with_top(Radius::Pixels(8.)))
|
||||
.cover()
|
||||
.top_aligned()
|
||||
.finish(),
|
||||
)
|
||||
.with_width(MODAL_WIDTH)
|
||||
.with_height(HERO_HEIGHT)
|
||||
.finish();
|
||||
|
||||
let close_el = Container::new(ChildView::new(&self.close_button).finish())
|
||||
.with_uniform_padding(4.)
|
||||
.with_padding_right(2.)
|
||||
.finish();
|
||||
|
||||
let mut hero_stack = Stack::new();
|
||||
hero_stack.add_child(hero);
|
||||
hero_stack.add_positioned_child(
|
||||
close_el,
|
||||
OffsetPositioning::offset_from_parent(
|
||||
vec2f(-4., 0.),
|
||||
ParentOffsetBounds::ParentByPosition,
|
||||
ParentAnchor::TopRight,
|
||||
ChildAnchor::TopRight,
|
||||
),
|
||||
);
|
||||
hero_stack.finish()
|
||||
}
|
||||
|
||||
fn render_badge(appearance: &Appearance) -> Box<dyn Element> {
|
||||
Container::new(
|
||||
Text::new_inline("New".to_string(), appearance.ui_font_family(), 14.)
|
||||
.with_color(PhenomenonStyle::modal_badge_text())
|
||||
.finish(),
|
||||
)
|
||||
.with_horizontal_padding(8.)
|
||||
.with_vertical_padding(2.)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
|
||||
.with_background(Fill::Solid(PhenomenonStyle::modal_badge_background()))
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_title(appearance: &Appearance) -> Box<dyn Element> {
|
||||
Text::new("Warp is now open-source", appearance.ui_font_family(), 20.)
|
||||
.with_color(PhenomenonStyle::modal_title_text())
|
||||
.with_style(Properties::default().weight(Weight::Semibold))
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_description(appearance: &Appearance) -> Box<dyn Element> {
|
||||
Text::new(
|
||||
"You, our community, can participate in building Warp using an agent-first workflow.",
|
||||
appearance.ui_font_family(),
|
||||
14.,
|
||||
)
|
||||
.with_color(PhenomenonStyle::modal_feature_description_text())
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Splits a plain text string on occurrences of `/feedback`, emitting
|
||||
/// `inline_code` fragments for each match and plain fragments for the rest.
|
||||
fn split_inline_code_fragments(text: &str) -> Vec<FormattedTextFragment> {
|
||||
const CODE_TOKEN: &str = "/feedback";
|
||||
let mut fragments = Vec::new();
|
||||
let mut remaining = text;
|
||||
while let Some(pos) = remaining.find(CODE_TOKEN) {
|
||||
if pos > 0 {
|
||||
fragments.push(FormattedTextFragment::plain_text(&remaining[..pos]));
|
||||
}
|
||||
fragments.push(FormattedTextFragment {
|
||||
text: CODE_TOKEN.into(),
|
||||
styles: FormattedTextStyles {
|
||||
inline_code: true,
|
||||
..Default::default()
|
||||
},
|
||||
});
|
||||
remaining = &remaining[pos + CODE_TOKEN.len()..];
|
||||
}
|
||||
if !remaining.is_empty() {
|
||||
fragments.push(FormattedTextFragment::plain_text(remaining));
|
||||
}
|
||||
fragments
|
||||
}
|
||||
|
||||
fn render_feature_description(item: &FeatureItem, appearance: &Appearance) -> Box<dyn Element> {
|
||||
let Some(link) = &item.inline_link else {
|
||||
return Text::new(item.description, appearance.ui_font_family(), 14.)
|
||||
.with_color(PhenomenonStyle::modal_feature_description_text())
|
||||
.finish();
|
||||
};
|
||||
|
||||
// Build a formatted description with an inline hyperlink and inline code.
|
||||
let (before, after) = item
|
||||
.description
|
||||
.split_once(link.text)
|
||||
.unwrap_or((item.description, ""));
|
||||
|
||||
let link_fragment = FormattedTextFragment {
|
||||
text: link.text.into(),
|
||||
styles: FormattedTextStyles {
|
||||
underline: true,
|
||||
hyperlink: Some(Hyperlink::Url(link.url.into())),
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
|
||||
let mut fragments = Self::split_inline_code_fragments(before);
|
||||
fragments.push(link_fragment);
|
||||
if !after.is_empty() {
|
||||
fragments.extend(Self::split_inline_code_fragments(after));
|
||||
}
|
||||
|
||||
let formatted = FormattedText::new([FormattedTextLine::Line(fragments)]);
|
||||
|
||||
FormattedTextElement::new(
|
||||
formatted,
|
||||
14.,
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_family(),
|
||||
PhenomenonStyle::modal_feature_description_text(),
|
||||
HighlightedHyperlink::default(),
|
||||
)
|
||||
.with_line_height_ratio(1.2)
|
||||
// Render the inline link in the same color as the description text so it
|
||||
// blends in; the underline (applied via FormattedTextStyles) still signals it's a link.
|
||||
.with_hyperlink_font_color(PhenomenonStyle::modal_feature_description_text())
|
||||
.register_default_click_handlers(|link, _ctx, app| {
|
||||
app.open_url(&link.url);
|
||||
})
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_feature_row(item: &FeatureItem, appearance: &Appearance) -> Box<dyn Element> {
|
||||
let icon_el = ConstrainedBox::new(
|
||||
item.icon
|
||||
.to_warpui_icon(Fill::Solid(
|
||||
PhenomenonStyle::modal_feature_description_text(),
|
||||
))
|
||||
.finish(),
|
||||
)
|
||||
.with_width(16.)
|
||||
.with_height(16.)
|
||||
.finish();
|
||||
|
||||
let text_col = Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
||||
.with_spacing(2.)
|
||||
.with_child(
|
||||
Text::new_inline(item.title.to_string(), appearance.ui_font_family(), 14.)
|
||||
.with_color(PhenomenonStyle::modal_feature_title_text())
|
||||
.finish(),
|
||||
)
|
||||
.with_child(Self::render_feature_description(item, appearance))
|
||||
.finish();
|
||||
|
||||
Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
||||
.with_spacing(10.)
|
||||
.with_child(icon_el)
|
||||
.with_child(Expanded::new(1., text_col).finish())
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_body(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
let mut features_col = Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
||||
.with_spacing(12.);
|
||||
for item in FEATURE_ITEMS {
|
||||
features_col.add_child(Self::render_feature_row(item, appearance));
|
||||
}
|
||||
|
||||
let cta = ChildView::new(&self.cta_button).finish();
|
||||
|
||||
Container::new(
|
||||
Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
||||
.with_child(
|
||||
Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
||||
.with_spacing(8.)
|
||||
.with_child(Self::render_badge(appearance))
|
||||
.with_child(Self::render_title(appearance))
|
||||
.with_child(Self::render_description(appearance))
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
Container::new(features_col.finish())
|
||||
.with_margin_top(16.)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(Container::new(cta).with_margin_top(32.).finish())
|
||||
.finish(),
|
||||
)
|
||||
.with_horizontal_padding(32.)
|
||||
.with_vertical_padding(32.)
|
||||
.with_background(Fill::Solid(PhenomenonStyle::modal_background()))
|
||||
.with_corner_radius(CornerRadius::with_bottom(Radius::Pixels(8.)))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for OpenWarpLaunchModal {
|
||||
type Event = OpenWarpLaunchModalEvent;
|
||||
}
|
||||
|
||||
impl View for OpenWarpLaunchModal {
|
||||
fn ui_name() -> &'static str {
|
||||
"OpenWarpLaunchModal"
|
||||
}
|
||||
|
||||
fn on_focus(&mut self, _focus_ctx: &warpui::FocusContext, ctx: &mut ViewContext<Self>) {
|
||||
ctx.focus_self();
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
|
||||
let card = ConstrainedBox::new(
|
||||
Container::new(
|
||||
Flex::column()
|
||||
.with_main_axis_size(MainAxisSize::Min)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
||||
.with_child(self.render_hero())
|
||||
.with_child(self.render_body(appearance))
|
||||
.finish(),
|
||||
)
|
||||
.with_background(Fill::Solid(PhenomenonStyle::modal_background()))
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
|
||||
.finish(),
|
||||
)
|
||||
.with_width(MODAL_WIDTH)
|
||||
.finish();
|
||||
|
||||
Container::new(Align::new(card).finish())
|
||||
.with_background_color(ColorU::new(18, 18, 18, 128))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for OpenWarpLaunchModal {
|
||||
type Action = OpenWarpLaunchModalAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
OpenWarpLaunchModalAction::Close => {
|
||||
ctx.emit(OpenWarpLaunchModalEvent::Close);
|
||||
}
|
||||
OpenWarpLaunchModalAction::VisitRepo => {
|
||||
ctx.open_url(REPO_URL);
|
||||
ctx.emit(OpenWarpLaunchModalEvent::Close);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,170 @@
|
||||
//! Logic to determine the working directory for new terminal sessions.
|
||||
|
||||
use super::Workspace;
|
||||
use crate::terminal::available_shells::AvailableShell;
|
||||
#[cfg(feature = "local_tty")]
|
||||
use crate::terminal::available_shells::AvailableShells;
|
||||
use crate::terminal::session_settings::{NewSessionSource, SessionSettings};
|
||||
use crate::terminal::ShellLaunchData;
|
||||
use std::path::PathBuf;
|
||||
use warpui::SingletonEntity;
|
||||
use warpui::{AppContext, ViewContext, WindowId};
|
||||
|
||||
impl Workspace {
|
||||
/// Helper function to compute the initial directory for a new session
|
||||
/// that is inheriting its initial directory from the active session in
|
||||
/// the given workspace.
|
||||
fn initial_directory_from_active_session(&self, ctx: &AppContext) -> Option<PathBuf> {
|
||||
(!self.tabs.is_empty())
|
||||
.then(|| {
|
||||
self.active_tab_pane_group().read(ctx, |pane_group, ctx| {
|
||||
pane_group.active_session_id(ctx).and_then(|base_pane_id| {
|
||||
pane_group.startup_path_for_new_session(Some(base_pane_id), ctx)
|
||||
})
|
||||
})
|
||||
})
|
||||
.flatten()
|
||||
}
|
||||
|
||||
/// Helper function to retrieve the shell launch data of the active session,
|
||||
/// which tells us whether it's a native or WSL session.
|
||||
fn shell_launch_info_from_active_session(&self, ctx: &AppContext) -> Option<ShellLaunchData> {
|
||||
(!self.tabs.is_empty())
|
||||
.then(|| {
|
||||
self.active_tab_pane_group().read(ctx, |pane_group, ctx| {
|
||||
pane_group.active_session_id(ctx).and_then(|base_pane_id| {
|
||||
pane_group.launch_data_for_session(base_pane_id, ctx)
|
||||
})
|
||||
})
|
||||
})
|
||||
.flatten()
|
||||
}
|
||||
|
||||
/// Helper function to compute the initial directory for a new session.
|
||||
/// Returns Some(path) if inheriting the initial directory from an active
|
||||
/// session or using the user's custom path setting,
|
||||
/// and None if the default startup directory (the user's home directory) should be used.
|
||||
pub(super) fn get_new_tab_startup_directory(
|
||||
&mut self,
|
||||
new_session_source: NewSessionSource,
|
||||
previous_session_window_id: Option<WindowId>,
|
||||
chosen_shell: Option<&AvailableShell>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Option<PathBuf> {
|
||||
// Get the Workspace from the window that hosted the previously-active
|
||||
// session.
|
||||
let active_session_info = match previous_session_window_id {
|
||||
// If the previous window is the one hosting this workspace, don't
|
||||
// do any indirection through AppContext.
|
||||
Some(window_id) if window_id == ctx.window_id() => Some((
|
||||
self.initial_directory_from_active_session(ctx),
|
||||
self.shell_launch_info_from_active_session(ctx),
|
||||
)),
|
||||
// Otherwise, lookup the Workspace in that window and query it.
|
||||
Some(window_id) => {
|
||||
let workspace_handle = ctx
|
||||
.views_of_type::<Workspace>(window_id)
|
||||
.and_then(|views| views.first().cloned());
|
||||
workspace_handle.map(|workspace| {
|
||||
workspace.read(ctx, |workspace, ctx| {
|
||||
(
|
||||
workspace.initial_directory_from_active_session(ctx),
|
||||
workspace.shell_launch_info_from_active_session(ctx),
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
let (prev_session_working_directory, prev_session_shell) =
|
||||
active_session_info.unwrap_or_default();
|
||||
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(feature = "local_tty")] {
|
||||
let is_wsl = new_session_shell(chosen_shell, ctx)
|
||||
.wsl_distro()
|
||||
.is_some();
|
||||
} else {
|
||||
let is_wsl = false;
|
||||
}
|
||||
}
|
||||
|
||||
let is_same_system = same_system(prev_session_shell.as_ref(), chosen_shell, ctx);
|
||||
|
||||
compute_startup_directory_from_prev_session(
|
||||
new_session_source,
|
||||
if is_same_system {
|
||||
prev_session_working_directory
|
||||
} else {
|
||||
None
|
||||
},
|
||||
is_wsl,
|
||||
ctx,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The shell to be used in the new session,
|
||||
/// based on the shell explicitly chosen by the user or
|
||||
/// the default startup shell specified in settings.
|
||||
#[cfg(feature = "local_tty")]
|
||||
fn new_session_shell(chosen_shell: Option<&AvailableShell>, ctx: &AppContext) -> AvailableShell {
|
||||
chosen_shell.cloned().unwrap_or_else(move || {
|
||||
AvailableShells::handle(ctx).read(ctx, |shells, ctx| shells.get_user_preferred_shell(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
/// Windows-specific helper function to determine whether the old and
|
||||
/// new shell sessions will exist in the same system, i.e. whether
|
||||
/// they're both on native Windows or both in the same WSL distribution.
|
||||
///
|
||||
/// Returns `true` if `old_session_launch_data` is `None`.
|
||||
#[cfg(feature = "local_tty")]
|
||||
fn same_system(
|
||||
old_session_launch_data: Option<&ShellLaunchData>,
|
||||
chosen_shell: Option<&AvailableShell>,
|
||||
ctx: &AppContext,
|
||||
) -> bool {
|
||||
// If there's no prior session, there is no prior system.
|
||||
// We're not crossing a system boundary, so return true.
|
||||
let Some(old_launch_data) = old_session_launch_data else {
|
||||
return true;
|
||||
};
|
||||
|
||||
let wsl_distro = new_session_shell(chosen_shell, ctx).wsl_distro();
|
||||
match old_launch_data {
|
||||
ShellLaunchData::WSL { distro: old_distro } => {
|
||||
wsl_distro.is_some_and(|new_distro| new_distro == *old_distro)
|
||||
}
|
||||
_ => wsl_distro.is_none(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "local_tty"))]
|
||||
const fn same_system(
|
||||
_old_session_launch_data: Option<&ShellLaunchData>,
|
||||
_chosen_shell: Option<&AvailableShell>,
|
||||
_ctx: &AppContext,
|
||||
) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Helper function to compute the actual startup directory for the
|
||||
/// new session based on the user's settings.
|
||||
fn compute_startup_directory_from_prev_session(
|
||||
new_session_source: NewSessionSource,
|
||||
initial_directory_from_prev_session: Option<PathBuf>,
|
||||
ignore_custom_directory: bool,
|
||||
ctx: &ViewContext<Workspace>,
|
||||
) -> Option<PathBuf> {
|
||||
SessionSettings::handle(ctx).read(ctx, |settings, _ctx| {
|
||||
settings
|
||||
.working_directory_config
|
||||
.initial_directory_for_new_session(
|
||||
new_session_source,
|
||||
initial_directory_from_prev_session,
|
||||
ignore_custom_directory,
|
||||
)
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,168 @@
|
||||
use serde_json::{json, Value};
|
||||
use strum_macros::{EnumDiscriminants, EnumIter};
|
||||
use warp_core::features::FeatureFlag;
|
||||
use warp_core::telemetry::{EnablementState, TelemetryEvent, TelemetryEventDesc};
|
||||
|
||||
use crate::workspace::tab_settings::{
|
||||
VerticalTabsCompactSubtitle, VerticalTabsDisplayGranularity, VerticalTabsPrimaryInfo,
|
||||
VerticalTabsTabItemMode, VerticalTabsViewMode,
|
||||
};
|
||||
|
||||
/// Which display option on the vertical tabs settings popup the user changed,
|
||||
/// along with the new value they picked.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum VerticalTabsDisplayOption {
|
||||
DisplayGranularity(VerticalTabsDisplayGranularity),
|
||||
TabItemMode(VerticalTabsTabItemMode),
|
||||
ViewMode(VerticalTabsViewMode),
|
||||
PrimaryInfo(VerticalTabsPrimaryInfo),
|
||||
CompactSubtitle(VerticalTabsCompactSubtitle),
|
||||
ShowPrLink(bool),
|
||||
ShowDiffStats(bool),
|
||||
ShowDetailsOnHover(bool),
|
||||
}
|
||||
|
||||
impl VerticalTabsDisplayOption {
|
||||
fn option_name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::DisplayGranularity(_) => "display_granularity",
|
||||
Self::TabItemMode(_) => "tab_item_mode",
|
||||
Self::ViewMode(_) => "view_mode",
|
||||
Self::PrimaryInfo(_) => "primary_info",
|
||||
Self::CompactSubtitle(_) => "compact_subtitle",
|
||||
Self::ShowPrLink(_) => "show_pr_link",
|
||||
Self::ShowDiffStats(_) => "show_diff_stats",
|
||||
Self::ShowDetailsOnHover(_) => "show_details_on_hover",
|
||||
}
|
||||
}
|
||||
|
||||
fn serialized_value(&self) -> Value {
|
||||
match self {
|
||||
Self::DisplayGranularity(VerticalTabsDisplayGranularity::Panes) => json!("panes"),
|
||||
Self::DisplayGranularity(VerticalTabsDisplayGranularity::Tabs) => json!("tabs"),
|
||||
Self::TabItemMode(VerticalTabsTabItemMode::FocusedSession) => json!("focused_session"),
|
||||
Self::TabItemMode(VerticalTabsTabItemMode::Summary) => json!("summary"),
|
||||
Self::ViewMode(VerticalTabsViewMode::Compact) => json!("compact"),
|
||||
Self::ViewMode(VerticalTabsViewMode::Expanded) => json!("expanded"),
|
||||
Self::PrimaryInfo(VerticalTabsPrimaryInfo::Command) => json!("command"),
|
||||
Self::PrimaryInfo(VerticalTabsPrimaryInfo::WorkingDirectory) => {
|
||||
json!("working_directory")
|
||||
}
|
||||
Self::PrimaryInfo(VerticalTabsPrimaryInfo::Branch) => json!("branch"),
|
||||
Self::CompactSubtitle(VerticalTabsCompactSubtitle::Branch) => json!("branch"),
|
||||
Self::CompactSubtitle(VerticalTabsCompactSubtitle::WorkingDirectory) => {
|
||||
json!("working_directory")
|
||||
}
|
||||
Self::CompactSubtitle(VerticalTabsCompactSubtitle::Command) => json!("command"),
|
||||
Self::ShowPrLink(value) => json!(value),
|
||||
Self::ShowDiffStats(value) => json!(value),
|
||||
Self::ShowDetailsOnHover(value) => json!(value),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where in the vertical tabs UI a clickable diff-stats or GitHub PR chip
|
||||
/// was rendered when the user clicked it.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum VerticalTabsChipEntrypoint {
|
||||
/// The chip was rendered on a row representing a single pane
|
||||
/// (display granularity: Panes).
|
||||
Pane,
|
||||
/// The chip was rendered on a row representing a tab group
|
||||
/// (display granularity: Tabs).
|
||||
Tab,
|
||||
/// The chip was rendered inside the detail sidecar that appears on row hover.
|
||||
DetailsSidecar,
|
||||
}
|
||||
|
||||
impl VerticalTabsChipEntrypoint {
|
||||
fn serialized(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Pane => "pane",
|
||||
Self::Tab => "tab",
|
||||
Self::DetailsSidecar => "details_sidecar",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, EnumDiscriminants)]
|
||||
#[strum_discriminants(derive(EnumIter))]
|
||||
pub enum VerticalTabsTelemetryEvent {
|
||||
/// The user updated a display option in the vertical tabs settings popup.
|
||||
DisplayOptionChanged(VerticalTabsDisplayOption),
|
||||
/// The user clicked the diff stats chip on a vertical tabs row or the detail sidecar.
|
||||
DiffStatsChipClicked {
|
||||
entrypoint: VerticalTabsChipEntrypoint,
|
||||
},
|
||||
/// The user clicked the GitHub PR chip on a vertical tabs row or the detail sidecar.
|
||||
PrChipClicked {
|
||||
entrypoint: VerticalTabsChipEntrypoint,
|
||||
},
|
||||
}
|
||||
|
||||
impl TelemetryEvent for VerticalTabsTelemetryEvent {
|
||||
fn name(&self) -> &'static str {
|
||||
VerticalTabsTelemetryEventDiscriminants::from(self).name()
|
||||
}
|
||||
|
||||
fn payload(&self) -> Option<Value> {
|
||||
match self {
|
||||
Self::DisplayOptionChanged(option) => Some(json!({
|
||||
"option": option.option_name(),
|
||||
"value": option.serialized_value(),
|
||||
})),
|
||||
Self::DiffStatsChipClicked { entrypoint } => Some(json!({
|
||||
"entrypoint": entrypoint.serialized(),
|
||||
})),
|
||||
Self::PrChipClicked { entrypoint } => Some(json!({
|
||||
"entrypoint": entrypoint.serialized(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
VerticalTabsTelemetryEventDiscriminants::from(self).description()
|
||||
}
|
||||
|
||||
fn enablement_state(&self) -> EnablementState {
|
||||
VerticalTabsTelemetryEventDiscriminants::from(self).enablement_state()
|
||||
}
|
||||
|
||||
fn contains_ugc(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn event_descs() -> impl Iterator<Item = Box<dyn TelemetryEventDesc>> {
|
||||
warp_core::telemetry::enum_events::<Self>()
|
||||
}
|
||||
}
|
||||
|
||||
impl TelemetryEventDesc for VerticalTabsTelemetryEventDiscriminants {
|
||||
fn name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::DisplayOptionChanged => "VerticalTabs.DisplayOptionChanged",
|
||||
Self::DiffStatsChipClicked => "VerticalTabs.DiffStatsChipClicked",
|
||||
Self::PrChipClicked => "VerticalTabs.PrChipClicked",
|
||||
}
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
match self {
|
||||
Self::DisplayOptionChanged => {
|
||||
"User updated a display option in the vertical tabs settings popup"
|
||||
}
|
||||
Self::DiffStatsChipClicked => {
|
||||
"User clicked a diff stats chip in the vertical tabs panel or detail sidecar"
|
||||
}
|
||||
Self::PrChipClicked => {
|
||||
"User clicked a GitHub PR chip in the vertical tabs panel or detail sidecar"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn enablement_state(&self) -> EnablementState {
|
||||
EnablementState::Flag(FeatureFlag::VerticalTabs)
|
||||
}
|
||||
}
|
||||
|
||||
warp_core::register_telemetry_event!(VerticalTabsTelemetryEvent);
|
||||
@@ -0,0 +1,990 @@
|
||||
use crate::context_chips::display_chip::GitLineChanges;
|
||||
use crate::pane_group::pane::IPaneType;
|
||||
use crate::pane_group::{PaneId, TerminalPaneId};
|
||||
use crate::safe_triangle::SafeTriangle;
|
||||
use crate::terminal::CLIAgent;
|
||||
use crate::workspace::tab_settings::VerticalTabsDisplayGranularity;
|
||||
use pathfinder_geometry::rect::RectF;
|
||||
use pathfinder_geometry::vector::Vector2F;
|
||||
use std::path::PathBuf;
|
||||
use warpui::elements::PositionedElementOffsetBounds;
|
||||
use warpui::EntityId;
|
||||
|
||||
use super::{
|
||||
branch_label_display, coalesce_summary_branch_entries, code_detail_kind_label,
|
||||
compact_branch_subtitle_display, detail_sidecar_width_and_bounds,
|
||||
detail_target_for_hovered_row, format_summary_primary_labels,
|
||||
non_terminal_search_text_fragments, pane_ids_for_display_granularity,
|
||||
pane_search_text_fragments, preferred_agent_tab_titles, search_fragments_contain_query,
|
||||
select_summary_pane_kind_icons, should_keep_detail_sidecar_visible_for_mouse_position,
|
||||
summary_overflow_count, summary_search_text_fragments, terminal_kind_badge_label,
|
||||
terminal_primary_line_data, terminal_pull_request_badge_label, terminal_search_text_fragments,
|
||||
terminal_title_fallback_font, uses_outer_group_container, visible_pane_ids_for_detail_target,
|
||||
vtab_diff_stats_text, AgentTabTextPreference, SummaryPaneKind, SummaryPaneKindIcons,
|
||||
TerminalAgentText, TerminalPrimaryLineData, TerminalPrimaryLineFont, VerticalTabsDetailTarget,
|
||||
VerticalTabsDetailTargetKind, VerticalTabsSummaryBranchEntry, VerticalTabsSummaryData,
|
||||
};
|
||||
|
||||
fn pane_id() -> PaneId {
|
||||
TerminalPaneId::dummy_terminal_pane_id().into()
|
||||
}
|
||||
fn code_summary_kind(title: &str) -> SummaryPaneKind {
|
||||
SummaryPaneKind::Code {
|
||||
title: title.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_pane_kind_icons_render_single_icon_for_homogeneous_tabs() {
|
||||
assert_eq!(
|
||||
select_summary_pane_kind_icons([
|
||||
(EntityId::from_usize(10), SummaryPaneKind::Terminal),
|
||||
(EntityId::from_usize(20), SummaryPaneKind::Terminal),
|
||||
]),
|
||||
Some(SummaryPaneKindIcons::Single(SummaryPaneKind::Terminal))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_pane_kind_icons_pick_two_oldest_distinct_pane_kinds() {
|
||||
assert_eq!(
|
||||
select_summary_pane_kind_icons([
|
||||
(EntityId::from_usize(30), SummaryPaneKind::Terminal),
|
||||
(EntityId::from_usize(20), code_summary_kind("main.rs")),
|
||||
(
|
||||
EntityId::from_usize(40),
|
||||
SummaryPaneKind::Notebook { is_plan: false },
|
||||
),
|
||||
(EntityId::from_usize(10), SummaryPaneKind::Terminal),
|
||||
]),
|
||||
Some(SummaryPaneKindIcons::Pair {
|
||||
primary: SummaryPaneKind::Terminal,
|
||||
secondary: code_summary_kind("main.rs"),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_pane_kind_icons_recompute_when_oldest_kind_is_removed() {
|
||||
assert_eq!(
|
||||
select_summary_pane_kind_icons([
|
||||
(EntityId::from_usize(20), code_summary_kind("main.rs")),
|
||||
(EntityId::from_usize(30), SummaryPaneKind::Terminal),
|
||||
]),
|
||||
Some(SummaryPaneKindIcons::Pair {
|
||||
primary: code_summary_kind("main.rs"),
|
||||
secondary: SummaryPaneKind::Terminal,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_pane_kind_icons_distinguish_agent_terminals_from_plain_terminals() {
|
||||
assert_eq!(
|
||||
select_summary_pane_kind_icons([
|
||||
(EntityId::from_usize(10), SummaryPaneKind::Terminal),
|
||||
(
|
||||
EntityId::from_usize(20),
|
||||
SummaryPaneKind::CLIAgent {
|
||||
agent: CLIAgent::Claude,
|
||||
},
|
||||
),
|
||||
(
|
||||
EntityId::from_usize(30),
|
||||
SummaryPaneKind::OzAgent { is_ambient: false },
|
||||
),
|
||||
]),
|
||||
Some(SummaryPaneKindIcons::Pair {
|
||||
primary: SummaryPaneKind::Terminal,
|
||||
secondary: SummaryPaneKind::CLIAgent {
|
||||
agent: CLIAgent::Claude,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferred_agent_tab_titles_default_to_title_like_text() {
|
||||
let agent_text = TerminalAgentText {
|
||||
conversation_display_title: Some("Generated Oz title".to_string()),
|
||||
conversation_latest_user_prompt: Some("Latest Oz prompt".to_string()),
|
||||
cli_agent_title: Some("CLI summary".to_string()),
|
||||
cli_agent_latest_user_prompt: Some("Latest CLI prompt".to_string()),
|
||||
is_oz_agent: true,
|
||||
cli_agent: Some(CLIAgent::Claude),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
preferred_agent_tab_titles(&agent_text, AgentTabTextPreference::ConversationTitle),
|
||||
(
|
||||
Some("Generated Oz title".to_string()),
|
||||
Some("CLI summary".to_string())
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferred_agent_tab_titles_do_not_use_cli_prompt_when_disabled() {
|
||||
let agent_text = TerminalAgentText {
|
||||
conversation_display_title: None,
|
||||
conversation_latest_user_prompt: None,
|
||||
cli_agent_title: None,
|
||||
cli_agent_latest_user_prompt: Some("Latest CLI prompt".to_string()),
|
||||
is_oz_agent: false,
|
||||
cli_agent: Some(CLIAgent::Claude),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
preferred_agent_tab_titles(&agent_text, AgentTabTextPreference::ConversationTitle),
|
||||
(None, None)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_primary_line_uses_terminal_title_when_disabled_cli_has_only_prompt() {
|
||||
let agent_text = TerminalAgentText {
|
||||
conversation_display_title: None,
|
||||
conversation_latest_user_prompt: None,
|
||||
cli_agent_title: None,
|
||||
cli_agent_latest_user_prompt: Some("Latest CLI prompt".to_string()),
|
||||
is_oz_agent: false,
|
||||
cli_agent: Some(CLIAgent::Claude),
|
||||
};
|
||||
let (conversation_title, cli_title) =
|
||||
preferred_agent_tab_titles(&agent_text, AgentTabTextPreference::ConversationTitle);
|
||||
|
||||
let line = terminal_primary_line_data(
|
||||
false,
|
||||
conversation_title,
|
||||
cli_title,
|
||||
"Generated Claude Code title",
|
||||
"~/warp",
|
||||
terminal_title_fallback_font(&agent_text),
|
||||
Some("claude".to_string()),
|
||||
);
|
||||
|
||||
assert_eq!(line.text(), "Generated Claude Code title");
|
||||
assert!(matches!(
|
||||
line,
|
||||
TerminalPrimaryLineData::Text {
|
||||
font: TerminalPrimaryLineFont::Ui,
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferred_agent_tab_titles_use_latest_prompt_when_enabled() {
|
||||
let agent_text = TerminalAgentText {
|
||||
conversation_display_title: Some("Generated Oz title".to_string()),
|
||||
conversation_latest_user_prompt: Some("Latest Oz prompt".to_string()),
|
||||
cli_agent_title: Some("CLI summary".to_string()),
|
||||
cli_agent_latest_user_prompt: Some("Latest CLI prompt".to_string()),
|
||||
is_oz_agent: true,
|
||||
cli_agent: Some(CLIAgent::Claude),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
preferred_agent_tab_titles(&agent_text, AgentTabTextPreference::LatestUserPrompt),
|
||||
(
|
||||
Some("Latest Oz prompt".to_string()),
|
||||
Some("Latest CLI prompt".to_string())
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_primary_line_uses_cli_prompt_when_enabled_cli_has_prompt() {
|
||||
let agent_text = TerminalAgentText {
|
||||
conversation_display_title: None,
|
||||
conversation_latest_user_prompt: None,
|
||||
cli_agent_title: None,
|
||||
cli_agent_latest_user_prompt: Some("Latest CLI prompt".to_string()),
|
||||
is_oz_agent: false,
|
||||
cli_agent: Some(CLIAgent::Claude),
|
||||
};
|
||||
let (conversation_title, cli_title) =
|
||||
preferred_agent_tab_titles(&agent_text, AgentTabTextPreference::LatestUserPrompt);
|
||||
|
||||
let line = terminal_primary_line_data(
|
||||
false,
|
||||
conversation_title,
|
||||
cli_title,
|
||||
"Generated Claude Code title",
|
||||
"~/warp",
|
||||
terminal_title_fallback_font(&agent_text),
|
||||
Some("claude".to_string()),
|
||||
);
|
||||
|
||||
assert_eq!(line.text(), "Latest CLI prompt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_primary_line_uses_cli_prompt_when_enabled_cli_is_long_running() {
|
||||
let agent_text = TerminalAgentText {
|
||||
conversation_display_title: None,
|
||||
conversation_latest_user_prompt: None,
|
||||
cli_agent_title: None,
|
||||
cli_agent_latest_user_prompt: Some("Latest CLI prompt".to_string()),
|
||||
is_oz_agent: false,
|
||||
cli_agent: Some(CLIAgent::Claude),
|
||||
};
|
||||
let (conversation_title, cli_title) =
|
||||
preferred_agent_tab_titles(&agent_text, AgentTabTextPreference::LatestUserPrompt);
|
||||
|
||||
let line = terminal_primary_line_data(
|
||||
true,
|
||||
conversation_title,
|
||||
cli_title,
|
||||
"Generated Claude Code title",
|
||||
"~/warp",
|
||||
terminal_title_fallback_font(&agent_text),
|
||||
Some("claude".to_string()),
|
||||
);
|
||||
|
||||
assert_eq!(line.text(), "Latest CLI prompt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferred_agent_tab_titles_fall_back_when_preferred_text_is_missing() {
|
||||
let agent_text = TerminalAgentText {
|
||||
conversation_display_title: Some("Generated Oz title".to_string()),
|
||||
conversation_latest_user_prompt: None,
|
||||
cli_agent_title: None,
|
||||
cli_agent_latest_user_prompt: Some("Latest CLI prompt".to_string()),
|
||||
is_oz_agent: true,
|
||||
cli_agent: Some(CLIAgent::Claude),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
preferred_agent_tab_titles(&agent_text, AgentTabTextPreference::LatestUserPrompt),
|
||||
(
|
||||
Some("Generated Oz title".to_string()),
|
||||
Some("Latest CLI prompt".to_string())
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
fn pane_type_supports_vertical_tabs_detail_sidecar(pane_type: IPaneType) -> bool {
|
||||
matches!(
|
||||
pane_type,
|
||||
IPaneType::Terminal
|
||||
| IPaneType::Code
|
||||
| IPaneType::Notebook
|
||||
| IPaneType::Workflow
|
||||
| IPaneType::EnvVarCollection
|
||||
| IPaneType::AIFact
|
||||
| IPaneType::AIDocument
|
||||
)
|
||||
}
|
||||
|
||||
fn collect_normalized_unique_summary_texts(
|
||||
texts: impl IntoIterator<Item = impl AsRef<str>>,
|
||||
) -> Vec<String> {
|
||||
texts
|
||||
.into_iter()
|
||||
.filter_map(|text| {
|
||||
let normalized = text
|
||||
.as_ref()
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
(!normalized.is_empty()).then_some(normalized)
|
||||
})
|
||||
.fold(Vec::new(), |mut values, normalized| {
|
||||
if !values.contains(&normalized) {
|
||||
values.push(normalized);
|
||||
}
|
||||
values
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detail_sidecar_supports_terminal_code_and_warp_drive_object_panes() {
|
||||
assert!(pane_type_supports_vertical_tabs_detail_sidecar(
|
||||
IPaneType::Terminal
|
||||
));
|
||||
assert!(pane_type_supports_vertical_tabs_detail_sidecar(
|
||||
IPaneType::Code
|
||||
));
|
||||
assert!(pane_type_supports_vertical_tabs_detail_sidecar(
|
||||
IPaneType::Notebook
|
||||
));
|
||||
assert!(pane_type_supports_vertical_tabs_detail_sidecar(
|
||||
IPaneType::Workflow
|
||||
));
|
||||
assert!(pane_type_supports_vertical_tabs_detail_sidecar(
|
||||
IPaneType::EnvVarCollection
|
||||
));
|
||||
assert!(pane_type_supports_vertical_tabs_detail_sidecar(
|
||||
IPaneType::AIFact
|
||||
));
|
||||
assert!(pane_type_supports_vertical_tabs_detail_sidecar(
|
||||
IPaneType::AIDocument
|
||||
));
|
||||
assert!(!pane_type_supports_vertical_tabs_detail_sidecar(
|
||||
IPaneType::Settings
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_detail_kind_label_uses_programming_language_display_name() {
|
||||
assert_eq!(
|
||||
code_detail_kind_label("block_id.rs"),
|
||||
Some("Rust".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
code_detail_kind_label("Dockerfile"),
|
||||
Some("Dockerfile".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_detail_kind_label_returns_none_when_language_is_unknown() {
|
||||
assert_eq!(code_detail_kind_label("notes.txt"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detail_target_matches_panes_granularity() {
|
||||
let pane_group_id = EntityId::new();
|
||||
let hovered_pane_id = pane_id();
|
||||
|
||||
assert_eq!(
|
||||
detail_target_for_hovered_row(
|
||||
pane_group_id,
|
||||
hovered_pane_id,
|
||||
VerticalTabsDisplayGranularity::Panes,
|
||||
),
|
||||
VerticalTabsDetailTarget::Pane {
|
||||
pane_group_id,
|
||||
pane_id: hovered_pane_id,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detail_target_matches_tabs_granularity() {
|
||||
let pane_group_id = EntityId::new();
|
||||
let hovered_pane_id = pane_id();
|
||||
|
||||
assert_eq!(
|
||||
detail_target_for_hovered_row(
|
||||
pane_group_id,
|
||||
hovered_pane_id,
|
||||
VerticalTabsDisplayGranularity::Tabs,
|
||||
),
|
||||
VerticalTabsDetailTarget::Tab {
|
||||
pane_group_id,
|
||||
source_pane_id: hovered_pane_id,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pane_detail_target_returns_hovered_pane_when_supported() {
|
||||
let hovered_pane_id = pane_id();
|
||||
|
||||
assert_eq!(
|
||||
visible_pane_ids_for_detail_target(
|
||||
&[hovered_pane_id],
|
||||
hovered_pane_id,
|
||||
VerticalTabsDetailTargetKind::Pane,
|
||||
|pane_id| pane_id == hovered_pane_id,
|
||||
),
|
||||
Some(vec![hovered_pane_id])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pane_detail_target_returns_none_when_hovered_pane_is_not_supported() {
|
||||
let hovered_pane_id = pane_id();
|
||||
|
||||
assert_eq!(
|
||||
visible_pane_ids_for_detail_target(
|
||||
&[hovered_pane_id],
|
||||
hovered_pane_id,
|
||||
VerticalTabsDetailTargetKind::Pane,
|
||||
|_| false,
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_detail_target_returns_all_visible_panes_when_every_pane_is_supported() {
|
||||
let pane_1 = pane_id();
|
||||
let pane_2 = pane_id();
|
||||
let pane_3 = pane_id();
|
||||
let visible_pane_ids = vec![pane_1, pane_2, pane_3];
|
||||
|
||||
assert_eq!(
|
||||
visible_pane_ids_for_detail_target(
|
||||
&visible_pane_ids,
|
||||
pane_2,
|
||||
VerticalTabsDetailTargetKind::Tab,
|
||||
|_| true,
|
||||
),
|
||||
Some(visible_pane_ids)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_detail_target_returns_none_for_mixed_support_tabs() {
|
||||
let pane_1 = pane_id();
|
||||
let pane_2 = pane_id();
|
||||
let pane_3 = pane_id();
|
||||
|
||||
assert_eq!(
|
||||
visible_pane_ids_for_detail_target(
|
||||
&[pane_1, pane_2, pane_3],
|
||||
pane_2,
|
||||
VerticalTabsDetailTargetKind::Tab,
|
||||
|pane_id| pane_id != pane_3,
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panes_granularity_returns_all_visible_panes_in_order() {
|
||||
let pane_1 = pane_id();
|
||||
let pane_2 = pane_id();
|
||||
let pane_3 = pane_id();
|
||||
let visible_pane_ids = vec![pane_1, pane_2, pane_3];
|
||||
|
||||
assert_eq!(
|
||||
pane_ids_for_display_granularity(
|
||||
&visible_pane_ids,
|
||||
pane_2,
|
||||
VerticalTabsDisplayGranularity::Panes,
|
||||
),
|
||||
visible_pane_ids
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tabs_granularity_returns_focused_pane_when_present() {
|
||||
let pane_1 = pane_id();
|
||||
let pane_2 = pane_id();
|
||||
let pane_3 = pane_id();
|
||||
|
||||
assert_eq!(
|
||||
pane_ids_for_display_granularity(
|
||||
&[pane_1, pane_2, pane_3],
|
||||
pane_2,
|
||||
VerticalTabsDisplayGranularity::Tabs,
|
||||
),
|
||||
vec![pane_2]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tabs_granularity_falls_back_to_first_visible_pane_when_focused_pane_is_absent() {
|
||||
let pane_1 = pane_id();
|
||||
let pane_2 = pane_id();
|
||||
let pane_3 = pane_id();
|
||||
let focused_pane = pane_id();
|
||||
|
||||
assert_eq!(
|
||||
pane_ids_for_display_granularity(
|
||||
&[pane_1, pane_2, pane_3],
|
||||
focused_pane,
|
||||
VerticalTabsDisplayGranularity::Tabs,
|
||||
),
|
||||
vec![pane_1]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tabs_granularity_returns_empty_for_empty_visible_panes() {
|
||||
assert_eq!(
|
||||
pane_ids_for_display_granularity(&[], pane_id(), VerticalTabsDisplayGranularity::Tabs,),
|
||||
Vec::<PaneId>::new()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detail_sidecar_uses_default_width_when_space_allows() {
|
||||
let (width, bounds) = detail_sidecar_width_and_bounds(400.);
|
||||
assert_eq!(width, 320.);
|
||||
assert!(matches!(
|
||||
bounds,
|
||||
PositionedElementOffsetBounds::WindowBySize
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detail_sidecar_shrinks_to_fit_before_hitting_min_width() {
|
||||
let (width, bounds) = detail_sidecar_width_and_bounds(280.);
|
||||
assert_eq!(width, 280.);
|
||||
assert!(matches!(
|
||||
bounds,
|
||||
PositionedElementOffsetBounds::WindowBySize
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detail_sidecar_stops_shrinking_at_min_width_and_allows_clipping() {
|
||||
let (width, bounds) = detail_sidecar_width_and_bounds(180.);
|
||||
assert_eq!(width, 240.);
|
||||
assert!(matches!(bounds, PositionedElementOffsetBounds::Unbounded));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detail_sidecar_visibility_helper_keeps_sidecar_visible_inside_sidecar_bounds() {
|
||||
let row_rect = RectF::new(Vector2F::new(0., 100.), Vector2F::new(100., 40.));
|
||||
let sidecar_rect = RectF::new(Vector2F::new(120., 50.), Vector2F::new(180., 220.));
|
||||
let mut safe_triangle = SafeTriangle::new();
|
||||
|
||||
assert!(should_keep_detail_sidecar_visible_for_mouse_position(
|
||||
Vector2F::new(200., 120.),
|
||||
Some(row_rect),
|
||||
Some(sidecar_rect),
|
||||
&mut safe_triangle,
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detail_sidecar_visibility_helper_keeps_sidecar_visible_in_safe_triangle() {
|
||||
let row_rect = RectF::new(Vector2F::new(0., 100.), Vector2F::new(100., 40.));
|
||||
let sidecar_rect = RectF::new(Vector2F::new(120., 50.), Vector2F::new(180., 220.));
|
||||
let mut safe_triangle = SafeTriangle::new();
|
||||
safe_triangle.set_target_rect(Some(sidecar_rect));
|
||||
safe_triangle.update_position(Vector2F::new(90., 120.));
|
||||
|
||||
assert!(should_keep_detail_sidecar_visible_for_mouse_position(
|
||||
Vector2F::new(110., 120.),
|
||||
Some(row_rect),
|
||||
Some(sidecar_rect),
|
||||
&mut safe_triangle,
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detail_sidecar_visibility_helper_clears_sidecar_outside_row_sidecar_and_safe_triangle() {
|
||||
let row_rect = RectF::new(Vector2F::new(0., 100.), Vector2F::new(100., 40.));
|
||||
let sidecar_rect = RectF::new(Vector2F::new(120., 50.), Vector2F::new(180., 220.));
|
||||
let mut safe_triangle = SafeTriangle::new();
|
||||
safe_triangle.update_position(Vector2F::new(200., 120.));
|
||||
|
||||
assert!(!should_keep_detail_sidecar_visible_for_mouse_position(
|
||||
Vector2F::new(340., 120.),
|
||||
Some(row_rect),
|
||||
Some(sidecar_rect),
|
||||
&mut safe_triangle,
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panes_granularity_uses_outer_group_container() {
|
||||
assert!(uses_outer_group_container(
|
||||
VerticalTabsDisplayGranularity::Panes
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tabs_granularity_does_not_use_outer_group_container() {
|
||||
assert!(!uses_outer_group_container(
|
||||
VerticalTabsDisplayGranularity::Tabs
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_primary_line_prefers_cli_agent_display_title() {
|
||||
let line = terminal_primary_line_data(
|
||||
false,
|
||||
None,
|
||||
Some("Review the failing tests".to_string()),
|
||||
"~/warp",
|
||||
"~/warp",
|
||||
TerminalPrimaryLineFont::Monospace,
|
||||
Some("cargo nextest run".to_string()),
|
||||
);
|
||||
|
||||
assert_eq!(line.text(), "Review the failing tests");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_primary_line_prefers_cli_agent_display_title_over_conversation_title() {
|
||||
let line = terminal_primary_line_data(
|
||||
false,
|
||||
Some("Review the failing tests".to_string()),
|
||||
Some("Summarize the failures".to_string()),
|
||||
"~/warp",
|
||||
"~/warp",
|
||||
TerminalPrimaryLineFont::Monospace,
|
||||
Some("cargo nextest run".to_string()),
|
||||
);
|
||||
|
||||
assert_eq!(line.text(), "Summarize the failures");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_primary_line_falls_through_to_terminal_title_when_cli_agent_has_no_plugin_data() {
|
||||
let line = terminal_primary_line_data(
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
"codex - ~/warp",
|
||||
"~/warp",
|
||||
TerminalPrimaryLineFont::Monospace,
|
||||
Some("cargo nextest run".to_string()),
|
||||
);
|
||||
|
||||
assert_eq!(line.text(), "codex - ~/warp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_primary_line_uses_terminal_title_as_fallback() {
|
||||
let line = terminal_primary_line_data(
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
"nvim src/workspace/view/vertical_tabs.rs",
|
||||
"~/warp",
|
||||
TerminalPrimaryLineFont::Monospace,
|
||||
Some("cargo nextest run".to_string()),
|
||||
);
|
||||
|
||||
assert_eq!(line.text(), "nvim src/workspace/view/vertical_tabs.rs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_primary_line_uses_last_completed_command_when_shell_title_matches_working_directory() {
|
||||
let line = terminal_primary_line_data(
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
"~/warp",
|
||||
"~/warp",
|
||||
TerminalPrimaryLineFont::Monospace,
|
||||
Some("cargo nextest run".to_string()),
|
||||
);
|
||||
|
||||
assert_eq!(line.text(), "cargo nextest run");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_primary_line_falls_back_to_new_session() {
|
||||
let line = terminal_primary_line_data(
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
"~/warp",
|
||||
"~/warp",
|
||||
TerminalPrimaryLineFont::Monospace,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(line.text(), "New session");
|
||||
assert!(matches!(
|
||||
line,
|
||||
TerminalPrimaryLineData::Text {
|
||||
font: TerminalPrimaryLineFont::Ui,
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_primary_line_uses_monospace_for_last_completed_command() {
|
||||
let line = terminal_primary_line_data(
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
"~/warp",
|
||||
"~/warp",
|
||||
TerminalPrimaryLineFont::Monospace,
|
||||
Some("cargo nextest run".to_string()),
|
||||
);
|
||||
|
||||
assert!(matches!(
|
||||
line,
|
||||
TerminalPrimaryLineData::Text {
|
||||
font: TerminalPrimaryLineFont::Monospace,
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_search_fragments_include_rendered_terminal_badges() {
|
||||
let fragments = terminal_search_text_fragments(
|
||||
"Review the failing tests".to_string(),
|
||||
"~/warp".to_string(),
|
||||
Some("main".to_string()),
|
||||
terminal_kind_badge_label(false, Some(CLIAgent::Claude)),
|
||||
Some(terminal_pull_request_badge_label(
|
||||
"https://github.com/warpdotdev/warp-internal/pull/12345",
|
||||
)),
|
||||
Some(GitLineChanges {
|
||||
files_changed: 1,
|
||||
lines_added: 2,
|
||||
lines_removed: 3,
|
||||
}),
|
||||
);
|
||||
|
||||
assert!(search_fragments_contain_query(&fragments, "claude"));
|
||||
assert!(search_fragments_contain_query(
|
||||
&fragments,
|
||||
"review the failing tests"
|
||||
));
|
||||
assert!(search_fragments_contain_query(&fragments, "#12345"));
|
||||
assert!(search_fragments_contain_query(&fragments, "+2"));
|
||||
assert!(search_fragments_contain_query(&fragments, "-3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pane_search_fragments_prepend_custom_title_and_keep_generated_metadata() {
|
||||
let fragments = pane_search_text_fragments(
|
||||
Some("Production API"),
|
||||
vec![
|
||||
"cargo nextest run".to_string(),
|
||||
"~/warp".to_string(),
|
||||
"Claude".to_string(),
|
||||
],
|
||||
);
|
||||
|
||||
assert_eq!(fragments[0], "Production API");
|
||||
assert!(search_fragments_contain_query(&fragments, "production api"));
|
||||
assert!(search_fragments_contain_query(&fragments, "cargo nextest"));
|
||||
assert!(search_fragments_contain_query(&fragments, "~/warp"));
|
||||
assert!(search_fragments_contain_query(&fragments, "claude"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pane_search_fragments_dedupe_custom_title_against_generated_text() {
|
||||
assert_eq!(
|
||||
pane_search_text_fragments(
|
||||
Some(" Production API "),
|
||||
vec![
|
||||
"Production API".to_string(),
|
||||
"~/warp".to_string(),
|
||||
"~/warp".to_string(),
|
||||
],
|
||||
),
|
||||
vec!["Production API".to_string(), "~/warp".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_terminal_search_fragments_only_include_rendered_text() {
|
||||
let fragments = non_terminal_search_text_fragments("Pane title", "and 2 more");
|
||||
|
||||
assert!(search_fragments_contain_query(&fragments, "pane title"));
|
||||
assert!(search_fragments_contain_query(&fragments, "and 2 more"));
|
||||
assert!(!search_fragments_contain_query(&fragments, "notebook"));
|
||||
assert!(!search_fragments_contain_query(&fragments, "unsaved"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_stats_text_matches_rendered_badge_text() {
|
||||
assert_eq!(
|
||||
vtab_diff_stats_text(&GitLineChanges {
|
||||
files_changed: 1,
|
||||
lines_added: 2,
|
||||
lines_removed: 3,
|
||||
}),
|
||||
"+2 -3"
|
||||
);
|
||||
assert_eq!(
|
||||
vtab_diff_stats_text(&GitLineChanges {
|
||||
files_changed: 1,
|
||||
lines_added: 0,
|
||||
lines_removed: 0,
|
||||
}),
|
||||
"0"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn branch_label_display_falls_back_without_branch_icon() {
|
||||
assert_eq!(
|
||||
branch_label_display(None, "~/warp"),
|
||||
("~/warp".to_string(), false)
|
||||
);
|
||||
assert_eq!(
|
||||
branch_label_display(Some(""), "~/warp"),
|
||||
("~/warp".to_string(), false)
|
||||
);
|
||||
assert_eq!(
|
||||
branch_label_display(Some("main"), "~/warp"),
|
||||
("main".to_string(), true)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_branch_subtitle_falls_back_to_working_directory_without_branch_icon() {
|
||||
assert_eq!(
|
||||
compact_branch_subtitle_display(None, Some("~/warp")),
|
||||
Some(("~/warp".to_string(), false))
|
||||
);
|
||||
assert_eq!(
|
||||
compact_branch_subtitle_display(Some(""), Some("~/warp")),
|
||||
Some(("~/warp".to_string(), false))
|
||||
);
|
||||
assert_eq!(
|
||||
compact_branch_subtitle_display(Some("main"), Some("~/warp")),
|
||||
Some(("main".to_string(), true))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_normalized_unique_summary_texts_dedupes_after_whitespace_normalization() {
|
||||
assert_eq!(
|
||||
collect_normalized_unique_summary_texts([
|
||||
" cargo test ",
|
||||
"cargo test",
|
||||
"",
|
||||
" git status ",
|
||||
]),
|
||||
vec!["cargo test".to_string(), "git status".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_normalized_unique_summary_texts_preserves_first_seen_order() {
|
||||
assert_eq!(
|
||||
collect_normalized_unique_summary_texts([
|
||||
"~/warp-internal",
|
||||
"~/warp-server",
|
||||
"~/warp-internal",
|
||||
"~/warp-terraform",
|
||||
]),
|
||||
vec![
|
||||
"~/warp-internal".to_string(),
|
||||
"~/warp-server".to_string(),
|
||||
"~/warp-terraform".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coalesce_summary_branch_entries_groups_by_repo_and_branch() {
|
||||
let repo_a = PathBuf::from("/tmp/repo-a");
|
||||
let repo_b = PathBuf::from("/tmp/repo-b");
|
||||
let entries = vec![
|
||||
VerticalTabsSummaryBranchEntry {
|
||||
repo_path: repo_a.clone(),
|
||||
branch_name: "main".to_string(),
|
||||
diff_stats: None,
|
||||
pull_request_label: None,
|
||||
},
|
||||
VerticalTabsSummaryBranchEntry {
|
||||
repo_path: repo_a.clone(),
|
||||
branch_name: "main".to_string(),
|
||||
diff_stats: Some(GitLineChanges {
|
||||
files_changed: 1,
|
||||
lines_added: 2,
|
||||
lines_removed: 3,
|
||||
}),
|
||||
pull_request_label: Some("#123".to_string()),
|
||||
},
|
||||
VerticalTabsSummaryBranchEntry {
|
||||
repo_path: repo_b.clone(),
|
||||
branch_name: "main".to_string(),
|
||||
diff_stats: Some(GitLineChanges {
|
||||
files_changed: 4,
|
||||
lines_added: 5,
|
||||
lines_removed: 6,
|
||||
}),
|
||||
pull_request_label: Some("#456".to_string()),
|
||||
},
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
coalesce_summary_branch_entries(entries),
|
||||
vec![
|
||||
VerticalTabsSummaryBranchEntry {
|
||||
repo_path: repo_a,
|
||||
branch_name: "main".to_string(),
|
||||
diff_stats: Some(GitLineChanges {
|
||||
files_changed: 1,
|
||||
lines_added: 2,
|
||||
lines_removed: 3,
|
||||
}),
|
||||
pull_request_label: Some("#123".to_string()),
|
||||
},
|
||||
VerticalTabsSummaryBranchEntry {
|
||||
repo_path: repo_b,
|
||||
branch_name: "main".to_string(),
|
||||
diff_stats: Some(GitLineChanges {
|
||||
files_changed: 4,
|
||||
lines_added: 5,
|
||||
lines_removed: 6,
|
||||
}),
|
||||
pull_request_label: Some("#456".to_string()),
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_summary_primary_labels_appends_overflow_count() {
|
||||
let labels = vec![
|
||||
"Claude".to_string(),
|
||||
"Oz".to_string(),
|
||||
"cargo".to_string(),
|
||||
"code review".to_string(),
|
||||
"tests".to_string(),
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
format_summary_primary_labels(&labels, 4),
|
||||
Some("Claude • Oz • cargo • code review + 1 more".to_string())
|
||||
);
|
||||
assert_eq!(summary_overflow_count(labels.len(), 4), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_search_fragments_include_hidden_overflow_values() {
|
||||
let summary = VerticalTabsSummaryData {
|
||||
primary_labels: vec![
|
||||
"Claude".to_string(),
|
||||
"Oz".to_string(),
|
||||
"cargo".to_string(),
|
||||
"code review".to_string(),
|
||||
"hidden work".to_string(),
|
||||
],
|
||||
working_directories: vec!["~/warp-internal".to_string(), "~/warp-server".to_string()],
|
||||
branch_entries: vec![
|
||||
VerticalTabsSummaryBranchEntry {
|
||||
repo_path: PathBuf::from("/tmp/repo-a"),
|
||||
branch_name: "main".to_string(),
|
||||
diff_stats: Some(GitLineChanges {
|
||||
files_changed: 1,
|
||||
lines_added: 2,
|
||||
lines_removed: 3,
|
||||
}),
|
||||
pull_request_label: Some("#123".to_string()),
|
||||
},
|
||||
VerticalTabsSummaryBranchEntry {
|
||||
repo_path: PathBuf::from("/tmp/repo-b"),
|
||||
branch_name: "feature/hidden".to_string(),
|
||||
diff_stats: None,
|
||||
pull_request_label: None,
|
||||
},
|
||||
VerticalTabsSummaryBranchEntry {
|
||||
repo_path: PathBuf::from("/tmp/repo-c"),
|
||||
branch_name: "cleanup".to_string(),
|
||||
diff_stats: None,
|
||||
pull_request_label: None,
|
||||
},
|
||||
VerticalTabsSummaryBranchEntry {
|
||||
repo_path: PathBuf::from("/tmp/repo-d"),
|
||||
branch_name: "hidden-branch".to_string(),
|
||||
diff_stats: None,
|
||||
pull_request_label: Some("#789".to_string()),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let fragments = summary_search_text_fragments(&summary, Some("Custom tab"));
|
||||
|
||||
assert!(search_fragments_contain_query(&fragments, "custom tab"));
|
||||
assert!(search_fragments_contain_query(&fragments, "hidden work"));
|
||||
assert!(search_fragments_contain_query(&fragments, "hidden-branch"));
|
||||
assert!(search_fragments_contain_query(&fragments, "#789"));
|
||||
assert!(search_fragments_contain_query(&fragments, "+2"));
|
||||
assert!(search_fragments_contain_query(&fragments, "-3"));
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
//! WASM-only view functions for the Workspace.
|
||||
|
||||
use warpui::elements::{ChildView, Element};
|
||||
use warpui::{AppContext, SingletonEntity, ViewContext, ViewHandle};
|
||||
|
||||
use warp_core::channel::ChannelState;
|
||||
|
||||
use crate::uri::browser_url_handler::parse_current_url;
|
||||
|
||||
use super::PanelPosition;
|
||||
|
||||
use crate::ai::agent_conversations_model::AgentConversationsModel;
|
||||
use crate::ai::conversation_details_panel::{
|
||||
ConversationDetailsData, ConversationDetailsPanel, ConversationDetailsPanelEvent,
|
||||
};
|
||||
use crate::terminal::TerminalView;
|
||||
use crate::ui_components::icons;
|
||||
use crate::view_components::action_button::{
|
||||
ActionButton, ButtonSize, NakedTheme, PrimaryTheme, SecondaryTheme,
|
||||
};
|
||||
use crate::wasm_nux_dialog::{WasmNUXDialog, WasmNUXDialogEvent};
|
||||
use crate::workspace::action::WorkspaceAction;
|
||||
use crate::workspace::view::{NotebookSource, OpenWarpDriveObjectSettings, Workspace};
|
||||
use crate::BlocklistAIHistoryModel;
|
||||
|
||||
const TRANSCRIPT_PANEL_WIDTH: f32 = 280.0;
|
||||
|
||||
/// Builds the OZ runs URL for viewing all cloud runs.
|
||||
fn build_oz_runs_url() -> String {
|
||||
format!("{}/runs", ChannelState::oz_root_url())
|
||||
}
|
||||
|
||||
impl Workspace {
|
||||
pub(super) fn build_wasm_nux_dialog(ctx: &mut ViewContext<Self>) -> ViewHandle<WasmNUXDialog> {
|
||||
let wasm_nux_dialog = ctx.add_typed_action_view(|_| WasmNUXDialog::new());
|
||||
ctx.subscribe_to_view(&wasm_nux_dialog, |me, _, event, ctx| match event {
|
||||
WasmNUXDialogEvent::Close => {
|
||||
me.show_wasm_nux_dialog = false;
|
||||
ctx.notify();
|
||||
}
|
||||
});
|
||||
wasm_nux_dialog
|
||||
}
|
||||
|
||||
pub(super) fn build_open_in_warp_button(
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> ViewHandle<ActionButton> {
|
||||
ctx.add_typed_action_view(|_ctx| {
|
||||
ActionButton::new("Open in Warp", PrimaryTheme).on_click(move |ctx| {
|
||||
// Get the current URL and dispatch action to open it on desktop
|
||||
if let Some(url) = parse_current_url() {
|
||||
ctx.dispatch_typed_action(WorkspaceAction::OpenLinkOnDesktop(url));
|
||||
} else {
|
||||
log::warn!("Could not get URL for Open in Warp button");
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn build_view_cloud_runs_button(
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> ViewHandle<ActionButton> {
|
||||
let url = build_oz_runs_url();
|
||||
ctx.add_typed_action_view(|_ctx| {
|
||||
ActionButton::new("View all cloud runs", SecondaryTheme).on_click(move |ctx| {
|
||||
ctx.dispatch_typed_action(WorkspaceAction::OpenLink(url.clone()));
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn build_transcript_info_button(
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> ViewHandle<ActionButton> {
|
||||
ctx.add_typed_action_view(|_ctx| {
|
||||
ActionButton::new("", NakedTheme)
|
||||
.with_icon(icons::Icon::Info)
|
||||
.with_size(ButtonSize::Small)
|
||||
.on_click(|ctx| {
|
||||
ctx.dispatch_typed_action(
|
||||
WorkspaceAction::ToggleConversationTranscriptDetailsPanel,
|
||||
);
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn build_transcript_details_panel(
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> ViewHandle<ConversationDetailsPanel> {
|
||||
let panel = ctx.add_typed_action_view(|ctx| {
|
||||
ConversationDetailsPanel::new(false, TRANSCRIPT_PANEL_WIDTH, ctx)
|
||||
});
|
||||
|
||||
ctx.subscribe_to_view(&panel, |me, _, event, ctx| match event {
|
||||
ConversationDetailsPanelEvent::Close => {
|
||||
me.current_workspace_state.is_transcript_details_panel_open = false;
|
||||
me.transcript_info_button.update(ctx, |button, ctx| {
|
||||
button.set_active(false, ctx);
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
ConversationDetailsPanelEvent::OpenPlanNotebook { notebook_uid } => {
|
||||
me.open_notebook(
|
||||
&NotebookSource::Existing((*notebook_uid).into()),
|
||||
&OpenWarpDriveObjectSettings::default(),
|
||||
ctx,
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
panel
|
||||
}
|
||||
|
||||
/// Check if we should show the conversation details panel, given the focused terminal view.
|
||||
/// Returns true for:
|
||||
/// - Conversation transcript viewers (always)
|
||||
/// - Shared sessions with an ambient agent task ID, OR an active conversation
|
||||
pub(super) fn should_show_conversation_details_panel(
|
||||
focused_terminal_view: &ViewHandle<TerminalView>,
|
||||
ctx: &AppContext,
|
||||
) -> bool {
|
||||
let terminal_view_ref = focused_terminal_view.as_ref(ctx);
|
||||
let model = terminal_view_ref.model.lock();
|
||||
|
||||
// Always show for conversation transcript viewers
|
||||
if model.is_conversation_transcript_viewer() {
|
||||
return true;
|
||||
}
|
||||
|
||||
// For shared sessions, show if there's an ambient agent task_id or an active conversation
|
||||
if model.shared_session_status().is_sharer_or_viewer() {
|
||||
if model.ambient_agent_task_id().is_some() {
|
||||
return true;
|
||||
}
|
||||
drop(model); // Release lock before accessing BlocklistAIHistoryModel
|
||||
return BlocklistAIHistoryModel::as_ref(ctx)
|
||||
.active_conversation(focused_terminal_view.id())
|
||||
.is_some();
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Renders the transcript details panel for WASM conversation transcript and shared session viewing.
|
||||
pub(super) fn render_transcript_details_panel(
|
||||
&self,
|
||||
app: &AppContext,
|
||||
) -> Option<Box<dyn Element>> {
|
||||
let terminal_view = self
|
||||
.active_tab_pane_group()
|
||||
.as_ref(app)
|
||||
.focused_session_view(app)?;
|
||||
|
||||
if !Self::should_show_conversation_details_panel(&terminal_view, app) {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(self.render_panel(
|
||||
app,
|
||||
ChildView::new(&self.transcript_details_panel).finish(),
|
||||
&PanelPosition::Right,
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) fn update_transcript_details_panel_data(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
// Get the focused terminal view
|
||||
let Some(terminal_view) = self
|
||||
.active_tab_pane_group()
|
||||
.as_ref(ctx)
|
||||
.focused_session_view(ctx)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
if !Self::should_show_conversation_details_panel(&terminal_view, ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
let terminal_view_id = terminal_view.id();
|
||||
let task_id = terminal_view
|
||||
.as_ref(ctx)
|
||||
.ambient_agent_task_id_for_details_panel(ctx);
|
||||
|
||||
self.transcript_details_panel.update(ctx, |panel, ctx| {
|
||||
// If we have an ambient agent task ID, try to populate from task data
|
||||
if let Some(task_id) = task_id {
|
||||
let conversations_model_handle = AgentConversationsModel::handle(ctx);
|
||||
let task = conversations_model_handle.update(ctx, |conversations_model, ctx| {
|
||||
conversations_model.get_or_async_fetch_task_data(&task_id, ctx)
|
||||
});
|
||||
if let Some(task) = task {
|
||||
let details = ConversationDetailsData::from_task(&task, None, None, ctx);
|
||||
panel.set_conversation_details(details, ctx);
|
||||
ctx.notify();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, populate from conversation
|
||||
let history_model = BlocklistAIHistoryModel::handle(ctx).as_ref(ctx);
|
||||
if let Some(conversation) = history_model.active_conversation(terminal_view_id) {
|
||||
let details = ConversationDetailsData::from_conversation(conversation, ctx);
|
||||
panel.set_conversation_details(details, ctx);
|
||||
}
|
||||
ctx.notify();
|
||||
});
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user