first pass of merging in warp (doesn't build)
This commit is contained in:
+306
-49
@@ -2,12 +2,28 @@ use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use session_sharing_protocol::common::SessionId;
|
||||
use ui_components::lightbox;
|
||||
use galaxy_util::path::LineAndColumnArg;
|
||||
use galaxyui::accessibility::AccessibilityVerbosity;
|
||||
use galaxyui::geometry::rect::RectF;
|
||||
use galaxyui::geometry::vector::Vector2F;
|
||||
use galaxyui::platform::Cursor;
|
||||
use galaxyui::{EntityId, WeakViewHandle, WindowId};
|
||||
|
||||
use super::global_actions::{ForkFromExchange, ForkedConversationDestination};
|
||||
use super::tab_settings::{
|
||||
VerticalTabsCompactSubtitle, VerticalTabsDisplayGranularity, VerticalTabsPrimaryInfo,
|
||||
VerticalTabsTabItemMode, VerticalTabsViewMode,
|
||||
};
|
||||
use super::view::{OnboardingTutorial, WorkspaceBanner};
|
||||
use crate::ai::agent::api::ServerConversationToken;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use crate::ai::agent::conversation::AIAgentHarness;
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::agent::AIAgentExchangeId;
|
||||
use crate::ai::ambient_agents::AmbientAgentTaskId;
|
||||
use crate::ai::blocklist::PendingAttachment;
|
||||
use crate::ai::document::ai_document_model::{AIDocumentId, AIDocumentVersion};
|
||||
use crate::auth::auth_manager::LoginGatedFeature;
|
||||
use crate::drive::items::WarpDriveItemId;
|
||||
@@ -21,29 +37,15 @@ use crate::server::telemetry::{
|
||||
AddTabWithShellSource, AgentModeEntrypoint, PaletteSource, SharingDialogSource,
|
||||
};
|
||||
use crate::settings_view::{SettingsAction as SettingsTabAction, SettingsSection};
|
||||
use crate::tab::NewSessionMenuItem;
|
||||
use crate::tab::{NewSessionMenuItem, SelectedTabColor};
|
||||
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::tab_group::TabGroupId;
|
||||
use crate::workspace::PaneViewLocator;
|
||||
use session_sharing_protocol::common::SessionId;
|
||||
|
||||
use galaxyui::accessibility::AccessibilityVerbosity;
|
||||
use galaxyui::geometry::rect::RectF;
|
||||
use galaxyui::geometry::vector::Vector2F;
|
||||
use galaxyui::platform::Cursor;
|
||||
use galaxyui::{EntityId, WeakViewHandle, WindowId};
|
||||
use ui_components::lightbox;
|
||||
|
||||
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)]
|
||||
@@ -81,6 +83,27 @@ pub enum TabContextMenuAnchor {
|
||||
VerticalTabsKebab,
|
||||
}
|
||||
|
||||
/// Describes how the new-session dropdown menu was opened so the renderer
|
||||
/// can pick the right anchor strategy.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum NewSessionMenuAnchor {
|
||||
/// Menu was opened from the `+` add-tab button. When vertical tabs are
|
||||
/// active, the renderer anchors below the button's save position;
|
||||
/// otherwise the contained position is used directly.
|
||||
AddTabButton(Vector2F),
|
||||
/// Menu was opened by right-clicking the vertical tabs panel.
|
||||
/// Always anchored at the contained pointer position.
|
||||
Pointer(Vector2F),
|
||||
}
|
||||
|
||||
impl NewSessionMenuAnchor {
|
||||
pub fn position(&self) -> Vector2F {
|
||||
match self {
|
||||
Self::AddTabButton(position) | Self::Pointer(position) => *position,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum VerticalTabsPaneContextMenuTarget {
|
||||
ClickedPane(PaneViewLocator),
|
||||
@@ -95,6 +118,12 @@ impl VerticalTabsPaneContextMenuTarget {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AutoCloudHandoffTrigger {
|
||||
MacOsSleep,
|
||||
Uri,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum WorkspaceAction {
|
||||
ActivateTab(usize),
|
||||
@@ -112,11 +141,28 @@ pub enum WorkspaceAction {
|
||||
RenamePane(PaneViewLocator),
|
||||
ResetPaneName(PaneViewLocator),
|
||||
RenameActiveTab,
|
||||
/// Renames the focused pane in the active tab. Mirrors `RenameActiveTab`
|
||||
/// so the action is reachable from the binding registry / Command Palette
|
||||
/// (see #9351). The context-menu path keeps using `RenamePane(locator)`.
|
||||
RenameActivePane,
|
||||
SetActiveTabName(String),
|
||||
/// Sets the manual color override for the active tab.
|
||||
///
|
||||
/// - `Color(_)` — apply that color.
|
||||
/// - `Cleared` — explicitly clear (suppresses any directory default).
|
||||
/// - `Unset` — remove the manual override (lets the directory default apply, if any).
|
||||
SetActiveTabColor(SelectedTabColor),
|
||||
ToggleTabRightClickMenu {
|
||||
tab_index: usize,
|
||||
anchor: TabContextMenuAnchor,
|
||||
},
|
||||
/// Toggles the multi-tab selection right-click menu.
|
||||
/// Dispatched by the UI when the right-clicked tab is part of a multi-tab
|
||||
/// selection (cmd-click or shift-click).
|
||||
ToggleTabSelectionRightClickMenu {
|
||||
tab_index: usize,
|
||||
anchor: TabContextMenuAnchor,
|
||||
},
|
||||
ToggleVerticalTabsPaneContextMenu {
|
||||
tab_index: usize,
|
||||
target: VerticalTabsPaneContextMenuTarget,
|
||||
@@ -134,6 +180,84 @@ pub enum WorkspaceAction {
|
||||
CloseNonActiveTabs,
|
||||
CloseTabsRight(usize),
|
||||
CloseTabsRightActiveTab,
|
||||
/// Close every tab that belongs to the given tab group.
|
||||
CloseTabGroup(TabGroupId),
|
||||
/// Toggle collapsed state for the given tab group.
|
||||
ToggleTabGroupCollapsed(TabGroupId),
|
||||
/// Opens an inline editor over the given group's header for renaming.
|
||||
RenameTabGroup(TabGroupId),
|
||||
/// Cancels any active rename (tab, pane, or group) without committing the
|
||||
/// new name. Dispatched when clicking on the vtab panel background while a
|
||||
/// rename editor is open.
|
||||
CancelActiveRename,
|
||||
/// Creates a new tab group containing the tab at the given index.
|
||||
NewTabGroupFromTab(usize),
|
||||
/// Moves the tab at `tab_index` into `group_id`, appending it to the
|
||||
/// end of the group's contiguous run.
|
||||
MoveTabToGroup {
|
||||
tab_index: usize,
|
||||
group_id: TabGroupId,
|
||||
},
|
||||
/// Removes the tab at the given index from its current group.
|
||||
RemoveTabFromGroup(usize),
|
||||
/// Selects every tab between the active tab and the shift-clicked row (inclusive).
|
||||
ShiftSelectTabRange {
|
||||
locator: PaneViewLocator,
|
||||
},
|
||||
/// Toggles whether the tab at `locator` is part of the active multi-selection.
|
||||
/// Dispatched on cmd-click of a vertical tab row.
|
||||
ToggleTabMultiSelection {
|
||||
locator: PaneViewLocator,
|
||||
},
|
||||
/// Clears the tab multi-selection. Dispatched from the UI when the user takes
|
||||
/// an action that should cancel any active selections.
|
||||
ClearTabMultiSelection,
|
||||
/// Creates a new tab group from the current tab multi-selection.
|
||||
NewTabGroupFromSelectedTabs,
|
||||
/// Context-aware "create group" entry point for the keybinding: groups
|
||||
/// the multi-selection when 2+ tabs are selected, otherwise groups the
|
||||
/// active tab.
|
||||
NewTabGroupFromActiveOrSelectedTabs,
|
||||
/// Moves every selected tab into `group_id`.
|
||||
MoveSelectedTabsToGroup {
|
||||
group_id: TabGroupId,
|
||||
},
|
||||
/// Removes every selected tab from its group (requires a single shared group).
|
||||
RemoveSelectedTabsFromGroup,
|
||||
/// Context-aware "remove from group" entry point for the keybinding:
|
||||
/// removes the multi-selection from its shared group when 2+ tabs are
|
||||
/// selected, otherwise removes the active tab.
|
||||
RemoveActiveOrSelectedTabsFromGroup,
|
||||
ToggleTabGroupRightClickMenu {
|
||||
group_id: TabGroupId,
|
||||
anchor: TabContextMenuAnchor,
|
||||
},
|
||||
UngroupTabs(TabGroupId),
|
||||
NewTabInGroup(TabGroupId),
|
||||
MoveTabGroupUp(TabGroupId),
|
||||
MoveTabGroupDown(TabGroupId),
|
||||
CloseTabsOutsideGroup(TabGroupId),
|
||||
CloseTabsAboveGroup(TabGroupId),
|
||||
CloseTabsBelowGroup(TabGroupId),
|
||||
/// Pins the tab at the given index. If the tab is part of a group, it
|
||||
/// is first extracted from the group and then pinned as ungrouped.
|
||||
PinTab(usize),
|
||||
/// Unpins the tab at the given index.
|
||||
UnpinTab(usize),
|
||||
/// Pins the active tab.
|
||||
PinActiveTab,
|
||||
/// Unpins the active tab.
|
||||
UnpinActiveTab,
|
||||
/// Pins the entire tab group: sets the group as pinned
|
||||
/// and moves the group block to the end of the pinned region.
|
||||
PinTabGroup(TabGroupId),
|
||||
/// Unpins the entire tab group: clears the pinned flag on the group
|
||||
/// and moves the group block to the start of the unpinned region.
|
||||
UnpinTabGroup(TabGroupId),
|
||||
/// Pins the active tab's group.
|
||||
PinActiveTabGroup,
|
||||
/// Unpins the active tab's group.
|
||||
UnpinActiveTabGroup,
|
||||
AddDefaultTab,
|
||||
AddTerminalTab {
|
||||
hide_homepage: bool,
|
||||
@@ -149,12 +273,11 @@ pub enum WorkspaceAction {
|
||||
/// Add a new tab running a local Docker sandbox via `sbx`.
|
||||
AddDockerSandboxTab,
|
||||
OpenNewSessionMenu {
|
||||
position: Vector2F,
|
||||
anchor: NewSessionMenuAnchor,
|
||||
},
|
||||
ToggleTabConfigsMenu,
|
||||
ToggleNewSessionMenu {
|
||||
position: Vector2F,
|
||||
is_vertical_tabs: bool,
|
||||
anchor: NewSessionMenuAnchor,
|
||||
},
|
||||
SelectNewSessionMenuItem(NewSessionMenuItem),
|
||||
AutoupdateFailureLink,
|
||||
@@ -211,6 +334,12 @@ pub enum WorkspaceAction {
|
||||
color: AnsiColorIdentifier,
|
||||
tab_index: usize,
|
||||
},
|
||||
/// Toggles the color for a tab group. Clears the color if it was already
|
||||
/// set to `color`; otherwise applies `color` as the uniform group color.
|
||||
ToggleTabGroupColor {
|
||||
color: AnsiColorIdentifier,
|
||||
group_id: TabGroupId,
|
||||
},
|
||||
OpenLaunchConfigSaveModal,
|
||||
SelectTabConfig(TabConfig),
|
||||
DispatchToSettingsTab(SettingsTabAction),
|
||||
@@ -240,16 +369,16 @@ pub enum WorkspaceAction {
|
||||
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,
|
||||
StartGroupDrag(TabGroupId),
|
||||
DragGroup {
|
||||
group_id: TabGroupId,
|
||||
/// The dragged group's painted rect.
|
||||
position: RectF,
|
||||
/// The position of the cursor while dragging a group.
|
||||
cursor_position: Vector2F,
|
||||
},
|
||||
DropGroup,
|
||||
/// 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.
|
||||
@@ -266,6 +395,7 @@ pub enum WorkspaceAction {
|
||||
OpenCodeReviewPanel(PaneViewLocator),
|
||||
/// Toggles the vertical tabs panel. This happens as an explicit action from the user.
|
||||
ToggleVerticalTabsPanel,
|
||||
OpenVerticalTabsPanel,
|
||||
ToggleVerticalTabsSettingsPopup,
|
||||
SetVerticalTabsDisplayGranularity(VerticalTabsDisplayGranularity),
|
||||
SetVerticalTabsTabItemMode(VerticalTabsTabItemMode),
|
||||
@@ -333,6 +463,9 @@ pub enum WorkspaceAction {
|
||||
CopySharedSessionLinkFromTab {
|
||||
tab_index: usize,
|
||||
},
|
||||
OpenSharedSessionQrCode {
|
||||
session_id: SessionId,
|
||||
},
|
||||
AddWindow,
|
||||
AddWindowWithShell {
|
||||
shell: AvailableShell,
|
||||
@@ -391,6 +524,8 @@ pub enum WorkspaceAction {
|
||||
},
|
||||
OpenCloudAgentSetupGuide,
|
||||
AttemptLoginGatedAIUpgrade,
|
||||
/// Open the modal explaining Prompt Suggestions aren't available on the Free plan.
|
||||
OpenPromptSuggestionsUnavailableModal,
|
||||
/// Dismisses the Wayland crash recovery banner and opens a link to our docs page with more
|
||||
/// information.
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -470,6 +605,8 @@ pub enum WorkspaceAction {
|
||||
summarization_prompt: Option<String>,
|
||||
/// Initial prompt to send in the forked conversation (sent after summarization if enabled).
|
||||
initial_prompt: Option<String>,
|
||||
/// Attachments (images/files) to send along with the initial prompt in the forked pane.
|
||||
initial_attachments: Vec<PendingAttachment>,
|
||||
/// Where to open the forked conversation.
|
||||
destination: ForkedConversationDestination,
|
||||
},
|
||||
@@ -479,24 +616,64 @@ pub enum WorkspaceAction {
|
||||
ContinueConversationLocally {
|
||||
conversation_id: AIConversationId,
|
||||
},
|
||||
/// Continue a completed third-party cloud harness run in a local split pane.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
ContinueThirdPartyConversationLocally {
|
||||
task_id: AmbientAgentTaskId,
|
||||
harness: AIAgentHarness,
|
||||
},
|
||||
/// Insert the /fork slash command into the active terminal's input.
|
||||
InsertForkSlashCommand,
|
||||
/// Open a local-to-cloud handoff pane next to the active conversation
|
||||
/// (REMOTE-1486). Triggered by the `/move-to-cloud` slash command
|
||||
/// and the footer chip of the same name. The dispatch site reads the
|
||||
/// active conversation's `server_conversation_token` and gates on
|
||||
/// `FeatureFlag::OzHandoff && FeatureFlag::HandoffLocalCloud`.
|
||||
/// Falls through to splitting a fresh cloud-mode pane when the active
|
||||
/// conversation isn't handoff-able (no synced server token, empty, or no
|
||||
/// active conversation at all).
|
||||
OpenLocalToCloudHandoffPane {
|
||||
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
|
||||
launch: Option<crate::ai::blocklist::handoff::PendingCloudLaunch>,
|
||||
#[cfg(not(all(feature = "local_fs", not(target_family = "wasm"))))]
|
||||
launch: Option<()>,
|
||||
environment_id: Option<crate::server::ids::SyncId>,
|
||||
entry_point: crate::ai::ambient_agents::telemetry::HandoffEntryPoint,
|
||||
},
|
||||
/// Automatically hand off the active running local agent conversation in the
|
||||
/// given terminal view to Cloud Mode.
|
||||
AutoHandoffActiveAgentToCloud {
|
||||
terminal_view_id: EntityId,
|
||||
conversation_id: AIConversationId,
|
||||
trigger: AutoCloudHandoffTrigger,
|
||||
},
|
||||
/// Show the environment creation modal during `&` handoff compose when no
|
||||
/// environments exist.
|
||||
ShowHandoffEnvironmentCreationModal,
|
||||
ShowCloudModeV2EnvironmentCreationModal,
|
||||
/// Open the workspace modal for creating a new managed auth secret.
|
||||
/// Dispatched by orchestration card pickers' "New API key…" item.
|
||||
OpenCreateAuthSecretModal {
|
||||
harness: warp_cli::agent::Harness,
|
||||
},
|
||||
/// 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
|
||||
/// Install the Oz CLI command to /usr/local/bin
|
||||
#[cfg(target_os = "macos")]
|
||||
InstallCLI,
|
||||
/// Uninstall the Warp CLI command from /usr/local/bin
|
||||
InstallOz,
|
||||
/// Uninstall the Oz CLI command from /usr/local/bin
|
||||
#[cfg(target_os = "macos")]
|
||||
UninstallCLI,
|
||||
UninstallOz,
|
||||
/// Install the Warp Control CLI command to /usr/local/bin
|
||||
#[cfg(target_os = "macos")]
|
||||
InstallWarpctrl,
|
||||
/// Uninstall the Warp Control CLI command from /usr/local/bin
|
||||
#[cfg(target_os = "macos")]
|
||||
UninstallWarpctrl,
|
||||
UndoRevertInCodeReviewPane {
|
||||
window_id: WindowId,
|
||||
view_id: EntityId,
|
||||
@@ -530,9 +707,13 @@ pub enum WorkspaceAction {
|
||||
NavigatePrevPaneOrPanel,
|
||||
NavigateNextPaneOrPanel,
|
||||
ToggleProjectExplorer,
|
||||
OpenProjectExplorer,
|
||||
ToggleGlobalSearch,
|
||||
ToggleHiddenFiles,
|
||||
OpenGlobalSearch,
|
||||
ToggleConversationListView,
|
||||
OpenConversationListView,
|
||||
OpenAgentManagementView,
|
||||
/// Open the Build Plan Migration Modal (for debugging)
|
||||
#[cfg(debug_assertions)]
|
||||
OpenBuildPlanMigrationModal,
|
||||
@@ -554,6 +735,28 @@ pub enum WorkspaceAction {
|
||||
/// Reset the OpenWarp launch modal dismissed state (for debugging)
|
||||
#[cfg(debug_assertions)]
|
||||
ResetOpenWarpLaunchModalState,
|
||||
/// Open the Orchestration Launch Modal (for debugging)
|
||||
#[cfg(debug_assertions)]
|
||||
OpenOrchestrationLaunchModal,
|
||||
/// Reset the orchestration launch modal dismissed state (for debugging)
|
||||
#[cfg(debug_assertions)]
|
||||
ResetOrchestrationLaunchModalState,
|
||||
/// Open the auto-handoff sleep modal (for debugging)
|
||||
#[cfg(debug_assertions)]
|
||||
OpenAutoHandoffSleepModal,
|
||||
/// Reset the auto-handoff sleep modal shown state (for debugging)
|
||||
#[cfg(debug_assertions)]
|
||||
ResetAutoHandoffSleepModalState,
|
||||
/// Trigger the auto-handoff-to-cloud flow in-process, as if the machine
|
||||
/// were about to sleep (for debugging)
|
||||
#[cfg(debug_assertions)]
|
||||
TriggerAutoHandoffToCloud,
|
||||
/// Open the Free AI Removal Modal (for debugging)
|
||||
#[cfg(debug_assertions)]
|
||||
OpenFreeAiRemovalModal,
|
||||
/// Reset the free AI removal modal seen state (for debugging)
|
||||
#[cfg(debug_assertions)]
|
||||
ResetFreeAiRemovalModalState,
|
||||
/// Install the opencode-warp plugin from GitHub into the global opencode config.
|
||||
#[cfg(debug_assertions)]
|
||||
InstallOpenCodeWarpPlugin,
|
||||
@@ -587,9 +790,8 @@ pub enum WorkspaceAction {
|
||||
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 {
|
||||
/// Open the canonical ambient agent conversation pane and attach it to a live session.
|
||||
OpenOrAttachAmbientAgentConversation {
|
||||
session_id: SessionId,
|
||||
task_id: AmbientAgentTaskId,
|
||||
},
|
||||
@@ -682,7 +884,6 @@ impl From<&WorkspaceAction> for LoginGatedFeature {
|
||||
|
||||
impl WorkspaceAction {
|
||||
pub fn blocked_for_anonymous_user(&self) -> bool {
|
||||
use WorkspaceAction::*;
|
||||
matches!(
|
||||
self,
|
||||
ImportToTeamDrive
|
||||
@@ -696,13 +897,14 @@ impl WorkspaceAction {
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// actions directly, rather than using _, so we're forced to make a conscious 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,
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
ContinueThirdPartyConversationLocally { .. } => true,
|
||||
ActivateTab(_)
|
||||
| ActivateTabByNumber(_)
|
||||
| ActivatePrevTab
|
||||
@@ -715,19 +917,49 @@ impl WorkspaceAction {
|
||||
| MoveTabLeft(_)
|
||||
| MoveTabRight(_)
|
||||
| DropTab
|
||||
| DropGroup
|
||||
| RenameTab(_)
|
||||
| ResetTabName(_)
|
||||
| RenamePane(_)
|
||||
| ResetPaneName(_)
|
||||
| RenameActiveTab
|
||||
| RenameActivePane
|
||||
| SetActiveTabName(_)
|
||||
| SetActiveTabColor(_)
|
||||
| CloseTab(_)
|
||||
| CloseActiveTab
|
||||
| CloseOtherTabs(_)
|
||||
| CloseNonActiveTabs
|
||||
| CloseTabsRight(_)
|
||||
| CloseTabsRightActiveTab
|
||||
| CloseTabGroup(_)
|
||||
| ToggleTabGroupCollapsed(_)
|
||||
| RenameTabGroup(_)
|
||||
| NewTabGroupFromTab(_)
|
||||
| MoveTabToGroup { .. }
|
||||
| RemoveTabFromGroup(_)
|
||||
| NewTabGroupFromSelectedTabs
|
||||
| NewTabGroupFromActiveOrSelectedTabs
|
||||
| MoveSelectedTabsToGroup { .. }
|
||||
| RemoveSelectedTabsFromGroup
|
||||
| RemoveActiveOrSelectedTabsFromGroup
|
||||
| UngroupTabs(_)
|
||||
| NewTabInGroup(_)
|
||||
| MoveTabGroupUp(_)
|
||||
| MoveTabGroupDown(_)
|
||||
| CloseTabsOutsideGroup(_)
|
||||
| CloseTabsAboveGroup(_)
|
||||
| CloseTabsBelowGroup(_)
|
||||
| PinTab(_)
|
||||
| UnpinTab(_)
|
||||
| PinActiveTab
|
||||
| UnpinActiveTab
|
||||
| PinTabGroup(_)
|
||||
| UnpinTabGroup(_)
|
||||
| PinActiveTabGroup
|
||||
| UnpinActiveTabGroup
|
||||
| ToggleTabColor { .. }
|
||||
| ToggleTabGroupColor { .. }
|
||||
| AddDefaultTab
|
||||
| AddTerminalTab { .. }
|
||||
| AddTabWithShell { .. }
|
||||
@@ -751,7 +983,8 @@ impl WorkspaceAction {
|
||||
| SummarizeAIConversation { .. }
|
||||
| OpenRepository { .. }
|
||||
| SelectTabConfig(_)
|
||||
| ToggleVerticalTabsPanel => true, // actions that actually change a state of the state of user's
|
||||
| ToggleVerticalTabsPanel
|
||||
| OpenVerticalTabsPanel => 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
|
||||
@@ -786,6 +1019,8 @@ impl WorkspaceAction {
|
||||
| ToggleSyntaxHighlighting
|
||||
| OpenLaunchConfigSaveModal
|
||||
| ToggleTabRightClickMenu { .. }
|
||||
| ToggleTabSelectionRightClickMenu { .. }
|
||||
| ToggleTabGroupRightClickMenu { .. }
|
||||
| ToggleVerticalTabsPaneContextMenu { .. }
|
||||
| OpenNewSessionMenu { .. }
|
||||
| ToggleTabConfigsMenu
|
||||
@@ -801,6 +1036,7 @@ impl WorkspaceAction {
|
||||
| ClickedAIAssistantIcon
|
||||
| ToggleAIAssistant
|
||||
| OpenCloudAgentSetupGuide
|
||||
| OpenPromptSuggestionsUnavailableModal
|
||||
| ToggleKeybindingsPage
|
||||
| ShowCommandSearch(_)
|
||||
| ToggleMouseReporting
|
||||
@@ -820,10 +1056,9 @@ impl WorkspaceAction {
|
||||
| CreateTeamAIPrompt
|
||||
| OpenInExplorer { .. }
|
||||
| DragTab { .. }
|
||||
| HandoffPendingTransfer { .. }
|
||||
| ReverseHandoff { .. }
|
||||
| StartTabDrag
|
||||
| FinalizeDropTab
|
||||
| DragGroup { .. }
|
||||
| StartGroupDrag(_)
|
||||
| ToggleLeftPanel
|
||||
| ToggleGalaxyDrive
|
||||
| OpenGalaxyDrive
|
||||
@@ -873,6 +1108,7 @@ impl WorkspaceAction {
|
||||
| StopSharingSessionFromTabMenu { .. }
|
||||
| StopSharingAllSessionsInTab { .. }
|
||||
| CopySharedSessionLinkFromTab { .. }
|
||||
| OpenSharedSessionQrCode { .. }
|
||||
| ReopenClosedSession
|
||||
| FocusLeftPanel
|
||||
| FocusRightPanel
|
||||
@@ -886,7 +1122,6 @@ impl WorkspaceAction {
|
||||
| RunCommand { .. }
|
||||
| InsertInInput { .. }
|
||||
| InsertForkSlashCommand
|
||||
| QueuePromptForConversation { .. }
|
||||
| AttemptLoginGatedAIUpgrade
|
||||
| UndoTrash(_)
|
||||
| OpenFilePath { .. }
|
||||
@@ -900,17 +1135,25 @@ impl WorkspaceAction {
|
||||
| OpenMCPServerCollection
|
||||
| FocusTerminalViewInWorkspace { .. }
|
||||
| FocusPane(..)
|
||||
| ShiftSelectTabRange { .. }
|
||||
| ToggleTabMultiSelection { .. }
|
||||
| ClearTabMultiSelection
|
||||
| CancelActiveRename
|
||||
| StartNewConversation { .. }
|
||||
| UndoRevertInCodeReviewPane { .. }
|
||||
| JumpToLatestToast
|
||||
| NavigatePrevPaneOrPanel
|
||||
| NavigateNextPaneOrPanel
|
||||
| ToggleProjectExplorer
|
||||
| OpenProjectExplorer
|
||||
| ToggleGlobalSearch
|
||||
| ToggleHiddenFiles
|
||||
| OpenGlobalSearch
|
||||
| ToggleConversationListView
|
||||
| OpenConversationListView
|
||||
| ToggleNotificationMailbox { .. }
|
||||
| ToggleAgentManagementView
|
||||
| OpenAgentManagementView
|
||||
| ViewAgentRunsForEnvironment { .. }
|
||||
| ToggleAIDocumentPane { .. }
|
||||
| HideAIDocumentPanes
|
||||
@@ -918,7 +1161,7 @@ impl WorkspaceAction {
|
||||
| ShowRewindConfirmationDialog { .. }
|
||||
| ExecuteRewindAIConversation { .. }
|
||||
| ExecuteDeleteConversation { .. }
|
||||
| OpenAmbientAgentSession { .. }
|
||||
| OpenOrAttachAmbientAgentConversation { .. }
|
||||
| OpenConversationTranscriptViewer { .. }
|
||||
| OpenLightbox { .. }
|
||||
| UpdateLightboxImage { .. }
|
||||
@@ -933,6 +1176,11 @@ impl WorkspaceAction {
|
||||
| TabConfigSidecarRemoveConfig { .. }
|
||||
| OpenSettingsFile
|
||||
| FixSettingsWithOz { .. }
|
||||
| OpenLocalToCloudHandoffPane { .. }
|
||||
| AutoHandoffActiveAgentToCloud { .. }
|
||||
| ShowHandoffEnvironmentCreationModal
|
||||
| ShowCloudModeV2EnvironmentCreationModal
|
||||
| OpenCreateAuthSecretModal { .. }
|
||||
| OpenNetworkLogPane => false,
|
||||
#[cfg(debug_assertions)]
|
||||
ShowHoaOnboardingFlow => false,
|
||||
@@ -946,6 +1194,13 @@ impl WorkspaceAction {
|
||||
| ResetOzLaunchModalState
|
||||
| OpenOpenWarpLaunchModal
|
||||
| ResetOpenWarpLaunchModalState
|
||||
| OpenOrchestrationLaunchModal
|
||||
| ResetOrchestrationLaunchModalState
|
||||
| OpenAutoHandoffSleepModal
|
||||
| ResetAutoHandoffSleepModalState
|
||||
| TriggerAutoHandoffToCloud
|
||||
| OpenFreeAiRemovalModal
|
||||
| ResetFreeAiRemovalModalState
|
||||
| InstallOpenCodeWarpPlugin
|
||||
| UseLocalOpenCodeWarpPlugin => false,
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
@@ -953,7 +1208,9 @@ impl WorkspaceAction {
|
||||
#[cfg(target_os = "macos")]
|
||||
SampleProcess => false,
|
||||
#[cfg(target_os = "macos")]
|
||||
InstallCLI | UninstallCLI => false,
|
||||
InstallOz | UninstallOz => false,
|
||||
#[cfg(target_os = "macos")]
|
||||
InstallWarpctrl | UninstallWarpctrl => false,
|
||||
#[cfg(feature = "local_fs")]
|
||||
FileRenamed { .. } => false, // File rename doesn't change workspace state
|
||||
#[cfg(feature = "local_fs")]
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use warpui::EntityId;
|
||||
|
||||
use super::WorkspaceAction;
|
||||
use crate::pane_group::TerminalPaneId;
|
||||
use crate::workspace::tab_settings::{
|
||||
@@ -5,7 +7,6 @@ use crate::workspace::tab_settings::{
|
||||
VerticalTabsViewMode,
|
||||
};
|
||||
use crate::workspace::PaneViewLocator;
|
||||
use galaxyui::EntityId;
|
||||
|
||||
#[test]
|
||||
fn vertical_tabs_view_mode_change_does_not_save_workspace_state() {
|
||||
@@ -74,4 +75,8 @@ fn pane_name_actions_save_workspace_state() {
|
||||
|
||||
assert!(WorkspaceAction::RenamePane(locator).should_save_app_state_on_action());
|
||||
assert!(WorkspaceAction::ResetPaneName(locator).should_save_app_state_on_action());
|
||||
// GH-9351: the keyboard-bindable variant must persist app state on the
|
||||
// same conditions as the locator-based one, since both ultimately drive
|
||||
// `rename_pane` which mutates `pane_configuration`.
|
||||
assert!(WorkspaceAction::RenameActivePane.should_save_app_state_on_action());
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, Weak},
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
#[cfg(test)]
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Weak};
|
||||
|
||||
use galaxy_util::local_or_remote_path::LocalOrRemotePath;
|
||||
use galaxyui::{Entity, EntityId, ModelContext, SingletonEntity, WindowId};
|
||||
|
||||
use crate::terminal::model::session::Session;
|
||||
@@ -28,8 +29,8 @@ 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 active session's working directory (local or remote).
|
||||
working_directory: Option<LocalOrRemotePath>,
|
||||
/// The [`EntityId`]` for the [`TerminalView`] for the active session, if there is one.
|
||||
terminal_view_id: Option<EntityId>,
|
||||
}
|
||||
@@ -52,8 +53,17 @@ impl ActiveSession {
|
||||
pub fn path_if_local(&self, window_id: WindowId) -> Option<&Path> {
|
||||
self.window_sessions
|
||||
.get(&window_id)?
|
||||
.path_if_local
|
||||
.as_deref()
|
||||
.working_directory
|
||||
.as_ref()
|
||||
.and_then(|wd| wd.to_local_path())
|
||||
}
|
||||
|
||||
/// The current working directory of the active session (local or remote).
|
||||
pub fn working_directory(&self, window_id: WindowId) -> Option<&LocalOrRemotePath> {
|
||||
self.window_sessions
|
||||
.get(&window_id)?
|
||||
.working_directory
|
||||
.as_ref()
|
||||
}
|
||||
|
||||
/// Set the current session, for use in tests.
|
||||
@@ -69,7 +79,7 @@ impl ActiveSession {
|
||||
self.set_session_state(
|
||||
window_id,
|
||||
Some(session),
|
||||
path_if_local.map(Into::into),
|
||||
path_if_local.map(|p| LocalOrRemotePath::Local(p.into())),
|
||||
terminal_view_id,
|
||||
ctx,
|
||||
);
|
||||
@@ -79,7 +89,7 @@ impl ActiveSession {
|
||||
&mut self,
|
||||
window_id: WindowId,
|
||||
session: Option<Arc<Session>>,
|
||||
path_if_local: Option<PathBuf>,
|
||||
working_directory: Option<LocalOrRemotePath>,
|
||||
terminal_view_id: Option<EntityId>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
@@ -100,8 +110,8 @@ impl ActiveSession {
|
||||
}
|
||||
}
|
||||
|
||||
if window_state.path_if_local != path_if_local {
|
||||
window_state.path_if_local = path_if_local;
|
||||
if window_state.working_directory != working_directory {
|
||||
window_state.working_directory = working_directory;
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,443 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use galaxy_core::send_telemetry_from_ctx;
|
||||
use warpui::{
|
||||
AppContext, Entity, EntityId, ModelContext, SingletonEntity, TypedActionView, ViewHandle,
|
||||
WindowId,
|
||||
};
|
||||
|
||||
use super::{
|
||||
AutoCloudHandoffTrigger, OneTimeModalModel, ToastStack, Workspace, WorkspaceAction,
|
||||
WorkspaceRegistry,
|
||||
};
|
||||
use crate::ai::active_agent_views_model::{ActiveAgentViewsModel, ConversationOrTaskId};
|
||||
use crate::ai::agent::conversation::{AIConversation, AIConversationId};
|
||||
use crate::ai::ambient_agents::telemetry::CloudAgentTelemetryEvent;
|
||||
use crate::ai::blocklist::orchestration_topology::has_local_orchestrated_children;
|
||||
use crate::settings::AISettings;
|
||||
use crate::system::{SystemStats, SystemStatsEvent};
|
||||
use crate::terminal::view::TerminalView;
|
||||
use crate::view_components::DismissibleToast;
|
||||
use crate::BlocklistAIHistoryModel;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum AutoCloudHandoffSkipReason {
|
||||
EmptyConversation,
|
||||
NotInProgress,
|
||||
MissingServerConversationToken,
|
||||
SharedSessionViewer,
|
||||
CloudHandoffUnavailable,
|
||||
OrchestratorWithLocalChildren,
|
||||
AlreadyAttempted,
|
||||
NoFocusedConversation,
|
||||
TerminalNotFound { terminal_view_id: EntityId },
|
||||
CloudPane,
|
||||
LongRunningCommand,
|
||||
ConversationNotLoaded { conversation_id: AIConversationId },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) struct AutoCloudHandoffEligibility {
|
||||
pub(crate) is_empty: bool,
|
||||
pub(crate) is_in_progress: bool,
|
||||
pub(crate) has_server_conversation_token: bool,
|
||||
pub(crate) is_viewing_shared_session: bool,
|
||||
pub(crate) can_handoff_to_cloud: bool,
|
||||
pub(crate) already_attempted: bool,
|
||||
/// True when the focused conversation is an orchestrator with at least one
|
||||
/// active local child agent. Handing such a session off to the cloud would
|
||||
/// fork only the parent and orphan its local children, so we skip it.
|
||||
pub(crate) has_local_orchestrated_children: bool,
|
||||
}
|
||||
|
||||
impl AutoCloudHandoffEligibility {
|
||||
pub(crate) fn from_conversation(
|
||||
conversation: &AIConversation,
|
||||
can_handoff_to_cloud: bool,
|
||||
already_attempted: bool,
|
||||
has_local_orchestrated_children: bool,
|
||||
) -> Self {
|
||||
Self {
|
||||
is_empty: conversation.is_empty(),
|
||||
is_in_progress: conversation.status().is_in_progress(),
|
||||
has_server_conversation_token: conversation.server_conversation_token().is_some(),
|
||||
is_viewing_shared_session: conversation.is_viewing_shared_session(),
|
||||
can_handoff_to_cloud,
|
||||
already_attempted,
|
||||
has_local_orchestrated_children,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn skip_reason(self) -> Option<AutoCloudHandoffSkipReason> {
|
||||
if self.already_attempted {
|
||||
return Some(AutoCloudHandoffSkipReason::AlreadyAttempted);
|
||||
}
|
||||
if self.is_viewing_shared_session {
|
||||
return Some(AutoCloudHandoffSkipReason::SharedSessionViewer);
|
||||
}
|
||||
if self.is_empty {
|
||||
return Some(AutoCloudHandoffSkipReason::EmptyConversation);
|
||||
}
|
||||
if !self.is_in_progress {
|
||||
return Some(AutoCloudHandoffSkipReason::NotInProgress);
|
||||
}
|
||||
if self.has_local_orchestrated_children {
|
||||
return Some(AutoCloudHandoffSkipReason::OrchestratorWithLocalChildren);
|
||||
}
|
||||
if !self.has_server_conversation_token {
|
||||
return Some(AutoCloudHandoffSkipReason::MissingServerConversationToken);
|
||||
}
|
||||
if !self.can_handoff_to_cloud {
|
||||
return Some(AutoCloudHandoffSkipReason::CloudHandoffUnavailable);
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct AutoCloudHandoffRequest {
|
||||
workspace: ViewHandle<Workspace>,
|
||||
terminal_view_id: EntityId,
|
||||
conversation_id: AIConversationId,
|
||||
trigger: AutoCloudHandoffTrigger,
|
||||
}
|
||||
|
||||
/// A focused local agent conversation that passed every auto-handoff
|
||||
/// precondition, resolved to the views needed to dispatch the handoff.
|
||||
struct AutoCloudHandoffCandidate {
|
||||
window_id: WindowId,
|
||||
workspace: ViewHandle<Workspace>,
|
||||
terminal_view_id: EntityId,
|
||||
conversation_id: AIConversationId,
|
||||
}
|
||||
|
||||
impl AutoCloudHandoffRequest {
|
||||
fn dispatch(&self, ctx: &mut AppContext) {
|
||||
self.workspace.update(ctx, |workspace, ctx| {
|
||||
workspace.handle_action(
|
||||
&WorkspaceAction::AutoHandoffActiveAgentToCloud {
|
||||
terminal_view_id: self.terminal_view_id,
|
||||
conversation_id: self.conversation_id,
|
||||
trigger: self.trigger,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
pub(crate) struct AutoCloudHandoffController {
|
||||
attempted_conversation_ids: HashSet<AIConversationId>,
|
||||
/// Set at sleep time when an eligible in-progress local agent run would have
|
||||
/// been handed off but `auto_handoff_on_sleep_enabled` is off. Consumed on
|
||||
/// wake to surface the discoverability modal.
|
||||
pending_sleep_prompt: bool,
|
||||
/// True between `CpuWillSleep` and `CpuWasAwakened`. Used to decide whether
|
||||
/// a handoff success toast can be shown right away or must wait for wake.
|
||||
is_system_sleeping: bool,
|
||||
/// Window of an automatic handoff that succeeded while the system was
|
||||
/// sleeping. Consumed on wake to show the success toast once the user can
|
||||
/// actually see it.
|
||||
pending_success_toast_window: Option<WindowId>,
|
||||
}
|
||||
|
||||
impl AutoCloudHandoffController {
|
||||
pub(crate) fn new(ctx: &mut ModelContext<Self>) -> Self {
|
||||
ctx.subscribe_to_model(&SystemStats::handle(ctx), |controller, _, event, ctx| {
|
||||
controller.handle_system_stats_event(event, ctx);
|
||||
});
|
||||
|
||||
Self {
|
||||
attempted_conversation_ids: HashSet::new(),
|
||||
pending_sleep_prompt: false,
|
||||
is_system_sleeping: false,
|
||||
pending_success_toast_window: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks the attempt as succeeded and surfaces the success toast:
|
||||
/// immediately when the system is awake (e.g. the fork RPC resolved after
|
||||
/// wake), otherwise deferred until `CpuWasAwakened` so the ephemeral
|
||||
/// toast's dismissal timeout doesn't elapse while the user is away.
|
||||
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
|
||||
pub(crate) fn record_handoff_succeeded(
|
||||
&mut self,
|
||||
conversation_id: AIConversationId,
|
||||
window_id: WindowId,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
self.attempted_conversation_ids.insert(conversation_id);
|
||||
|
||||
if self.is_system_sleeping {
|
||||
self.pending_success_toast_window = Some(window_id);
|
||||
} else {
|
||||
Self::show_success_toast(window_id, ctx);
|
||||
}
|
||||
}
|
||||
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
|
||||
pub(crate) fn record_handoff_failed(&mut self, conversation_id: AIConversationId) {
|
||||
self.attempted_conversation_ids.remove(&conversation_id);
|
||||
}
|
||||
|
||||
fn handle_system_stats_event(
|
||||
&mut self,
|
||||
event: &SystemStatsEvent,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
match event {
|
||||
SystemStatsEvent::CpuWillSleep => {
|
||||
self.is_system_sleeping = true;
|
||||
self.handle_cpu_will_sleep(ctx);
|
||||
}
|
||||
SystemStatsEvent::CpuWasAwakened => {
|
||||
self.is_system_sleeping = false;
|
||||
self.maybe_show_success_toast(ctx);
|
||||
self.maybe_show_sleep_prompt(ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// On wake, shows the success toast for an automatic handoff that
|
||||
/// completed while the system was sleeping.
|
||||
fn maybe_show_success_toast(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
if let Some(window_id) = self.pending_success_toast_window.take() {
|
||||
Self::show_success_toast(window_id, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
fn show_success_toast(window_id: WindowId, ctx: &mut ModelContext<Self>) {
|
||||
log::info!("auto handoff: showing success toast in window {window_id:?}");
|
||||
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
|
||||
toast_stack.add_ephemeral_toast(
|
||||
DismissibleToast::success("Handed session off to the cloud".to_owned()),
|
||||
window_id,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// At sleep time, hands the focused eligible local agent run off to the
|
||||
/// cloud when `auto_handoff_on_sleep_enabled` is on. When the setting is
|
||||
/// off, records a pending discoverability prompt instead; the modal is
|
||||
/// surfaced on wake by [`Self::maybe_show_sleep_prompt`] and shown at most
|
||||
/// once per user (enforced by `OneTimeModalModel`).
|
||||
fn handle_cpu_will_sleep(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
self.pending_sleep_prompt = false;
|
||||
|
||||
let candidate = match self.evaluate_handoff_candidate(ctx) {
|
||||
Ok(candidate) => candidate,
|
||||
Err(reason) => {
|
||||
log::info!("auto handoff: skipping at sleep: {reason:?}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if AISettings::as_ref(ctx).is_auto_handoff_on_sleep_enabled(ctx) {
|
||||
self.dispatch_handoff(candidate, AutoCloudHandoffTrigger::MacOsSleep, ctx);
|
||||
} else {
|
||||
log::info!(
|
||||
"auto-handoff sleep prompt: recorded pending prompt for conversation {:?} in terminal {:?}",
|
||||
candidate.conversation_id,
|
||||
candidate.terminal_view_id,
|
||||
);
|
||||
self.pending_sleep_prompt = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// On wake, surfaces the discoverability modal recorded at sleep time, as
|
||||
/// long as the setting is still off. The modal itself is once-ever per
|
||||
/// user; `OneTimeModalModel` enforces that.
|
||||
fn maybe_show_sleep_prompt(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
if !std::mem::take(&mut self.pending_sleep_prompt) {
|
||||
log::info!(
|
||||
"auto-handoff sleep prompt: nothing to show on wake, no pending prompt was recorded at sleep"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if AISettings::as_ref(ctx).is_auto_handoff_on_sleep_enabled(ctx) {
|
||||
log::info!(
|
||||
"auto-handoff sleep prompt: not showing on wake, auto-handoff-on-sleep was enabled in the meantime"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let shown = OneTimeModalModel::handle(ctx).update(ctx, |model, ctx| {
|
||||
model.check_and_trigger_auto_handoff_sleep_modal(ctx)
|
||||
});
|
||||
if shown {
|
||||
log::info!("auto-handoff sleep prompt: showing modal on wake");
|
||||
send_telemetry_from_ctx!(CloudAgentTelemetryEvent::SleepPromptShown, ctx);
|
||||
} else {
|
||||
log::info!(
|
||||
"auto-handoff sleep prompt: not showing on wake, modal was already shown once"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn trigger(&mut self, trigger: AutoCloudHandoffTrigger, ctx: &mut ModelContext<Self>) {
|
||||
if !Self::is_trigger_enabled(trigger, ctx) {
|
||||
log::info!(
|
||||
"auto handoff: skipping {trigger:?} trigger, auto-handoff-on-sleep is disabled"
|
||||
);
|
||||
return;
|
||||
}
|
||||
match self.evaluate_handoff_candidate(ctx) {
|
||||
Ok(candidate) => self.dispatch_handoff(candidate, trigger, ctx),
|
||||
Err(reason) => log::info!("auto handoff: skipping {trigger:?} trigger: {reason:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves the focused local agent conversation and checks every
|
||||
/// precondition shared by automatic handoff and the sleep discoverability
|
||||
/// prompt. Returns the resolved candidate, or the first reason it must be
|
||||
/// skipped.
|
||||
fn evaluate_handoff_candidate(
|
||||
&self,
|
||||
ctx: &ModelContext<Self>,
|
||||
) -> Result<AutoCloudHandoffCandidate, AutoCloudHandoffSkipReason> {
|
||||
let Some((terminal_view_id, conversation_id)) = Self::last_focused_local_conversation(ctx)
|
||||
else {
|
||||
return Err(AutoCloudHandoffSkipReason::NoFocusedConversation);
|
||||
};
|
||||
|
||||
let Some((window_id, workspace, terminal_view)) =
|
||||
Self::find_workspace_and_terminal(terminal_view_id, ctx)
|
||||
else {
|
||||
return Err(AutoCloudHandoffSkipReason::TerminalNotFound { terminal_view_id });
|
||||
};
|
||||
|
||||
if terminal_view
|
||||
.as_ref(ctx)
|
||||
.ambient_agent_view_model()
|
||||
.is_some()
|
||||
{
|
||||
return Err(AutoCloudHandoffSkipReason::CloudPane);
|
||||
}
|
||||
|
||||
if terminal_view.as_ref(ctx).has_active_long_running_command() {
|
||||
return Err(AutoCloudHandoffSkipReason::LongRunningCommand);
|
||||
}
|
||||
|
||||
let history = BlocklistAIHistoryModel::as_ref(ctx);
|
||||
let Some(conversation) = history.conversation(&conversation_id) else {
|
||||
return Err(AutoCloudHandoffSkipReason::ConversationNotLoaded { conversation_id });
|
||||
};
|
||||
|
||||
let can_handoff_to_cloud = AISettings::as_ref(ctx).is_cloud_handoff_enabled(ctx);
|
||||
if let Some(reason) = AutoCloudHandoffEligibility::from_conversation(
|
||||
conversation,
|
||||
can_handoff_to_cloud,
|
||||
self.attempted_conversation_ids.contains(&conversation_id),
|
||||
has_local_orchestrated_children(history, conversation_id),
|
||||
)
|
||||
.skip_reason()
|
||||
{
|
||||
return Err(reason);
|
||||
}
|
||||
|
||||
Ok(AutoCloudHandoffCandidate {
|
||||
window_id,
|
||||
workspace,
|
||||
terminal_view_id,
|
||||
conversation_id,
|
||||
})
|
||||
}
|
||||
|
||||
/// Marks the candidate as attempted and emits the handoff request.
|
||||
fn dispatch_handoff(
|
||||
&mut self,
|
||||
candidate: AutoCloudHandoffCandidate,
|
||||
trigger: AutoCloudHandoffTrigger,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
self.attempted_conversation_ids
|
||||
.insert(candidate.conversation_id);
|
||||
|
||||
log::info!(
|
||||
"Triggering auto handoff to cloud for conversation {:?} in window {:?} via {trigger:?}",
|
||||
candidate.conversation_id,
|
||||
candidate.window_id,
|
||||
);
|
||||
ctx.emit(AutoCloudHandoffRequest {
|
||||
workspace: candidate.workspace,
|
||||
terminal_view_id: candidate.terminal_view_id,
|
||||
conversation_id: candidate.conversation_id,
|
||||
trigger,
|
||||
});
|
||||
}
|
||||
|
||||
fn last_focused_local_conversation(
|
||||
ctx: &ModelContext<Self>,
|
||||
) -> Option<(EntityId, AIConversationId)> {
|
||||
let active_agent_views = ActiveAgentViewsModel::as_ref(ctx);
|
||||
let conversation_id = match active_agent_views.get_last_focused_conversation()? {
|
||||
ConversationOrTaskId::ConversationId(conversation_id) => conversation_id,
|
||||
ConversationOrTaskId::TaskId(_) => return None,
|
||||
};
|
||||
// The last-focused terminal id can go stale (e.g. its pane was closed
|
||||
// or swapped) while the conversation lives on in another view. Prefer
|
||||
// the history model's owner mapping — it's the same mapping the
|
||||
// handoff flow validates against — then the agent-view registry, and
|
||||
// only fall back to the last-focused id.
|
||||
let terminal_view_id = BlocklistAIHistoryModel::as_ref(ctx)
|
||||
.terminal_surface_id_for_conversation(&conversation_id)
|
||||
.or_else(|| {
|
||||
active_agent_views.get_terminal_view_id_for_conversation(conversation_id, ctx)
|
||||
})
|
||||
.or_else(|| active_agent_views.get_last_focused_terminal_id())?;
|
||||
Some((terminal_view_id, conversation_id))
|
||||
}
|
||||
|
||||
fn is_trigger_enabled(trigger: AutoCloudHandoffTrigger, ctx: &ModelContext<Self>) -> bool {
|
||||
match trigger {
|
||||
AutoCloudHandoffTrigger::MacOsSleep | AutoCloudHandoffTrigger::Uri => {
|
||||
AISettings::as_ref(ctx).is_auto_handoff_on_sleep_enabled(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
fn find_workspace_and_terminal(
|
||||
terminal_view_id: EntityId,
|
||||
ctx: &ModelContext<Self>,
|
||||
) -> Option<(WindowId, ViewHandle<Workspace>, ViewHandle<TerminalView>)> {
|
||||
WorkspaceRegistry::as_ref(ctx)
|
||||
.all_workspaces(ctx)
|
||||
.into_iter()
|
||||
.find_map(|(window_id, workspace)| {
|
||||
let terminal_view = workspace.as_ref(ctx).terminal_view(terminal_view_id, ctx)?;
|
||||
Some((window_id, workspace, terminal_view))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for AutoCloudHandoffController {
|
||||
type Event = AutoCloudHandoffRequest;
|
||||
}
|
||||
|
||||
impl SingletonEntity for AutoCloudHandoffController {}
|
||||
|
||||
pub(crate) fn init(app: &mut AppContext) {
|
||||
let controller = app.add_singleton_model(AutoCloudHandoffController::new);
|
||||
app.subscribe_to_model(&controller, |_, request, ctx| {
|
||||
request.dispatch(ctx);
|
||||
});
|
||||
}
|
||||
|
||||
/// Triggers an auto-handoff to the cloud. This is the entry point for the
|
||||
/// `warp://.../auto_handoff_to_cloud` URI action; the real macOS sleep path
|
||||
/// goes through the `SystemStats` subscription instead.
|
||||
///
|
||||
/// Callers that dispatch from inside an in-progress workspace view update
|
||||
/// (e.g. the debug palette entry) must defer past that update before calling
|
||||
/// this: `update_view` temporarily removes the dispatching workspace from its
|
||||
/// window, so the synchronous workspace lookup here would otherwise miss it.
|
||||
pub(crate) fn trigger_auto_handoff_to_cloud(
|
||||
trigger: AutoCloudHandoffTrigger,
|
||||
ctx: &mut AppContext,
|
||||
) {
|
||||
AutoCloudHandoffController::handle(ctx).update(ctx, |controller, ctx| {
|
||||
controller.trigger(trigger, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "auto_handoff_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,109 @@
|
||||
use super::{AutoCloudHandoffEligibility, AutoCloudHandoffSkipReason};
|
||||
|
||||
fn eligibility() -> AutoCloudHandoffEligibility {
|
||||
AutoCloudHandoffEligibility {
|
||||
is_empty: false,
|
||||
is_in_progress: true,
|
||||
has_server_conversation_token: true,
|
||||
is_viewing_shared_session: false,
|
||||
can_handoff_to_cloud: true,
|
||||
already_attempted: false,
|
||||
has_local_orchestrated_children: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eligible_running_synced_conversation_is_not_skipped() {
|
||||
assert_eq!(eligibility().skip_reason(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_handoff_skips_orchestrator_with_local_children() {
|
||||
let eligibility = AutoCloudHandoffEligibility {
|
||||
has_local_orchestrated_children: true,
|
||||
..eligibility()
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
eligibility.skip_reason(),
|
||||
Some(AutoCloudHandoffSkipReason::OrchestratorWithLocalChildren)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_handoff_skips_empty_conversations() {
|
||||
let eligibility = AutoCloudHandoffEligibility {
|
||||
is_empty: true,
|
||||
..eligibility()
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
eligibility.skip_reason(),
|
||||
Some(AutoCloudHandoffSkipReason::EmptyConversation)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_handoff_skips_idle_conversations() {
|
||||
let eligibility = AutoCloudHandoffEligibility {
|
||||
is_in_progress: false,
|
||||
..eligibility()
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
eligibility.skip_reason(),
|
||||
Some(AutoCloudHandoffSkipReason::NotInProgress)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_handoff_skips_unsynced_conversations() {
|
||||
let eligibility = AutoCloudHandoffEligibility {
|
||||
has_server_conversation_token: false,
|
||||
..eligibility()
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
eligibility.skip_reason(),
|
||||
Some(AutoCloudHandoffSkipReason::MissingServerConversationToken)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_handoff_skips_shared_session_viewers() {
|
||||
let eligibility = AutoCloudHandoffEligibility {
|
||||
is_viewing_shared_session: true,
|
||||
..eligibility()
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
eligibility.skip_reason(),
|
||||
Some(AutoCloudHandoffSkipReason::SharedSessionViewer)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_handoff_skips_already_attempted_conversations() {
|
||||
let eligibility = AutoCloudHandoffEligibility {
|
||||
already_attempted: true,
|
||||
..eligibility()
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
eligibility.skip_reason(),
|
||||
Some(AutoCloudHandoffSkipReason::AlreadyAttempted)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_handoff_skips_conversations_that_cannot_handoff_to_cloud() {
|
||||
let eligibility = AutoCloudHandoffEligibility {
|
||||
can_handoff_to_cloud: false,
|
||||
..eligibility()
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
eligibility.skip_reason(),
|
||||
Some(AutoCloudHandoffSkipReason::CloudHandoffUnavailable)
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use chrono::{Duration, Utc};
|
||||
use galaxy_core::settings::Setting;
|
||||
use warpui::{Entity, ModelContext, SingletonEntity};
|
||||
|
||||
use crate::ai::request_usage_model::{
|
||||
AIRequestUsageModel, AIRequestUsageModelEvent, BonusGrant, BonusGrantScope,
|
||||
};
|
||||
use crate::terminal::general_settings::GeneralSettings;
|
||||
use chrono::{Duration, Utc};
|
||||
use galaxy_core::settings::Setting;
|
||||
use galaxyui::{Entity, ModelContext, SingletonEntity};
|
||||
use std::collections::HashSet;
|
||||
|
||||
pub struct BonusGrantNotificationModel {
|
||||
/// In-memory tracking of grants shown during this session. This prevents duplicate
|
||||
@@ -27,7 +29,7 @@ impl SingletonEntity for BonusGrantNotificationModel {}
|
||||
|
||||
impl BonusGrantNotificationModel {
|
||||
pub fn new(ctx: &mut ModelContext<Self>) -> Self {
|
||||
ctx.subscribe_to_model(&AIRequestUsageModel::handle(ctx), |me, event, ctx| {
|
||||
ctx.subscribe_to_model(&AIRequestUsageModel::handle(ctx), |me, _, event, ctx| {
|
||||
if let AIRequestUsageModelEvent::RequestUsageUpdated = event {
|
||||
me.check_for_new_bonus_grants(ctx);
|
||||
}
|
||||
|
||||
@@ -7,11 +7,54 @@ use command::blocking::Command;
|
||||
use galaxy_core::channel::ChannelState;
|
||||
use galaxy_util::path::ShellFamily;
|
||||
|
||||
/// Compute the target path where the symlink should be installed, based on channel
|
||||
fn cli_install_target_path() -> PathBuf {
|
||||
/// Compute the target path where the Oz CLI symlink should be installed, based on channel
|
||||
fn oz_install_target_path() -> PathBuf {
|
||||
PathBuf::from("/usr/local/bin").join(ChannelState::channel().cli_command_name())
|
||||
}
|
||||
|
||||
/// Compute the target path where the Warp Control symlink should be installed, based on channel
|
||||
fn warpctrl_install_target_path() -> PathBuf {
|
||||
PathBuf::from("/usr/local/bin").join(ChannelState::channel().warpctrl_command_name())
|
||||
}
|
||||
|
||||
/// Compute the source path of the warpctrl wrapper inside the current app bundle.
|
||||
///
|
||||
/// Oz commands are part of the shared executable's normal argument parser, so
|
||||
/// Oz can symlink directly to the current executable. Warp Control has a
|
||||
/// separate parser selected by the hidden `--warpctrl` flag, so its installed
|
||||
/// symlink must target the bundled wrapper that injects that flag. Without it,
|
||||
/// Warp Control subcommands such as `tab` would reach the normal parser and be
|
||||
/// rejected as unknown.
|
||||
fn warpctrl_bundle_source_path() -> Result<PathBuf> {
|
||||
let current_binary =
|
||||
std::env::current_exe().context("Failed to get current executable path")?;
|
||||
let bundle_root = current_binary
|
||||
.parent()
|
||||
.and_then(|p| p.parent())
|
||||
.and_then(|p| p.parent())
|
||||
.ok_or_else(|| anyhow!("Current executable is not inside a bundled app"))?;
|
||||
Ok(bundle_root
|
||||
.join("Contents/Resources/bin")
|
||||
.join(ChannelState::channel().warpctrl_command_name()))
|
||||
}
|
||||
fn path_resolves_to(path: &Path, expected_path: &Path) -> bool {
|
||||
let Ok(path) = path.canonicalize() else {
|
||||
return false;
|
||||
};
|
||||
let Ok(expected_path) = expected_path.canonicalize() else {
|
||||
return false;
|
||||
};
|
||||
path == expected_path
|
||||
}
|
||||
|
||||
/// Whether the installed Warp Control command resolves to this app bundle's wrapper.
|
||||
pub fn is_warpctrl_installed() -> bool {
|
||||
let Ok(source) = warpctrl_bundle_source_path() else {
|
||||
return false;
|
||||
};
|
||||
path_resolves_to(&warpctrl_install_target_path(), &source)
|
||||
}
|
||||
|
||||
/// Create a symlink with elevated privileges using osascript
|
||||
///
|
||||
/// This function uses macOS's osascript to prompt for administrator privileges
|
||||
@@ -89,87 +132,112 @@ fn remove_file_with_admin(target: &Path) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Install the CLI by creating a symlink (channel-specific target)
|
||||
/// Install a channel-specific CLI symlink.
|
||||
///
|
||||
/// 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() {
|
||||
/// The target must either be absent or already be a symlink. Installation first
|
||||
/// attempts to create the symlink without elevated privileges, then falls back
|
||||
/// to prompting for administrator privileges.
|
||||
fn install_symlink(source: &Path, target: &Path, command_name: &str) -> Result<()> {
|
||||
if target.exists() && !target.is_symlink() {
|
||||
return Err(anyhow!(
|
||||
"Cannot install: {:?} exists but is not a symlink. Please remove it manually first.",
|
||||
cli_path
|
||||
"Cannot install {command_name}: {:?} exists but is not a symlink. Please remove it manually first.",
|
||||
target
|
||||
));
|
||||
}
|
||||
|
||||
// Try to create symlink without admin privileges first
|
||||
let symlink_result = symlink(¤t_binary, &cli_path);
|
||||
|
||||
match symlink_result {
|
||||
match symlink(source, target) {
|
||||
Ok(_) => {
|
||||
log::debug!(
|
||||
"CLI installed successfully without admin privileges: {:?} -> {}",
|
||||
cli_path,
|
||||
current_binary.display()
|
||||
"{command_name} installed successfully without admin privileges: {:?} -> {}",
|
||||
target,
|
||||
source.display()
|
||||
);
|
||||
}
|
||||
Err(_) => {
|
||||
log::debug!("Symlink creation failed, trying with admin privileges");
|
||||
|
||||
create_symlink_with_admin(¤t_binary, &cli_path)
|
||||
log::debug!("{command_name} symlink creation failed, trying with admin privileges");
|
||||
create_symlink_with_admin(source, target)
|
||||
.context("Failed to create symlink even with admin privileges")?;
|
||||
|
||||
log::debug!("CLI installed successfully with admin privileges");
|
||||
log::debug!("{command_name} installed successfully with admin privileges");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Uninstall the CLI by removing the symlink (channel-specific target)
|
||||
/// Uninstall a channel-specific CLI symlink.
|
||||
///
|
||||
/// 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."));
|
||||
/// The target must be a symlink so uninstalling cannot remove an unrelated
|
||||
/// file. Removal first runs without elevated privileges, then falls back to
|
||||
/// prompting for administrator privileges.
|
||||
fn uninstall_symlink(target: &Path, command_name: &str) -> Result<()> {
|
||||
if !target.exists() {
|
||||
return Err(anyhow!("{command_name} is not currently installed."));
|
||||
}
|
||||
|
||||
// Safety check: verify it's actually a symlink before removing
|
||||
if !cli_path.is_symlink() {
|
||||
if !target.is_symlink() {
|
||||
return Err(anyhow!(
|
||||
"Cannot uninstall: {:?} exists but is not a symlink. Please remove it manually.",
|
||||
cli_path
|
||||
"Cannot uninstall {command_name}: {:?} exists but is not a symlink. Please remove it manually.",
|
||||
target
|
||||
));
|
||||
}
|
||||
|
||||
// Try to remove without admin privileges first
|
||||
let remove_result = fs::remove_file(&cli_path);
|
||||
|
||||
match remove_result {
|
||||
match fs::remove_file(target) {
|
||||
Ok(_) => {
|
||||
log::debug!("CLI uninstalled successfully without admin privileges");
|
||||
log::debug!("{command_name} uninstalled successfully without admin privileges");
|
||||
}
|
||||
Err(_) => {
|
||||
log::debug!("File removal failed, trying with admin privileges");
|
||||
|
||||
remove_file_with_admin(&cli_path)
|
||||
log::debug!("{command_name} file removal failed, trying with admin privileges");
|
||||
remove_file_with_admin(target)
|
||||
.context("Failed to remove symlink even with admin privileges")?;
|
||||
|
||||
log::debug!("CLI uninstalled successfully with admin privileges");
|
||||
log::debug!("{command_name} uninstalled successfully with admin privileges");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Install the Oz CLI by symlinking the shared Warp executable into /usr/local/bin.
|
||||
///
|
||||
/// The normal argument parser dispatches Oz subcommands directly. It also uses
|
||||
/// the `oz`-prefixed invocation name to print CLI help rather than launch the
|
||||
/// GUI when no subcommand is provided.
|
||||
pub fn install_oz() -> Result<()> {
|
||||
let oz_path = oz_install_target_path();
|
||||
let current_binary =
|
||||
std::env::current_exe().context("Failed to get current executable path")?;
|
||||
install_symlink(¤t_binary, &oz_path, "Oz CLI")
|
||||
}
|
||||
|
||||
/// Uninstall the Oz CLI by removing the symlink from /usr/local/bin
|
||||
pub fn uninstall_oz() -> Result<()> {
|
||||
uninstall_symlink(&oz_install_target_path(), "Oz command")
|
||||
}
|
||||
|
||||
/// Install Warp Control by symlinking its bundled wrapper into /usr/local/bin.
|
||||
///
|
||||
/// The wrapper contains no control implementation. It resolves this installed
|
||||
/// symlink back into the app bundle, launches the shared Warp executable, and
|
||||
/// injects `--warpctrl` so startup selects the separate Warp Control parser
|
||||
/// before normal parsing or GUI startup.
|
||||
pub fn install_warpctrl() -> Result<()> {
|
||||
let warpctrl_path = warpctrl_install_target_path();
|
||||
let warpctrl_source = warpctrl_bundle_source_path()?;
|
||||
|
||||
if !warpctrl_source.exists() {
|
||||
return Err(anyhow!(
|
||||
"Cannot install Warp Control CLI: bundled wrapper not found at {}",
|
||||
warpctrl_source.display()
|
||||
));
|
||||
}
|
||||
|
||||
install_symlink(&warpctrl_source, &warpctrl_path, "Warp Control CLI")
|
||||
}
|
||||
|
||||
/// Uninstall the Warp Control CLI by removing the symlink from /usr/local/bin
|
||||
pub fn uninstall_warpctrl() -> Result<()> {
|
||||
uninstall_symlink(&warpctrl_install_target_path(), "Warp Control command")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "cli_install_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
use std::fs;
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn path_resolves_to_detects_matching_symlink() -> Result<()> {
|
||||
let temp_dir = tempfile::tempdir()?;
|
||||
let source = temp_dir.path().join("source");
|
||||
let target = temp_dir.path().join("target");
|
||||
|
||||
fs::write(&source, "wrapper")?;
|
||||
assert!(!path_resolves_to(&target, &source));
|
||||
|
||||
symlink(&source, &target)?;
|
||||
assert!(path_resolves_to(&target, &source));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,26 +1,22 @@
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use galaxy_core::ui::theme::Fill;
|
||||
use galaxyui::elements::{
|
||||
Align, ChildAnchor, Container, MouseStateHandle, OffsetPositioning, ParentAnchor,
|
||||
ParentOffsetBounds, Stack,
|
||||
};
|
||||
use galaxyui::fonts::Weight;
|
||||
use galaxyui::platform::Cursor;
|
||||
use galaxyui::ui_components::button::ButtonVariant;
|
||||
use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
|
||||
use galaxyui::ui_components::text::Span;
|
||||
use galaxyui::{
|
||||
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 pathfinder_geometry::vector::vec2f;
|
||||
|
||||
use crate::{
|
||||
appearance::Appearance,
|
||||
pane_group::PaneId,
|
||||
ui_components::dialog::{dialog_styles, Dialog},
|
||||
workspace::TabMovement,
|
||||
};
|
||||
use crate::appearance::Appearance;
|
||||
use crate::pane_group::PaneId;
|
||||
use crate::ui_components::dialog::{dialog_styles, Dialog};
|
||||
use crate::workspace::TabMovement;
|
||||
|
||||
#[allow(clippy::enum_variant_names)]
|
||||
#[derive(Copy, Clone)]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,102 @@
|
||||
//! Unit tests for [`CrossWindowTabDrag`] placeholder-collapse policy.
|
||||
//!
|
||||
//! These focus on [`CrossWindowTabDrag::collapsed_source_placeholder_index`],
|
||||
//! which decides whether the source window's horizontal tab bar collapses the
|
||||
//! detached-placeholder slot to zero width. The regression these guard against
|
||||
//! is the horizontal "fuzzy shake": collapsing the placeholder while the cursor
|
||||
//! is reordering it back in the source window removed the visible drop zone and
|
||||
//! made the slot oscillate every frame.
|
||||
|
||||
use warpui::geometry::vector::{vec2f, Vector2F};
|
||||
use warpui::WindowId;
|
||||
|
||||
use super::CrossWindowTabDrag;
|
||||
|
||||
const SOURCE_TAB_INDEX: usize = 2;
|
||||
|
||||
fn begin_multi_tab_drag(
|
||||
drag: &mut CrossWindowTabDrag,
|
||||
source_window_id: WindowId,
|
||||
preview_window_id: WindowId,
|
||||
) {
|
||||
drag.begin_multi_tab_drag(
|
||||
source_window_id,
|
||||
SOURCE_TAB_INDEX,
|
||||
Vector2F::zero(),
|
||||
vec2f(800.0, 600.0),
|
||||
Vector2F::zero(),
|
||||
preview_window_id,
|
||||
false,
|
||||
vec2f(120.0, 34.0),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_active_drag_keeps_all_slots_full_width() {
|
||||
let drag = CrossWindowTabDrag::new();
|
||||
assert_eq!(
|
||||
drag.collapsed_source_placeholder_index(WindowId::from_usize(1)),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_tab_drag_collapses_only_the_source_window_placeholder() {
|
||||
let source = WindowId::from_usize(1);
|
||||
let preview = WindowId::from_usize(2);
|
||||
let other = WindowId::from_usize(3);
|
||||
|
||||
let mut drag = CrossWindowTabDrag::new();
|
||||
begin_multi_tab_drag(&mut drag, source, preview);
|
||||
|
||||
// The source window collapses its detached placeholder while the tab is
|
||||
// floating in the preview window.
|
||||
assert_eq!(
|
||||
drag.collapsed_source_placeholder_index(source),
|
||||
Some(SOURCE_TAB_INDEX)
|
||||
);
|
||||
// The preview and unrelated windows never collapse a slot.
|
||||
assert_eq!(drag.collapsed_source_placeholder_index(preview), None);
|
||||
assert_eq!(drag.collapsed_source_placeholder_index(other), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_reorder_keeps_placeholder_full_width() {
|
||||
let source = WindowId::from_usize(1);
|
||||
let preview = WindowId::from_usize(2);
|
||||
|
||||
let mut drag = CrossWindowTabDrag::new();
|
||||
begin_multi_tab_drag(&mut drag, source, preview);
|
||||
|
||||
// Cursor returns to the source's own tab bar: the placeholder is reordered
|
||||
// in place like an in-window drag and must stay full width. Collapsing it
|
||||
// here is what produced the horizontal "fuzzy shake".
|
||||
drag.set_reordering_in_source_for_test(true);
|
||||
assert_eq!(drag.collapsed_source_placeholder_index(source), None);
|
||||
|
||||
// Leaving the source again restores the zero-width collapse.
|
||||
drag.set_reordering_in_source_for_test(false);
|
||||
assert_eq!(
|
||||
drag.collapsed_source_placeholder_index(source),
|
||||
Some(SOURCE_TAB_INDEX)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_tab_drag_never_collapses_a_slot() {
|
||||
let source = WindowId::from_usize(1);
|
||||
|
||||
let mut drag = CrossWindowTabDrag::new();
|
||||
// A single-tab window is its own floating preview; there is no separate
|
||||
// placeholder to collapse.
|
||||
drag.begin_single_tab_drag(
|
||||
source,
|
||||
Vector2F::zero(),
|
||||
vec2f(800.0, 600.0),
|
||||
Vector2F::zero(),
|
||||
false,
|
||||
vec2f(120.0, 34.0),
|
||||
);
|
||||
|
||||
assert_eq!(drag.collapsed_source_placeholder_index(source), None);
|
||||
}
|
||||
@@ -1,22 +1,20 @@
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use galaxy_core::ui::theme::Fill;
|
||||
use galaxyui::elements::{
|
||||
Align, ChildAnchor, ChildView, Container, OffsetPositioning, ParentAnchor, ParentOffsetBounds,
|
||||
Stack,
|
||||
};
|
||||
use galaxyui::keymap::{FixedBinding, Keystroke};
|
||||
use galaxyui::ui_components::components::{UiComponent, UiComponentStyles};
|
||||
use galaxyui::{
|
||||
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 pathfinder_geometry::vector::vec2f;
|
||||
|
||||
use crate::{
|
||||
ai::agent::conversation::AIConversationId,
|
||||
appearance::Appearance,
|
||||
ui_components::dialog::{dialog_styles, Dialog},
|
||||
view_components::action_button::{
|
||||
ActionButton, DangerPrimaryTheme, KeystrokeSource, NakedTheme,
|
||||
},
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::appearance::Appearance;
|
||||
use crate::ui_components::dialog::{dialog_styles, Dialog};
|
||||
use crate::view_components::action_button::{
|
||||
ActionButton, DangerPrimaryTheme, KeystrokeSource, NakedTheme,
|
||||
};
|
||||
|
||||
pub fn init(app: &mut AppContext) {
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
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 std::path::PathBuf;
|
||||
|
||||
use ::settings::ToggleableSetting;
|
||||
use galaxy_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 galaxy_graphql::mutations::create_anonymous_user::AnonymousUserType;
|
||||
use galaxyui::windowing::WindowManager;
|
||||
use galaxyui::{AppContext, SingletonEntity, TypedActionView};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::agent::AIAgentExchangeId;
|
||||
use crate::app_state::get_app_state;
|
||||
use crate::network::NetworkStatus;
|
||||
use crate::persistence::ModelEvent;
|
||||
use crate::root_view::OpenPath;
|
||||
use crate::server::server_api::ServerApiProvider;
|
||||
use crate::terminal::alt_screen_reporting::AltScreenReporting;
|
||||
use crate::terminal::general_settings::GeneralSettings;
|
||||
use crate::undo_close::UndoCloseStack;
|
||||
use crate::workspace::cross_window_tab_drag::CrossWindowTabDrag;
|
||||
use crate::workspace::{Workspace, WorkspaceAction};
|
||||
use crate::{auth, GlobalResourceHandlesProvider};
|
||||
|
||||
/// Specifies where a forked conversation should be opened.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
@@ -32,6 +33,16 @@ pub enum ForkedConversationDestination {
|
||||
}
|
||||
|
||||
impl ForkedConversationDestination {
|
||||
/// Fork destination from an Enter (`false`) / Cmd-or-Ctrl+Enter (`true`) trigger: Enter
|
||||
/// opens a new split pane, Cmd/Ctrl+Enter opens a new tab. Shared by all fork-style commands.
|
||||
pub fn for_fork_trigger(cmd_or_ctrl_enter: bool) -> Self {
|
||||
if cmd_or_ctrl_enter {
|
||||
Self::NewTab
|
||||
} else {
|
||||
Self::SplitPane
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_new_tab(&self) -> bool {
|
||||
matches!(self, Self::NewTab)
|
||||
}
|
||||
@@ -127,6 +138,18 @@ fn save_app(_: &(), ctx: &mut AppContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
// While a cross-window tab drag is active, the dragged tab's pane group
|
||||
// is in flight between source and preview windows and `get_app_state`
|
||||
// would produce a snapshot with zero windows. Persisting that snapshot
|
||||
// wipes the on-disk session via `save_app_state`'s delete-then-insert
|
||||
// transaction. `save_app` fires from window move / focus / resize /
|
||||
// close callbacks (see `app_callbacks` in `lib.rs`), all of which run
|
||||
// during a drag, so we have to short-circuit at this boundary. The
|
||||
// first save after the drag finalizes will rewrite the snapshot.
|
||||
if CrossWindowTabDrag::as_ref(ctx).is_active() {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(model_event_sender) = GlobalResourceHandlesProvider::as_ref(ctx)
|
||||
.get()
|
||||
.model_event_sender
|
||||
@@ -160,9 +183,10 @@ fn toggle_debug_network_status(_: &(), ctx: &mut AppContext) {
|
||||
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 auth_client =
|
||||
ServerApiProvider::handle(ctx).read(ctx, |provider, _ctx| provider.get_auth_client());
|
||||
let result =
|
||||
galaxyui::r#async::block_on(server_api.create_anonymous_user(None, anonymous_user_type));
|
||||
galaxyui::r#async::block_on(auth_client.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:?}"),
|
||||
@@ -218,6 +242,7 @@ fn fork_ai_conversation(params: &ForkAIConversationParams, ctx: &mut AppContext)
|
||||
summarize_after_fork: params.summarize_after_fork,
|
||||
summarization_prompt: params.summarization_prompt.clone(),
|
||||
initial_prompt: params.initial_prompt.clone(),
|
||||
initial_attachments: vec![],
|
||||
destination: params.destination,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use settings::Setting as _;
|
||||
use galaxyui::keymap::FixedBinding;
|
||||
|
||||
use galaxyui::{AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext};
|
||||
|
||||
use crate::chip_configurator::{
|
||||
@@ -7,15 +7,11 @@ use crate::chip_configurator::{
|
||||
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 _;
|
||||
use crate::{report_if_error, Appearance};
|
||||
|
||||
const MODAL_TITLE: &str = "Edit toolbar";
|
||||
|
||||
@@ -179,15 +175,6 @@ fn sync_show_hide_settings<V: View>(
|
||||
.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 {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use settings::Setting as _;
|
||||
use warpui::{AppContext, SingletonEntity};
|
||||
|
||||
use crate::auth::AuthStateProvider;
|
||||
use crate::features::FeatureFlag;
|
||||
@@ -6,9 +8,6 @@ use crate::settings::AISettings;
|
||||
use crate::ui_components::icons::Icon;
|
||||
use crate::workspace::tab_settings::TabSettings;
|
||||
|
||||
use galaxyui::{AppContext, SingletonEntity};
|
||||
use settings::Setting as _;
|
||||
|
||||
/// A configurable item in the vertical tabs header toolbar.
|
||||
///
|
||||
/// Each variant represents a panel toggle button that can be placed on either
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use markdown_parser::{
|
||||
FormattedText, FormattedTextFragment, FormattedTextLine, FormattedTextStyles, Hyperlink,
|
||||
};
|
||||
use pathfinder_color::ColorU;
|
||||
use settings::Setting;
|
||||
use galaxy_core::ui::theme::phenomenon::PhenomenonStyle;
|
||||
use galaxy_core::ui::theme::Fill;
|
||||
use galaxyui::elements::{
|
||||
Align, ChildView, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Empty, Flex,
|
||||
FormattedTextElement, MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, Radius,
|
||||
@@ -19,9 +26,7 @@ use markdown_parser::{
|
||||
FormattedText, FormattedTextFragment, FormattedTextLine, FormattedTextStyles, Hyperlink,
|
||||
};
|
||||
|
||||
use galaxy_core::ui::theme::{phenomenon::PhenomenonStyle, Fill};
|
||||
use pathfinder_color::ColorU;
|
||||
|
||||
use super::{tab_config_step, welcome_banner};
|
||||
use crate::appearance::Appearance;
|
||||
use crate::settings::AISettings;
|
||||
use crate::tab_configs::session_config::{is_git_repo, SessionConfigSelection, SessionType};
|
||||
@@ -36,11 +41,6 @@ use crate::view_components::callout_bubble::{
|
||||
};
|
||||
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;
|
||||
|
||||
@@ -3,10 +3,8 @@ mod tab_config_step;
|
||||
mod welcome_banner;
|
||||
|
||||
pub use hoa_onboarding_flow::{init, HoaOnboardingFlow, HoaOnboardingFlowEvent, HoaOnboardingStep};
|
||||
|
||||
use galaxyui::AppContext;
|
||||
|
||||
use galaxy_core::user_preferences::GetUserPreferences;
|
||||
use galaxyui::AppContext;
|
||||
|
||||
const HAS_COMPLETED_HOA_ONBOARDING_KEY: &str = "HasCompletedHOAOnboarding";
|
||||
|
||||
|
||||
@@ -5,8 +5,7 @@ use galaxyui::elements::{
|
||||
};
|
||||
use galaxyui::fonts::{Properties, Weight};
|
||||
use galaxyui::geometry::vector::Vector2F;
|
||||
use galaxyui::Element;
|
||||
use galaxyui::EventContext;
|
||||
use galaxyui::{Element, EventContext};
|
||||
|
||||
use crate::appearance::Appearance;
|
||||
use crate::tab_configs::session_config::SessionType;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use galaxy_core::ui::theme::{phenomenon::PhenomenonStyle, Fill};
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use galaxy_core::ui::theme::phenomenon::PhenomenonStyle;
|
||||
use galaxy_core::ui::theme::Fill;
|
||||
use galaxyui::assets::asset_cache::AssetSource;
|
||||
use galaxyui::elements::{
|
||||
CacheOption, ChildAnchor, ChildView, ConstrainedBox, Container, CornerRadius,
|
||||
@@ -6,15 +8,12 @@ use galaxyui::elements::{
|
||||
ParentElement, ParentOffsetBounds, Radius, Stack, Text,
|
||||
};
|
||||
use galaxyui::fonts::{Properties, Weight};
|
||||
use galaxyui::Element;
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use galaxyui::{Element, ViewHandle};
|
||||
|
||||
use crate::appearance::Appearance;
|
||||
use crate::ui_components::icons::Icon;
|
||||
use crate::view_components::action_button::ActionButton;
|
||||
|
||||
use galaxyui::ViewHandle;
|
||||
|
||||
const BANNER_WIDTH: f32 = 420.;
|
||||
const HERO_HEIGHT: f32 = 92.;
|
||||
const HERO_IMAGE_PATH: &str = "async/png/onboarding/hoa_welcome_banner.png";
|
||||
@@ -85,15 +84,23 @@ pub fn render_welcome_banner(
|
||||
);
|
||||
|
||||
// "New" badge
|
||||
let badge = Container::new(
|
||||
Text::new_inline("New".to_string(), appearance.ui_font_family(), 14.)
|
||||
.with_color(PhenomenonStyle::modal_badge_text())
|
||||
.finish(),
|
||||
let text = Text::new_inline("New".to_string(), appearance.ui_font_family(), 14.)
|
||||
.with_color(PhenomenonStyle::modal_badge_text())
|
||||
.finish();
|
||||
let badge = ConstrainedBox::new(
|
||||
Container::new(
|
||||
Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_main_axis_size(MainAxisSize::Min)
|
||||
.with_child(text)
|
||||
.finish(),
|
||||
)
|
||||
.with_horizontal_padding(8.)
|
||||
.with_background(Fill::Solid(PhenomenonStyle::modal_badge_background()))
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Percentage(50.)))
|
||||
.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()))
|
||||
.with_height(24.)
|
||||
.finish();
|
||||
|
||||
// Title
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use galaxyui::assets::asset_cache::{AssetCache, AssetSource, AssetState};
|
||||
use galaxyui::image_cache::ImageType;
|
||||
use galaxyui::keymap::{FixedBinding, Keystroke};
|
||||
use galaxyui::prelude::*;
|
||||
use galaxyui::{AppContext, BlurContext, Element, Entity, SingletonEntity, View, ViewContext};
|
||||
pub use lightbox::LightboxImage;
|
||||
use pathfinder_geometry::vector::Vector2F;
|
||||
use ui_components::{lightbox, Component as _};
|
||||
|
||||
use crate::appearance::Appearance;
|
||||
|
||||
pub use lightbox::LightboxImage;
|
||||
|
||||
pub fn init(app: &mut AppContext) {
|
||||
use galaxyui::keymap::macros::*;
|
||||
let view_id = id!(LightboxView::ui_name());
|
||||
|
||||
+230
-85
@@ -1,9 +1,11 @@
|
||||
mod action;
|
||||
mod active_session;
|
||||
pub(crate) mod auto_handoff;
|
||||
pub mod bonus_grant_notification_model;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod cli_install;
|
||||
pub(crate) mod cli_install;
|
||||
mod close_session_confirmation_dialog;
|
||||
pub(crate) mod cross_window_tab_drag;
|
||||
pub mod delete_conversation_confirmation_dialog;
|
||||
mod global_actions;
|
||||
pub mod header_toolbar_editor;
|
||||
@@ -16,90 +18,65 @@ mod one_time_modal_model;
|
||||
mod registry;
|
||||
pub mod rewind_confirmation_dialog;
|
||||
pub mod sync_inputs;
|
||||
pub mod tab_group;
|
||||
pub mod tab_settings;
|
||||
mod toast_stack;
|
||||
pub mod util;
|
||||
pub mod view;
|
||||
|
||||
use crate::ai::blocklist::NEW_AGENT_PANE_LABEL;
|
||||
use crate::ai::skills::SkillManager;
|
||||
use crate::ai::AIRequestUsageModel;
|
||||
use crate::channel::Channel;
|
||||
use crate::code;
|
||||
use crate::features::FeatureFlag;
|
||||
use crate::modal;
|
||||
use crate::notebooks;
|
||||
use crate::pane_group::TabBarHoverIndex;
|
||||
use crate::server::telemetry::AgentModeEntrypoint;
|
||||
use crate::server::telemetry::PaletteSource;
|
||||
use crate::settings::AISettings;
|
||||
use crate::settings_view::{self, flags, SettingsSection};
|
||||
use crate::tab::uses_vertical_tabs;
|
||||
use crate::tab_configs;
|
||||
use galaxyui::SingletonEntity;
|
||||
|
||||
use crate::channel::ChannelState;
|
||||
|
||||
use crate::util::bindings::{self, cmd_or_ctrl_shift, is_binding_pty_compliant, CustomAction};
|
||||
|
||||
use crate::palette::PaletteMode;
|
||||
use galaxy_core::context_flag::ContextFlag;
|
||||
use galaxyui::accessibility::AccessibilityVerbosity;
|
||||
use galaxyui::elements::DropTargetData;
|
||||
use galaxyui::keymap::FixedBinding;
|
||||
use galaxyui::keymap::{BindingDescription, EditableBinding};
|
||||
use galaxyui::AppContext;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub use action::{
|
||||
CommandSearchOptions, InitContent, RestoreConversationLayout, TabContextMenuAnchor,
|
||||
VerticalTabsPaneContextMenuTarget, WorkspaceAction,
|
||||
AutoCloudHandoffTrigger, CommandSearchOptions, InitContent, RestoreConversationLayout,
|
||||
TabContextMenuAnchor, VerticalTabsPaneContextMenuTarget, WorkspaceAction,
|
||||
};
|
||||
pub use active_session::ActiveSession;
|
||||
pub use global_actions::{
|
||||
ForkAIConversationParams, ForkFromExchange, ForkedConversationDestination,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
pub use util::{active_terminal_in_window, PaneViewLocator, TabMovement};
|
||||
pub use view::{
|
||||
Workspace, NEW_SESSION_MENU_BUTTON_POSITION_ID, NEW_TAB_BUTTON_POSITION_ID,
|
||||
PANEL_HEADER_HEIGHT, TAB_BAR_HEIGHT, TOTAL_TAB_BAR_HEIGHT, WORKSPACE_PADDING,
|
||||
};
|
||||
use galaxy_core::context_flag::ContextFlag;
|
||||
use warpui::accessibility::AccessibilityVerbosity;
|
||||
use warpui::elements::DropTargetData;
|
||||
use warpui::keymap::{BindingDescription, EditableBinding, FixedBinding};
|
||||
use warpui::AppContext;
|
||||
|
||||
use crate::ai::blocklist::NEW_AGENT_PANE_LABEL;
|
||||
use crate::channel::{Channel, ChannelState};
|
||||
use crate::features::FeatureFlag;
|
||||
use crate::palette::PaletteMode;
|
||||
use crate::server::telemetry::{AgentModeEntrypoint, PaletteSource};
|
||||
use crate::settings_view::{self, flags, SettingsSection};
|
||||
use crate::tab::{uses_vertical_tabs, NewSessionMenuItem};
|
||||
use crate::util::bindings::{self, cmd_or_ctrl_shift, is_binding_pty_compliant, CustomAction};
|
||||
use crate::{code, modal, notebooks, tab_configs};
|
||||
|
||||
// Helper function to access panel header corner radius from other modules
|
||||
pub fn panel_header_corner_radius() -> galaxyui::elements::CornerRadius {
|
||||
galaxyui::elements::CornerRadius::with_top(galaxyui::elements::Radius::Pixels(8.))
|
||||
}
|
||||
|
||||
/// Returns `true` when `WorkspaceAction::SendFeedback` will launch the guided
|
||||
/// feedback skill in a new agent pane. When `false`, the action falls back to
|
||||
/// opening the GitHub issue form in the browser.
|
||||
///
|
||||
/// Kept in sync with the availability check in `Workspace::send_feedback` so
|
||||
/// the command palette label and the menu item behavior never diverge.
|
||||
pub fn is_feedback_skill_available(ctx: &AppContext) -> bool {
|
||||
AISettings::as_ref(ctx).is_any_ai_enabled(ctx)
|
||||
&& AIRequestUsageModel::as_ref(ctx).has_any_ai_remaining(ctx)
|
||||
&& SkillManager::as_ref(ctx)
|
||||
.active_bundled_skill("feedback", ctx)
|
||||
.is_some()
|
||||
}
|
||||
pub use one_time_modal_model::OneTimeModalModel;
|
||||
pub use registry::WorkspaceRegistry;
|
||||
pub use toast_stack::ToastStack;
|
||||
|
||||
use crate::workspace::view::{
|
||||
LEFT_PANEL_AGENT_CONVERSATIONS_BINDING_NAME, LEFT_PANEL_GLOBAL_SEARCH_BINDING_NAME,
|
||||
LEFT_PANEL_PROJECT_EXPLORER_BINDING_NAME, LEFT_PANEL_WARP_DRIVE_BINDING_NAME,
|
||||
NEW_AGENT_TAB_BINDING_NAME, NEW_AMBIENT_AGENT_TAB_BINDING_NAME, NEW_TAB_BINDING_NAME,
|
||||
NEW_TERMINAL_TAB_BINDING_NAME, OPEN_GLOBAL_SEARCH_BINDING_NAME,
|
||||
NEW_AGENT_TAB_BINDING_NAME, NEW_AMBIENT_AGENT_TAB_BINDING_NAME, NEW_FILE_BINDING_NAME,
|
||||
NEW_TAB_BINDING_NAME, NEW_TERMINAL_TAB_BINDING_NAME, OPEN_GLOBAL_SEARCH_BINDING_NAME,
|
||||
TOGGLE_CONVERSATION_LIST_VIEW_BINDING_NAME, TOGGLE_NOTIFICATION_MAILBOX_BINDING_NAME,
|
||||
TOGGLE_PROJECT_EXPLORER_BINDING_NAME, TOGGLE_RIGHT_PANEL_BINDING_NAME,
|
||||
TOGGLE_TAB_CONFIGS_MENU_BINDING_NAME, TOGGLE_VERTICAL_TABS_PANEL_BINDING_NAME,
|
||||
TOGGLE_WARP_DRIVE_BINDING_NAME,
|
||||
};
|
||||
pub use one_time_modal_model::OneTimeModalModel;
|
||||
pub use registry::WorkspaceRegistry;
|
||||
pub use toast_stack::ToastStack;
|
||||
|
||||
pub fn init(app: &mut AppContext) {
|
||||
app.add_singleton_model(|_| WorkspaceRegistry::new());
|
||||
app.add_singleton_model(|_| cross_window_tab_drag::CrossWindowTabDrag::new());
|
||||
use galaxyui::keymap::macros::*;
|
||||
app.register_binding_validator::<Workspace>(is_binding_pty_compliant);
|
||||
|
||||
@@ -113,8 +90,11 @@ pub fn init(app: &mut AppContext) {
|
||||
tab_configs::session_config_modal::init(app);
|
||||
view::launch_modal::oz_launch::init(app);
|
||||
view::openwarp_launch_modal::init(app);
|
||||
view::orchestration_launch_modal::init(app);
|
||||
view::auto_handoff_sleep_modal::init(app);
|
||||
view::cloud_agent_capacity_modal::init(app);
|
||||
view::codex_modal::init(app);
|
||||
view::free_ai_removal_modal::init(app);
|
||||
view::free_tier_limit_hit_modal::init(app);
|
||||
view::global_search::view::GlobalSearchView::init(app);
|
||||
view::right_panel::RightPanelView::init(app);
|
||||
@@ -227,6 +207,48 @@ pub fn init(app: &mut AppContext) {
|
||||
WorkspaceAction::ResetOpenWarpLaunchModalState,
|
||||
)
|
||||
.with_context_predicate(id!("Workspace")),
|
||||
EditableBinding::new(
|
||||
"workspace:open_orchestration_launch_modal",
|
||||
"[Debug] Open Orchestration Launch Modal",
|
||||
WorkspaceAction::OpenOrchestrationLaunchModal,
|
||||
)
|
||||
.with_context_predicate(id!("Workspace")),
|
||||
EditableBinding::new(
|
||||
"workspace:reset_orchestration_launch_modal_state",
|
||||
"[Debug] Reset Orchestration Launch Modal State",
|
||||
WorkspaceAction::ResetOrchestrationLaunchModalState,
|
||||
)
|
||||
.with_context_predicate(id!("Workspace")),
|
||||
EditableBinding::new(
|
||||
"workspace:open_auto_handoff_sleep_modal",
|
||||
"[Debug] Open Auto-Handoff Sleep Modal",
|
||||
WorkspaceAction::OpenAutoHandoffSleepModal,
|
||||
)
|
||||
.with_context_predicate(id!("Workspace")),
|
||||
EditableBinding::new(
|
||||
"workspace:reset_auto_handoff_sleep_modal_state",
|
||||
"[Debug] Reset Auto-Handoff Sleep Modal State",
|
||||
WorkspaceAction::ResetAutoHandoffSleepModalState,
|
||||
)
|
||||
.with_context_predicate(id!("Workspace")),
|
||||
EditableBinding::new(
|
||||
"workspace:trigger_auto_handoff_to_cloud",
|
||||
"[Debug] Trigger Auto-Handoff to Cloud",
|
||||
WorkspaceAction::TriggerAutoHandoffToCloud,
|
||||
)
|
||||
.with_context_predicate(id!("Workspace")),
|
||||
EditableBinding::new(
|
||||
"workspace:open_free_ai_removal_modal",
|
||||
"[Debug] Open Free AI Removal Modal",
|
||||
WorkspaceAction::OpenFreeAiRemovalModal,
|
||||
)
|
||||
.with_context_predicate(id!("Workspace")),
|
||||
EditableBinding::new(
|
||||
"workspace:reset_free_ai_removal_modal_state",
|
||||
"[Debug] Reset Free AI Removal Modal State",
|
||||
WorkspaceAction::ResetFreeAiRemovalModalState,
|
||||
)
|
||||
.with_context_predicate(id!("Workspace")),
|
||||
EditableBinding::new(
|
||||
"workspace:install_opencode_warp_plugin",
|
||||
"[Debug] Install OpenCode Galaxy plugin",
|
||||
@@ -293,14 +315,16 @@ pub fn init(app: &mut AppContext) {
|
||||
id!("Workspace"),
|
||||
)
|
||||
.with_enabled(|| ContextFlag::CreateNewSession.is_enabled()),
|
||||
FixedBinding::custom(
|
||||
CustomAction::NewFile,
|
||||
WorkspaceAction::NewCodeFile,
|
||||
"New File",
|
||||
id!("Workspace") & !id!("Workspace_ViewOnlySharedSession"),
|
||||
),
|
||||
]);
|
||||
|
||||
app.register_editable_bindings([EditableBinding::new(
|
||||
NEW_FILE_BINDING_NAME,
|
||||
BindingDescription::new("New File"),
|
||||
WorkspaceAction::NewCodeFile,
|
||||
)
|
||||
.with_custom_action(CustomAction::NewFile)
|
||||
.with_context_predicate(id!("Workspace") & !id!("Workspace_ViewOnlySharedSession"))]);
|
||||
|
||||
if FeatureFlag::UIZoom.is_enabled() {
|
||||
app.register_fixed_bindings([
|
||||
FixedBinding::custom(
|
||||
@@ -411,7 +435,7 @@ pub fn init(app: &mut AppContext) {
|
||||
)
|
||||
.with_context_predicate(id!("Workspace"))
|
||||
.with_group(bindings::BindingGroup::Settings.as_str())
|
||||
.with_key_binding("ctrl-shift->"),
|
||||
.with_key_binding("alt-shift->"),
|
||||
EditableBinding::new(
|
||||
"workspace:decrease_font_size",
|
||||
"Decrease font size",
|
||||
@@ -419,7 +443,7 @@ pub fn init(app: &mut AppContext) {
|
||||
)
|
||||
.with_context_predicate(id!("Workspace"))
|
||||
.with_group(bindings::BindingGroup::Settings.as_str())
|
||||
.with_key_binding("ctrl-shift-<"),
|
||||
.with_key_binding("alt-shift-<"),
|
||||
EditableBinding::new(
|
||||
"workspace:reset_font_size",
|
||||
"Reset font size to default",
|
||||
@@ -594,13 +618,6 @@ pub fn init(app: &mut AppContext) {
|
||||
.with_context_predicate(id!("Workspace"))
|
||||
.with_group(bindings::BindingGroup::Navigation.as_str())
|
||||
.with_custom_action(CustomAction::ActivateNextPane),
|
||||
EditableBinding::new(
|
||||
"workspace:toggle_mouse_reporting",
|
||||
"Toggle Mouse Reporting",
|
||||
WorkspaceAction::ToggleMouseReporting,
|
||||
)
|
||||
.with_group(bindings::BindingGroup::Settings.as_str())
|
||||
.with_context_predicate(id!("Workspace")),
|
||||
EditableBinding::new(
|
||||
"workspace:create_team_notebook",
|
||||
BindingDescription::new("Create a new team notebook")
|
||||
@@ -735,6 +752,14 @@ pub fn init(app: &mut AppContext) {
|
||||
.with_group(bindings::BindingGroup::Navigation.as_str())
|
||||
.with_enabled(|| FeatureFlag::VerticalTabs.is_enabled())
|
||||
.with_key_binding(cmd_or_ctrl_shift("b")),
|
||||
EditableBinding::new(
|
||||
LEFT_PANEL_PROJECT_EXPLORER_BINDING_NAME,
|
||||
BindingDescription::new("Left Panel: Project explorer"),
|
||||
WorkspaceAction::ToggleProjectExplorer,
|
||||
)
|
||||
.with_group(bindings::BindingGroup::Navigation.as_str())
|
||||
.with_context_predicate(id!("Workspace") & id!(flags::SHOW_PROJECT_EXPLORER))
|
||||
.with_custom_action(CustomAction::ToggleProjectExplorer),
|
||||
EditableBinding::new(
|
||||
LEFT_PANEL_AGENT_CONVERSATIONS_BINDING_NAME,
|
||||
BindingDescription::new("Left Panel: Agent conversations"),
|
||||
@@ -744,14 +769,6 @@ pub fn init(app: &mut AppContext) {
|
||||
.with_context_predicate(id!("Workspace") & id!(flags::SHOW_CONVERSATION_HISTORY))
|
||||
.with_enabled(|| FeatureFlag::AgentViewConversationListView.is_enabled())
|
||||
.with_custom_action(CustomAction::ToggleConversationListView),
|
||||
EditableBinding::new(
|
||||
LEFT_PANEL_PROJECT_EXPLORER_BINDING_NAME,
|
||||
BindingDescription::new("Left Panel: Project explorer"),
|
||||
WorkspaceAction::ToggleProjectExplorer,
|
||||
)
|
||||
.with_group(bindings::BindingGroup::Navigation.as_str())
|
||||
.with_context_predicate(id!("Workspace") & id!(flags::SHOW_PROJECT_EXPLORER))
|
||||
.with_custom_action(CustomAction::ToggleProjectExplorer),
|
||||
EditableBinding::new(
|
||||
LEFT_PANEL_GLOBAL_SEARCH_BINDING_NAME,
|
||||
BindingDescription::new("Left Panel: Global search"),
|
||||
@@ -761,6 +778,15 @@ pub fn init(app: &mut AppContext) {
|
||||
.with_context_predicate(id!("Workspace") & id!(flags::SHOW_GLOBAL_SEARCH))
|
||||
.with_enabled(|| FeatureFlag::GlobalSearch.is_enabled())
|
||||
.with_custom_action(CustomAction::ToggleGlobalSearch),
|
||||
EditableBinding::new(
|
||||
"file_tree:toggle_hidden_files",
|
||||
BindingDescription::new("Toggle hidden files in Project Explorer"),
|
||||
WorkspaceAction::ToggleHiddenFiles,
|
||||
)
|
||||
.with_group(bindings::BindingGroup::Navigation.as_str())
|
||||
.with_context_predicate(id!("Workspace") & id!(flags::SHOW_PROJECT_EXPLORER))
|
||||
.with_mac_key_binding("cmd-shift->")
|
||||
.with_linux_or_windows_key_binding("ctrl-shift->"),
|
||||
EditableBinding::new(
|
||||
LEFT_PANEL_WARP_DRIVE_BINDING_NAME,
|
||||
BindingDescription::new("Left Panel: Galaxy Drive"),
|
||||
@@ -912,6 +938,112 @@ pub fn init(app: &mut AppContext) {
|
||||
.with_custom_action(CustomAction::RenameTab)
|
||||
.with_context_predicate(id!("Workspace"))]);
|
||||
|
||||
// Pane rename — same shape as RenameActiveTab but acts on the focused pane
|
||||
// in the active tab. Ships with no default keybinding so it surfaces in
|
||||
// Settings → Keyboard shortcuts as remappable; resolves issue #9351, where
|
||||
// the action existed only in the right-click context menu and was not
|
||||
// reachable via the binding registry.
|
||||
app.register_editable_bindings([EditableBinding::new(
|
||||
"workspace:rename_active_pane",
|
||||
"Rename the current pane",
|
||||
WorkspaceAction::RenameActivePane,
|
||||
)
|
||||
.with_group(bindings::BindingGroup::Settings.as_str())
|
||||
.with_context_predicate(id!("Workspace"))]);
|
||||
|
||||
// Tab grouping bindings (keyless by default; gated on `GroupedTabs`).
|
||||
app.register_editable_bindings([
|
||||
EditableBinding::new(
|
||||
"workspace:new_tab_group",
|
||||
"Create new tab group",
|
||||
// Reuse the new-session dropdown's action, not a dedicated variant.
|
||||
WorkspaceAction::SelectNewSessionMenuItem(NewSessionMenuItem::CreateNewTabGroup),
|
||||
)
|
||||
.with_enabled(|| FeatureFlag::GroupedTabs.is_enabled())
|
||||
.with_group(bindings::BindingGroup::Navigation.as_str())
|
||||
.with_context_predicate(id!("Workspace") & !id!("Workspace_PaneDragging")),
|
||||
EditableBinding::new(
|
||||
"workspace:new_tab_group_from_active_or_selected_tabs",
|
||||
"Create tab group from active or selected tab(s)",
|
||||
WorkspaceAction::NewTabGroupFromActiveOrSelectedTabs,
|
||||
)
|
||||
.with_enabled(|| FeatureFlag::GroupedTabs.is_enabled())
|
||||
.with_group(bindings::BindingGroup::Navigation.as_str())
|
||||
.with_context_predicate(id!("Workspace") & !id!("Workspace_PaneDragging")),
|
||||
// Gated on `Workspace_ActiveOrSelectedTabsInGroup`: offered only when
|
||||
// there's an unambiguous group to leave — a single-group multi-selection,
|
||||
// or (with no selection) a grouped active tab. Mixed selections aren't
|
||||
// offered, matching the multi-tab right-click menu.
|
||||
EditableBinding::new(
|
||||
"workspace:remove_active_or_selected_tabs_from_group",
|
||||
"Remove active or selected tab(s) from group",
|
||||
WorkspaceAction::RemoveActiveOrSelectedTabsFromGroup,
|
||||
)
|
||||
.with_enabled(|| FeatureFlag::GroupedTabs.is_enabled())
|
||||
.with_group(bindings::BindingGroup::Navigation.as_str())
|
||||
.with_context_predicate(
|
||||
id!("Workspace")
|
||||
& id!("Workspace_ActiveOrSelectedTabsInGroup")
|
||||
& !id!("Workspace_PaneDragging"),
|
||||
),
|
||||
]);
|
||||
|
||||
// Tab/group pinning bindings (keyless by default; gated on `PinnedTabs`).
|
||||
// Pin/unpin are split into separate entries so the palette label tracks
|
||||
// the active tab/group's current state.
|
||||
app.register_editable_bindings([
|
||||
EditableBinding::new(
|
||||
"workspace:pin_active_tab",
|
||||
"Pin current tab",
|
||||
WorkspaceAction::PinActiveTab,
|
||||
)
|
||||
.with_enabled(|| FeatureFlag::PinnedTabs.is_enabled())
|
||||
.with_group(bindings::BindingGroup::Navigation.as_str())
|
||||
.with_context_predicate(
|
||||
id!("Workspace") & !id!("Workspace_ActiveTabPinned") & !id!("Workspace_PaneDragging"),
|
||||
),
|
||||
EditableBinding::new(
|
||||
"workspace:unpin_active_tab",
|
||||
"Unpin current tab",
|
||||
WorkspaceAction::UnpinActiveTab,
|
||||
)
|
||||
.with_enabled(|| FeatureFlag::PinnedTabs.is_enabled())
|
||||
.with_group(bindings::BindingGroup::Navigation.as_str())
|
||||
.with_context_predicate(
|
||||
id!("Workspace") & id!("Workspace_ActiveTabPinned") & !id!("Workspace_PaneDragging"),
|
||||
),
|
||||
EditableBinding::new(
|
||||
"workspace:pin_active_tab_group",
|
||||
"Pin current tab group",
|
||||
WorkspaceAction::PinActiveTabGroup,
|
||||
)
|
||||
.with_enabled(|| {
|
||||
FeatureFlag::PinnedTabs.is_enabled() && FeatureFlag::GroupedTabs.is_enabled()
|
||||
})
|
||||
.with_group(bindings::BindingGroup::Navigation.as_str())
|
||||
.with_context_predicate(
|
||||
id!("Workspace")
|
||||
& id!("Workspace_ActiveTabInGroup")
|
||||
& !id!("Workspace_ActiveTabGroupPinned")
|
||||
& !id!("Workspace_PaneDragging"),
|
||||
),
|
||||
EditableBinding::new(
|
||||
"workspace:unpin_active_tab_group",
|
||||
"Unpin current tab group",
|
||||
WorkspaceAction::UnpinActiveTabGroup,
|
||||
)
|
||||
.with_enabled(|| {
|
||||
FeatureFlag::PinnedTabs.is_enabled() && FeatureFlag::GroupedTabs.is_enabled()
|
||||
})
|
||||
.with_group(bindings::BindingGroup::Navigation.as_str())
|
||||
.with_context_predicate(
|
||||
id!("Workspace")
|
||||
& id!("Workspace_ActiveTabInGroup")
|
||||
& id!("Workspace_ActiveTabGroupPinned")
|
||||
& !id!("Workspace_PaneDragging"),
|
||||
),
|
||||
]);
|
||||
|
||||
app.register_editable_bindings([
|
||||
EditableBinding::new(
|
||||
"workspace:terminate_app",
|
||||
@@ -1082,25 +1214,43 @@ pub fn init(app: &mut AppContext) {
|
||||
.with_context_predicate(id!("Workspace") & id!(flags::ENABLE_WARP_DRIVE))]);
|
||||
}
|
||||
|
||||
// CLI install/uninstall actions (macOS only)
|
||||
// Oz and Warp Control CLI install/uninstall actions (macOS only)
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
app.register_editable_bindings([
|
||||
EditableBinding::new(
|
||||
"workspace:install_cli",
|
||||
"Install Oz CLI command",
|
||||
WorkspaceAction::InstallCLI,
|
||||
"Install Oz CLI globally for use outside of Warp",
|
||||
WorkspaceAction::InstallOz,
|
||||
)
|
||||
.with_group(bindings::BindingGroup::Settings.as_str())
|
||||
.with_context_predicate(id!("Workspace")),
|
||||
EditableBinding::new(
|
||||
"workspace:uninstall_cli",
|
||||
"Uninstall Oz CLI command",
|
||||
WorkspaceAction::UninstallCLI,
|
||||
"Undo global Oz CLI installation (oz will still work within Warp)",
|
||||
WorkspaceAction::UninstallOz,
|
||||
)
|
||||
.with_group(bindings::BindingGroup::Settings.as_str())
|
||||
.with_context_predicate(id!("Workspace")),
|
||||
]);
|
||||
if FeatureFlag::WarpControlCli.is_enabled() {
|
||||
app.register_editable_bindings([
|
||||
EditableBinding::new(
|
||||
"workspace:install_warpctrl",
|
||||
"Install Warp Control CLI globally for use outside of Warp",
|
||||
WorkspaceAction::InstallWarpctrl,
|
||||
)
|
||||
.with_group(bindings::BindingGroup::Settings.as_str())
|
||||
.with_context_predicate(id!("Workspace")),
|
||||
EditableBinding::new(
|
||||
"workspace:uninstall_warpctrl",
|
||||
"Undo global Warp Control CLI installation (warpctrl will still work within Warp)",
|
||||
WorkspaceAction::UninstallWarpctrl,
|
||||
)
|
||||
.with_group(bindings::BindingGroup::Settings.as_str())
|
||||
.with_context_predicate(id!("Workspace")),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if FeatureFlag::Changelog.is_enabled() {
|
||||
@@ -1336,7 +1486,6 @@ pub fn init(app: &mut AppContext) {
|
||||
}
|
||||
|
||||
fn add_open_setting_pages_as_editable_binding(app: &mut AppContext) {
|
||||
use galaxyui::keymap::macros::*;
|
||||
|
||||
// Add the ability to open setting modals to the command palette.
|
||||
app.register_editable_bindings([
|
||||
@@ -1481,7 +1630,6 @@ fn add_open_setting_pages_as_editable_binding(app: &mut AppContext) {
|
||||
}
|
||||
|
||||
fn add_overflow_menu_items_as_editable_binding(app: &mut AppContext) {
|
||||
use galaxyui::keymap::macros::*;
|
||||
|
||||
// Add the ability to open all overflow menu items to the command palette.
|
||||
app.register_editable_bindings([
|
||||
@@ -1506,9 +1654,7 @@ fn add_overflow_menu_items_as_editable_binding(app: &mut AppContext) {
|
||||
.with_context_predicate(id!("Workspace")),
|
||||
EditableBinding::new(
|
||||
"workspace:send_feedback",
|
||||
BindingDescription::new("Send feedback (opens external link)").with_dynamic_override(
|
||||
|ctx| is_feedback_skill_available(ctx).then(|| "Send feedback with Oz".into()),
|
||||
),
|
||||
BindingDescription::new("Send feedback (opens external link)"),
|
||||
WorkspaceAction::SendFeedback,
|
||||
)
|
||||
.with_context_predicate(id!("Workspace")),
|
||||
@@ -1536,7 +1682,6 @@ pub struct TabBarDropTargetData {
|
||||
#[derive(PartialEq, Copy, Clone, Debug)]
|
||||
pub struct VerticalTabsPaneDropTargetData {
|
||||
pub tab_bar_location: TabBarLocation,
|
||||
pub tab_hover_index: TabBarHoverIndex,
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Copy, Clone, Debug, Serialize, Deserialize)]
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
use settings::Setting as _;
|
||||
use galaxy_core::ui::theme::Fill;
|
||||
use warpui::elements::{Align, Container, Empty, Flex, MouseStateHandle, ParentElement};
|
||||
use warpui::fonts::Weight;
|
||||
use warpui::keymap::FixedBinding;
|
||||
use warpui::modals::{AlertDialogWithCallbacks, AppModalCallback};
|
||||
use warpui::platform::Cursor;
|
||||
use warpui::ui_components::button::ButtonVariant;
|
||||
use warpui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
|
||||
use warpui::ui_components::text::Span;
|
||||
use warpui::{
|
||||
AppContext, Element, Entity, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext,
|
||||
};
|
||||
|
||||
use crate::appearance::Appearance;
|
||||
use crate::terminal::general_settings::{GeneralSettings, GeneralSettingsChangedEvent};
|
||||
use crate::ui_components::dialog::{dialog_styles, Dialog};
|
||||
use galaxy_core::ui::theme::Fill;
|
||||
use galaxyui::elements::{Align, Container, Empty, Flex, ParentElement};
|
||||
use galaxyui::keymap::FixedBinding;
|
||||
use galaxyui::modals::{AlertDialogWithCallbacks, AppModalCallback};
|
||||
use galaxyui::ui_components::components::{Coords, UiComponent};
|
||||
use galaxyui::{
|
||||
elements::MouseStateHandle,
|
||||
fonts::Weight,
|
||||
platform::Cursor,
|
||||
ui_components::{button::ButtonVariant, components::UiComponentStyles, text::Span},
|
||||
Element, Entity, TypedActionView, View,
|
||||
};
|
||||
use galaxyui::{AppContext, ModelHandle, SingletonEntity, ViewContext};
|
||||
use settings::Setting as _;
|
||||
|
||||
pub(super) fn init(app: &mut AppContext) {
|
||||
use galaxyui::keymap::macros::*;
|
||||
|
||||
@@ -1,15 +1,30 @@
|
||||
use std::future::Future;
|
||||
|
||||
use ai::api_keys::ApiKeyManager;
|
||||
use settings::Setting as _;
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxy_core::send_telemetry_from_ctx;
|
||||
use warp_util::sync::Condition;
|
||||
use warpui::{AppContext, Entity, ModelContext, SingletonEntity, WindowId};
|
||||
|
||||
use super::hoa_onboarding;
|
||||
use super::view::free_ai_removal_modal::{
|
||||
FreeAiRemovalModalTelemetryEvent, FreeAiRemovalModalVariant,
|
||||
};
|
||||
use crate::ai::blocklist::agent_view::toolbar_item::AgentToolbarItemKind;
|
||||
use crate::ai::{AIRequestUsageModel, AIRequestUsageModelEvent};
|
||||
use crate::auth::auth_manager::AuthManagerEvent;
|
||||
use crate::auth::AuthManager;
|
||||
use crate::auth::{AuthManager, AuthStateProvider};
|
||||
use crate::channel::{Channel, ChannelState};
|
||||
use crate::root_view::has_completed_local_onboarding;
|
||||
use crate::settings::cloud_preferences_syncer::{
|
||||
CloudPreferencesSyncer, CloudPreferencesSyncerEvent,
|
||||
};
|
||||
use crate::settings::{AISettings, CodeSettings};
|
||||
use crate::terminal::general_settings::GeneralSettings;
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxyui::{Entity, ModelContext, SingletonEntity, WindowId};
|
||||
use settings::Setting as _;
|
||||
use crate::terminal::session_settings::{AgentToolbarChipSelection, SessionSettings};
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
use crate::workspaces::workspace::CustomerType;
|
||||
|
||||
/// A generic model for managing one-time modals that should be shown to users only once.
|
||||
///
|
||||
@@ -23,8 +38,26 @@ pub struct OneTimeModalModel {
|
||||
is_oz_launch_modal_open: bool,
|
||||
/// Whether the OpenWarp launch modal is currently being shown.
|
||||
is_openwarp_launch_modal_open: bool,
|
||||
is_orchestration_launch_modal_open: bool,
|
||||
/// Whether the auto-handoff sleep discoverability modal is currently being shown.
|
||||
is_auto_handoff_sleep_modal_open: bool,
|
||||
/// Set while the auto-handoff sleep modal is closed and reset while it is
|
||||
/// open, so async work (e.g. auto-resume-after-error) can wait for the
|
||||
/// modal to close. Mirrors the `Condition` pattern used by
|
||||
/// `NetworkStatus::pending_reconnect`.
|
||||
auto_handoff_sleep_modal_closed: Condition,
|
||||
/// Whether the free-AI-removal notice modal is currently being shown.
|
||||
is_free_ai_removal_modal_open: bool,
|
||||
/// Whether the HOA onboarding flow is currently being shown.
|
||||
is_hoa_onboarding_open: bool,
|
||||
/// Whether the initial one-time modal checks have run. The seen markers are
|
||||
/// cloud-synced settings, so event-driven re-checks must wait for the initial
|
||||
/// cloud preferences load to avoid acting on stale values.
|
||||
has_completed_initial_modal_checks: bool,
|
||||
/// Whether `UserWorkspaces` has emitted `TeamsChanged`, meaning workspace billing
|
||||
/// data reflects more than the local cache and "no workspace" can be trusted to
|
||||
/// mean a solo (Free) user rather than not-yet-loaded data.
|
||||
has_fetched_workspaces: 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>,
|
||||
@@ -35,17 +68,32 @@ impl OneTimeModalModel {
|
||||
// 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| {
|
||||
|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);
|
||||
match event {
|
||||
UserWorkspacesEvent::SunsettedToBuildDataUpdated => {
|
||||
// When sunsetted_to_build_ts is updated, check if we should show the modal
|
||||
me.check_and_trigger_build_plan_migration_modal(ctx);
|
||||
}
|
||||
UserWorkspacesEvent::TeamsChanged => {
|
||||
me.has_fetched_workspaces = true;
|
||||
me.maybe_recheck_free_ai_removal_modal(ctx);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// The base-credit allowance that gates the free-AI-removal notice loads
|
||||
// asynchronously, so re-evaluate the notice whenever request usage updates.
|
||||
ctx.subscribe_to_model(&AIRequestUsageModel::handle(ctx), |me, _, event, ctx| {
|
||||
if let AIRequestUsageModelEvent::RequestUsageUpdated = event {
|
||||
me.maybe_recheck_free_ai_removal_modal(ctx);
|
||||
}
|
||||
});
|
||||
|
||||
// Subscribe to auth manager events to automatically trigger modal when user becomes onboarded
|
||||
ctx.subscribe_to_model(&AuthManager::handle(ctx), |_, event, ctx| {
|
||||
ctx.subscribe_to_model(&AuthManager::handle(ctx), |_, _, event, ctx| {
|
||||
let AuthManagerEvent::AuthComplete = event else {
|
||||
return;
|
||||
};
|
||||
@@ -57,10 +105,12 @@ impl OneTimeModalModel {
|
||||
// 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| {
|
||||
move |me, _, event, ctx| {
|
||||
if let CloudPreferencesSyncerEvent::InitialLoadCompleted = event {
|
||||
ctx.unsubscribe_from_model(&CloudPreferencesSyncer::handle(ctx));
|
||||
me.has_completed_initial_modal_checks = true;
|
||||
me.check_and_trigger_all_modals(ctx);
|
||||
maybe_ensure_handoff_chip_in_toolbar(ctx);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -72,7 +122,16 @@ impl OneTimeModalModel {
|
||||
{
|
||||
log::warn!("Failed to mark Oz launch modal as dismissed: {e}");
|
||||
}
|
||||
if let Err(e) = settings
|
||||
.did_check_to_trigger_orchestration_launch_modal
|
||||
.set_value(true, ctx)
|
||||
{
|
||||
log::warn!("Failed to mark orchestration launch modal as dismissed: {e}");
|
||||
}
|
||||
});
|
||||
// Accounts created after the removal of free AI go through the new
|
||||
// onboarding and are treated as already-noticed (no modal).
|
||||
mark_free_ai_removal_notice_seen(ctx);
|
||||
GeneralSettings::handle(ctx).update(ctx, |settings, ctx| {
|
||||
if let Err(e) = settings
|
||||
.did_check_to_trigger_openwarp_launch_modal
|
||||
@@ -84,11 +143,22 @@ impl OneTimeModalModel {
|
||||
}
|
||||
});
|
||||
|
||||
// The auto-handoff sleep modal starts closed, so its close condition
|
||||
// starts satisfied.
|
||||
let auto_handoff_sleep_modal_closed = Condition::new();
|
||||
auto_handoff_sleep_modal_closed.set();
|
||||
|
||||
Self {
|
||||
is_build_plan_migration_modal_open: false,
|
||||
is_oz_launch_modal_open: false,
|
||||
is_openwarp_launch_modal_open: false,
|
||||
is_orchestration_launch_modal_open: false,
|
||||
is_auto_handoff_sleep_modal_open: false,
|
||||
auto_handoff_sleep_modal_closed,
|
||||
is_free_ai_removal_modal_open: false,
|
||||
is_hoa_onboarding_open: false,
|
||||
has_completed_initial_modal_checks: false,
|
||||
has_fetched_workspaces: false,
|
||||
target_window_id: None,
|
||||
}
|
||||
}
|
||||
@@ -116,6 +186,80 @@ impl OneTimeModalModel {
|
||||
self.set_openwarp_launch_modal_open(false, ctx);
|
||||
}
|
||||
|
||||
pub fn is_orchestration_launch_modal_open(&self) -> bool {
|
||||
self.is_orchestration_launch_modal_open && self.target_window_id.is_some()
|
||||
}
|
||||
|
||||
pub fn mark_orchestration_launch_modal_dismissed(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
self.set_orchestration_launch_modal_open(false, ctx);
|
||||
}
|
||||
|
||||
/// Returns whether the auto-handoff sleep discoverability modal is currently open.
|
||||
pub fn is_auto_handoff_sleep_modal_open(&self) -> bool {
|
||||
self.is_auto_handoff_sleep_modal_open && self.target_window_id.is_some()
|
||||
}
|
||||
|
||||
pub fn mark_auto_handoff_sleep_modal_dismissed(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
self.set_auto_handoff_sleep_modal_open(false, ctx);
|
||||
}
|
||||
|
||||
/// Triggers the auto-handoff sleep discoverability modal. Unlike the launch
|
||||
/// modals, this is not called on startup: the auto-handoff controller calls
|
||||
/// it on wake when a sleep interrupted an in-progress local agent run that
|
||||
/// would have been handed off had `auto_handoff_on_sleep_enabled` been on.
|
||||
/// Shows at most once per user (tracked by a synced private setting).
|
||||
/// Returns true when the modal was opened.
|
||||
pub fn check_and_trigger_auto_handoff_sleep_modal(
|
||||
&mut self,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> bool {
|
||||
let ai_settings = AISettings::as_ref(ctx);
|
||||
if *ai_settings.did_show_auto_handoff_sleep_modal {
|
||||
return false;
|
||||
}
|
||||
|
||||
AISettings::handle(ctx).update(ctx, |settings, ctx| {
|
||||
if let Err(e) = settings
|
||||
.did_show_auto_handoff_sleep_modal
|
||||
.set_value(true, ctx)
|
||||
{
|
||||
log::warn!("Failed to mark auto-handoff sleep modal as shown: {e}");
|
||||
}
|
||||
});
|
||||
|
||||
let should_show = !matches!(ChannelState::channel(), Channel::Integration);
|
||||
self.set_auto_handoff_sleep_modal_open(should_show, ctx);
|
||||
should_show
|
||||
}
|
||||
|
||||
/// Sets whether the auto-handoff sleep modal is open. `pub(crate)` so the
|
||||
/// debug palette action can force the modal open.
|
||||
pub(crate) fn set_auto_handoff_sleep_modal_open(
|
||||
&mut self,
|
||||
is_open: bool,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> bool {
|
||||
if self.is_auto_handoff_sleep_modal_open != is_open {
|
||||
self.is_auto_handoff_sleep_modal_open = is_open;
|
||||
if is_open {
|
||||
self.auto_handoff_sleep_modal_closed.reset();
|
||||
} else {
|
||||
self.auto_handoff_sleep_modal_closed.set();
|
||||
}
|
||||
ctx.emit(OneTimeModalEvent::VisibilityChanged { is_open });
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Returns a future that resolves immediately if the auto-handoff sleep
|
||||
/// modal is closed, or when it next closes if currently open. The future
|
||||
/// reads live modal state at poll time, so it can be created ahead of the
|
||||
/// modal opening.
|
||||
pub fn wait_until_auto_handoff_sleep_modal_closed(&self) -> impl Future<Output = ()> {
|
||||
self.auto_handoff_sleep_modal_closed.wait()
|
||||
}
|
||||
|
||||
/// 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()
|
||||
@@ -129,7 +273,10 @@ impl OneTimeModalModel {
|
||||
pub fn is_any_modal_open(&self) -> bool {
|
||||
(self.is_oz_launch_modal_open
|
||||
|| self.is_openwarp_launch_modal_open
|
||||
|| self.is_orchestration_launch_modal_open
|
||||
|| self.is_auto_handoff_sleep_modal_open
|
||||
|| self.is_build_plan_migration_modal_open
|
||||
|| self.is_free_ai_removal_modal_open
|
||||
|| self.is_hoa_onboarding_open)
|
||||
&& self.target_window_id.is_some()
|
||||
}
|
||||
@@ -144,6 +291,11 @@ impl OneTimeModalModel {
|
||||
self.set_openwarp_launch_modal_open(true, ctx);
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
pub fn force_open_orchestration_launch_modal(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
self.set_orchestration_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);
|
||||
@@ -176,6 +328,19 @@ impl OneTimeModalModel {
|
||||
false
|
||||
}
|
||||
|
||||
fn set_orchestration_launch_modal_open(
|
||||
&mut self,
|
||||
is_open: bool,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> bool {
|
||||
if self.is_orchestration_launch_modal_open != is_open {
|
||||
self.is_orchestration_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") {
|
||||
@@ -202,6 +367,14 @@ impl OneTimeModalModel {
|
||||
return;
|
||||
}
|
||||
|
||||
if self.check_and_trigger_orchestration_launch_modal(ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
if self.check_and_trigger_free_ai_removal_modal(ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
if self.check_and_trigger_hoa_onboarding(ctx) {
|
||||
return;
|
||||
}
|
||||
@@ -209,6 +382,108 @@ impl OneTimeModalModel {
|
||||
self.check_and_trigger_build_plan_migration_modal(ctx);
|
||||
}
|
||||
|
||||
/// Returns whether the free-AI-removal notice modal is currently open.
|
||||
pub fn is_free_ai_removal_modal_open(&self) -> bool {
|
||||
self.is_free_ai_removal_modal_open && self.target_window_id.is_some()
|
||||
}
|
||||
|
||||
pub fn mark_free_ai_removal_modal_dismissed(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
self.set_free_ai_removal_modal_open(false, ctx);
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
pub fn force_open_free_ai_removal_modal(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
self.set_free_ai_removal_modal_open(true, ctx);
|
||||
}
|
||||
|
||||
fn set_free_ai_removal_modal_open(
|
||||
&mut self,
|
||||
is_open: bool,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> bool {
|
||||
if self.is_free_ai_removal_modal_open != is_open {
|
||||
self.is_free_ai_removal_modal_open = is_open;
|
||||
ctx.emit(OneTimeModalEvent::VisibilityChanged { is_open });
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Re-evaluates the free-AI-removal notice outside the initial startup check, e.g.
|
||||
/// when workspace billing data arrives after startup.
|
||||
fn maybe_recheck_free_ai_removal_modal(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
if !self.has_completed_initial_modal_checks || self.is_any_modal_open() {
|
||||
return;
|
||||
}
|
||||
self.check_and_trigger_free_ai_removal_modal(ctx);
|
||||
}
|
||||
|
||||
fn check_and_trigger_free_ai_removal_modal(&mut self, ctx: &mut ModelContext<Self>) -> bool {
|
||||
// Gated on the OpenWarpNewSettingsModes rollout flag (the server experiment
|
||||
// that previously gated this was removed in C1).
|
||||
if !FeatureFlag::OpenWarpNewSettingsModes.is_enabled() {
|
||||
return false;
|
||||
}
|
||||
|
||||
if *AISettings::as_ref(ctx).did_check_to_trigger_free_ai_removal_modal {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Anonymous users have no BYOK or upgrade path; leave them unmarked so the
|
||||
// decision is made after they sign in.
|
||||
if AuthStateProvider::as_ref(ctx)
|
||||
.get()
|
||||
.is_anonymous_or_logged_out()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
let customer_type = UserWorkspaces::as_ref(ctx)
|
||||
.current_workspace()
|
||||
.map(|workspace| workspace.billing_metadata.customer_type);
|
||||
let is_warp_ai_enabled = *AISettings::as_ref(ctx).is_any_ai_enabled;
|
||||
let has_byok_or_byoe = ApiKeyManager::as_ref(ctx).has_any_key();
|
||||
let completed_new_onboarding = has_completed_local_onboarding(ctx);
|
||||
let has_zero_base_credits = AIRequestUsageModel::as_ref(ctx).request_limit() == 0;
|
||||
|
||||
let decision = free_ai_removal_modal_decision(
|
||||
customer_type,
|
||||
is_warp_ai_enabled,
|
||||
has_byok_or_byoe,
|
||||
completed_new_onboarding,
|
||||
has_zero_base_credits,
|
||||
self.has_fetched_workspaces,
|
||||
);
|
||||
if decision == FreeAiRemovalModalDecision::Defer {
|
||||
return false;
|
||||
}
|
||||
|
||||
AISettings::handle(ctx).update(ctx, |settings, ctx| {
|
||||
if let Err(e) = settings
|
||||
.did_check_to_trigger_free_ai_removal_modal
|
||||
.set_value(true, ctx)
|
||||
{
|
||||
log::warn!("Failed to mark free AI removal modal as seen: {e}");
|
||||
}
|
||||
});
|
||||
|
||||
if decision == FreeAiRemovalModalDecision::MarkSeenSilently {
|
||||
return false;
|
||||
}
|
||||
|
||||
let should_show = !matches!(ChannelState::channel(), Channel::Integration);
|
||||
if should_show {
|
||||
send_telemetry_from_ctx!(
|
||||
FreeAiRemovalModalTelemetryEvent::Shown {
|
||||
variant: FreeAiRemovalModalVariant::Notice,
|
||||
},
|
||||
ctx
|
||||
);
|
||||
}
|
||||
self.set_free_ai_removal_modal_open(should_show, ctx);
|
||||
should_show
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -295,6 +570,33 @@ impl OneTimeModalModel {
|
||||
should_show_openwarp_modal
|
||||
}
|
||||
|
||||
fn check_and_trigger_orchestration_launch_modal(
|
||||
&mut self,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> bool {
|
||||
if !FeatureFlag::OrchestrationLaunchModal.is_enabled() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let ai_settings = AISettings::as_ref(ctx);
|
||||
if *ai_settings.did_check_to_trigger_orchestration_launch_modal {
|
||||
return false;
|
||||
}
|
||||
|
||||
AISettings::handle(ctx).update(ctx, |settings, ctx| {
|
||||
if let Err(e) = settings
|
||||
.did_check_to_trigger_orchestration_launch_modal
|
||||
.set_value(true, ctx)
|
||||
{
|
||||
log::warn!("Failed to mark orchestration launch modal as dismissed: {e}");
|
||||
}
|
||||
});
|
||||
|
||||
let should_show = !matches!(ChannelState::channel(), Channel::Integration);
|
||||
self.set_orchestration_launch_modal_open(should_show, ctx);
|
||||
should_show
|
||||
}
|
||||
|
||||
pub fn is_build_plan_migration_modal_open(&self) -> bool {
|
||||
self.is_build_plan_migration_modal_open && self.target_window_id.is_some()
|
||||
}
|
||||
@@ -325,7 +627,6 @@ impl OneTimeModalModel {
|
||||
&mut self,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> bool {
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
|
||||
// Check if already dismissed
|
||||
let general_settings = GeneralSettings::as_ref(ctx);
|
||||
@@ -374,6 +675,113 @@ impl OneTimeModalModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// One-time migration: if the user has a custom agent toolbar layout that
|
||||
/// predates the handoff-to-cloud chip, append the chip so they get the
|
||||
/// new feature without losing their customization.
|
||||
///
|
||||
/// Users on `Default` already see the chip via `AgentToolbarItemKind::default_right()`.
|
||||
fn maybe_ensure_handoff_chip_in_toolbar(ctx: &mut ModelContext<OneTimeModalModel>) {
|
||||
if !FeatureFlag::OzHandoff.is_enabled()
|
||||
|| !FeatureFlag::HandoffLocalCloud.is_enabled()
|
||||
|| !cfg!(all(feature = "local_fs", not(target_family = "wasm")))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let session_settings = SessionSettings::as_ref(ctx);
|
||||
if *session_settings.did_add_handoff_chip_to_toolbar {
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark as done so future app starts skip this path.
|
||||
SessionSettings::handle(ctx).update(ctx, |settings, ctx| {
|
||||
if let Err(e) = settings
|
||||
.did_add_handoff_chip_to_toolbar
|
||||
.set_value(true, ctx)
|
||||
{
|
||||
log::warn!("Failed to mark handoff chip toolbar migration as done: {e}");
|
||||
}
|
||||
});
|
||||
|
||||
// `Default` already includes the chip — nothing to do.
|
||||
let selection = SessionSettings::as_ref(ctx)
|
||||
.agent_footer_chip_selection
|
||||
.clone();
|
||||
let AgentToolbarChipSelection::Custom { mut left, right } = selection else {
|
||||
return;
|
||||
};
|
||||
|
||||
let handoff = AgentToolbarItemKind::HandoffToCloud;
|
||||
if left.contains(&handoff) || right.contains(&handoff) {
|
||||
return;
|
||||
}
|
||||
|
||||
left.push(handoff);
|
||||
SessionSettings::handle(ctx).update(ctx, |settings, ctx| {
|
||||
if let Err(e) = settings
|
||||
.agent_footer_chip_selection
|
||||
.set_value(AgentToolbarChipSelection::Custom { left, right }, ctx)
|
||||
{
|
||||
log::warn!("Failed to add handoff chip to toolbar: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Marks the free-AI-removal notice as seen without showing it.
|
||||
pub fn mark_free_ai_removal_notice_seen(app: &mut AppContext) {
|
||||
AISettings::handle(app).update(app, |settings, ctx| {
|
||||
if let Err(e) = settings
|
||||
.did_check_to_trigger_free_ai_removal_modal
|
||||
.set_value(true, ctx)
|
||||
{
|
||||
log::warn!("Failed to mark free AI removal notice as seen: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The outcome of evaluating the free-AI-removal notice conditions.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum FreeAiRemovalModalDecision {
|
||||
/// Show the modal and write the seen marker.
|
||||
Show,
|
||||
/// Write the seen marker without showing the modal.
|
||||
MarkSeenSilently,
|
||||
/// Not enough data to decide; re-evaluate on the next billing/experiments update.
|
||||
Defer,
|
||||
}
|
||||
|
||||
fn free_ai_removal_modal_decision(
|
||||
customer_type: Option<CustomerType>,
|
||||
is_warp_ai_enabled: bool,
|
||||
has_byok_or_byoe: bool,
|
||||
completed_new_onboarding: bool,
|
||||
has_zero_base_credits: bool,
|
||||
workspaces_fetched: bool,
|
||||
) -> FreeAiRemovalModalDecision {
|
||||
if !is_warp_ai_enabled || has_byok_or_byoe || completed_new_onboarding {
|
||||
return FreeAiRemovalModalDecision::MarkSeenSilently;
|
||||
}
|
||||
// Restrict to a Free (or confirmed solo) user; anyone else is paid (silently
|
||||
// marked) or not-yet-known (deferred).
|
||||
match customer_type {
|
||||
Some(CustomerType::Free) => {}
|
||||
// A missing workspace usually means billing data hasn't loaded yet; only treat
|
||||
// it as a solo Free user once a server fetch has confirmed there is none, so a
|
||||
// paid user's modal decision never runs against absent data.
|
||||
None if workspaces_fetched => {}
|
||||
None | Some(CustomerType::Unknown) => return FreeAiRemovalModalDecision::Defer,
|
||||
Some(_) => return FreeAiRemovalModalDecision::MarkSeenSilently,
|
||||
}
|
||||
// Some ICPs still receive base AI credits on the Free plan; don't spook them with
|
||||
// the notice. Only show once the base allowance is gone, and defer (rather than
|
||||
// mark seen) otherwise so it re-evaluates if the allowance later drops to zero.
|
||||
if has_zero_base_credits {
|
||||
FreeAiRemovalModalDecision::Show
|
||||
} else {
|
||||
FreeAiRemovalModalDecision::Defer
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum OneTimeModalEvent {
|
||||
VisibilityChanged { is_open: bool },
|
||||
@@ -384,3 +792,7 @@ impl Entity for OneTimeModalModel {
|
||||
}
|
||||
|
||||
impl SingletonEntity for OneTimeModalModel {}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "one_time_modal_model_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
use futures::FutureExt;
|
||||
use warpui::{App, SingletonEntity};
|
||||
|
||||
use super::{free_ai_removal_modal_decision, FreeAiRemovalModalDecision, OneTimeModalModel};
|
||||
use crate::test_util::terminal::{add_window_with_terminal, initialize_app_for_terminal_view};
|
||||
use crate::workspaces::workspace::CustomerType;
|
||||
|
||||
#[test]
|
||||
fn wait_until_auto_handoff_sleep_modal_closed_tracks_modal_state() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app_for_terminal_view(&mut app);
|
||||
let terminal = add_window_with_terminal(&mut app, None);
|
||||
|
||||
terminal.update(&mut app, |_, ctx| {
|
||||
OneTimeModalModel::handle(ctx).update(ctx, |model, ctx| {
|
||||
// Resolves immediately while the modal is closed.
|
||||
assert!(model
|
||||
.wait_until_auto_handoff_sleep_modal_closed()
|
||||
.now_or_never()
|
||||
.is_some());
|
||||
|
||||
// The auto-resume path creates its wait future before the
|
||||
// modal opens (e.g. while offline during sleep); it must
|
||||
// still observe the modal that opens later.
|
||||
let pending_probe = model.wait_until_auto_handoff_sleep_modal_closed();
|
||||
let resolving_waiter = model.wait_until_auto_handoff_sleep_modal_closed();
|
||||
|
||||
model.set_auto_handoff_sleep_modal_open(true, ctx);
|
||||
|
||||
// Pending while the modal is open, because the future reads
|
||||
// live modal state at poll time.
|
||||
assert!(pending_probe.now_or_never().is_none());
|
||||
|
||||
model.mark_auto_handoff_sleep_modal_dismissed(ctx);
|
||||
|
||||
// An existing waiter resolves once the modal closes.
|
||||
assert!(resolving_waiter.now_or_never().is_some());
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_free_ai_removal_modal_decision_matrix() {
|
||||
struct Case {
|
||||
name: &'static str,
|
||||
customer_type: Option<CustomerType>,
|
||||
is_warp_ai_enabled: bool,
|
||||
has_byok_or_byoe: bool,
|
||||
completed_new_onboarding: bool,
|
||||
has_zero_base_credits: bool,
|
||||
workspaces_fetched: bool,
|
||||
expected: FreeAiRemovalModalDecision,
|
||||
}
|
||||
|
||||
let cases = [
|
||||
Case {
|
||||
name: "free user with AI enabled and no base credits sees the modal",
|
||||
customer_type: Some(CustomerType::Free),
|
||||
is_warp_ai_enabled: true,
|
||||
has_byok_or_byoe: false,
|
||||
completed_new_onboarding: false,
|
||||
has_zero_base_credits: true,
|
||||
workspaces_fetched: false,
|
||||
expected: FreeAiRemovalModalDecision::Show,
|
||||
},
|
||||
Case {
|
||||
name: "free user who still receives base credits defers (ICP)",
|
||||
customer_type: Some(CustomerType::Free),
|
||||
is_warp_ai_enabled: true,
|
||||
has_byok_or_byoe: false,
|
||||
completed_new_onboarding: false,
|
||||
has_zero_base_credits: false,
|
||||
workspaces_fetched: false,
|
||||
expected: FreeAiRemovalModalDecision::Defer,
|
||||
},
|
||||
Case {
|
||||
name: "free user with AI disabled is marked seen silently",
|
||||
customer_type: Some(CustomerType::Free),
|
||||
is_warp_ai_enabled: false,
|
||||
has_byok_or_byoe: false,
|
||||
completed_new_onboarding: false,
|
||||
has_zero_base_credits: true,
|
||||
workspaces_fetched: false,
|
||||
expected: FreeAiRemovalModalDecision::MarkSeenSilently,
|
||||
},
|
||||
Case {
|
||||
name: "free user with a BYO key or endpoint is marked seen silently",
|
||||
customer_type: Some(CustomerType::Free),
|
||||
is_warp_ai_enabled: true,
|
||||
has_byok_or_byoe: true,
|
||||
completed_new_onboarding: false,
|
||||
has_zero_base_credits: true,
|
||||
workspaces_fetched: true,
|
||||
expected: FreeAiRemovalModalDecision::MarkSeenSilently,
|
||||
},
|
||||
Case {
|
||||
name: "free user who completed the new onboarding is marked seen silently",
|
||||
customer_type: Some(CustomerType::Free),
|
||||
is_warp_ai_enabled: true,
|
||||
has_byok_or_byoe: false,
|
||||
completed_new_onboarding: true,
|
||||
has_zero_base_credits: true,
|
||||
workspaces_fetched: true,
|
||||
expected: FreeAiRemovalModalDecision::MarkSeenSilently,
|
||||
},
|
||||
Case {
|
||||
name: "paid (Build) user is marked seen silently",
|
||||
customer_type: Some(CustomerType::Build),
|
||||
is_warp_ai_enabled: true,
|
||||
has_byok_or_byoe: false,
|
||||
completed_new_onboarding: false,
|
||||
has_zero_base_credits: true,
|
||||
workspaces_fetched: false,
|
||||
expected: FreeAiRemovalModalDecision::MarkSeenSilently,
|
||||
},
|
||||
Case {
|
||||
name: "paid (BuildMax) user is marked seen silently",
|
||||
customer_type: Some(CustomerType::BuildMax),
|
||||
is_warp_ai_enabled: true,
|
||||
has_byok_or_byoe: false,
|
||||
completed_new_onboarding: false,
|
||||
has_zero_base_credits: true,
|
||||
workspaces_fetched: true,
|
||||
expected: FreeAiRemovalModalDecision::MarkSeenSilently,
|
||||
},
|
||||
Case {
|
||||
name: "enterprise user is marked seen silently",
|
||||
customer_type: Some(CustomerType::Enterprise),
|
||||
is_warp_ai_enabled: true,
|
||||
has_byok_or_byoe: false,
|
||||
completed_new_onboarding: false,
|
||||
has_zero_base_credits: true,
|
||||
workspaces_fetched: true,
|
||||
expected: FreeAiRemovalModalDecision::MarkSeenSilently,
|
||||
},
|
||||
Case {
|
||||
name: "legacy paid (Prosumer) user is marked seen silently",
|
||||
customer_type: Some(CustomerType::Prosumer),
|
||||
is_warp_ai_enabled: true,
|
||||
has_byok_or_byoe: false,
|
||||
completed_new_onboarding: false,
|
||||
has_zero_base_credits: true,
|
||||
workspaces_fetched: true,
|
||||
expected: FreeAiRemovalModalDecision::MarkSeenSilently,
|
||||
},
|
||||
Case {
|
||||
name: "unknown customer type defers until billing data resolves",
|
||||
customer_type: Some(CustomerType::Unknown),
|
||||
is_warp_ai_enabled: true,
|
||||
has_byok_or_byoe: false,
|
||||
completed_new_onboarding: false,
|
||||
has_zero_base_credits: true,
|
||||
workspaces_fetched: true,
|
||||
expected: FreeAiRemovalModalDecision::Defer,
|
||||
},
|
||||
Case {
|
||||
name: "missing workspace defers before the first server fetch",
|
||||
customer_type: None,
|
||||
is_warp_ai_enabled: true,
|
||||
has_byok_or_byoe: false,
|
||||
completed_new_onboarding: false,
|
||||
has_zero_base_credits: true,
|
||||
workspaces_fetched: false,
|
||||
expected: FreeAiRemovalModalDecision::Defer,
|
||||
},
|
||||
Case {
|
||||
name: "missing workspace after a server fetch with no base credits is a solo free user",
|
||||
customer_type: None,
|
||||
is_warp_ai_enabled: true,
|
||||
has_byok_or_byoe: false,
|
||||
completed_new_onboarding: false,
|
||||
has_zero_base_credits: true,
|
||||
workspaces_fetched: true,
|
||||
expected: FreeAiRemovalModalDecision::Show,
|
||||
},
|
||||
Case {
|
||||
name: "solo user who still receives base credits defers (ICP)",
|
||||
customer_type: None,
|
||||
is_warp_ai_enabled: true,
|
||||
has_byok_or_byoe: false,
|
||||
completed_new_onboarding: false,
|
||||
has_zero_base_credits: false,
|
||||
workspaces_fetched: true,
|
||||
expected: FreeAiRemovalModalDecision::Defer,
|
||||
},
|
||||
Case {
|
||||
name: "missing workspace with AI disabled is marked seen silently",
|
||||
customer_type: None,
|
||||
is_warp_ai_enabled: false,
|
||||
has_byok_or_byoe: false,
|
||||
completed_new_onboarding: false,
|
||||
has_zero_base_credits: true,
|
||||
workspaces_fetched: true,
|
||||
expected: FreeAiRemovalModalDecision::MarkSeenSilently,
|
||||
},
|
||||
];
|
||||
|
||||
for case in cases {
|
||||
assert_eq!(
|
||||
free_ai_removal_modal_decision(
|
||||
case.customer_type,
|
||||
case.is_warp_ai_enabled,
|
||||
case.has_byok_or_byoe,
|
||||
case.completed_new_onboarding,
|
||||
case.has_zero_base_credits,
|
||||
case.workspaces_fetched,
|
||||
),
|
||||
case.expected,
|
||||
"case failed: {}",
|
||||
case.name,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,25 @@
|
||||
use galaxy_core::ui::{color::coloru_with_opacity, theme::Fill};
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use galaxy_core::ui::color::coloru_with_opacity;
|
||||
use galaxy_core::ui::theme::Fill;
|
||||
use galaxyui::elements::{
|
||||
Align, ChildAnchor, ConstrainedBox, Container, CrossAxisAlignment, Flex, Hoverable,
|
||||
MouseStateHandle, OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, Stack,
|
||||
Text,
|
||||
};
|
||||
use galaxyui::fonts::Weight;
|
||||
use galaxyui::keymap::{FixedBinding, Keystroke};
|
||||
use galaxyui::platform::Cursor;
|
||||
use galaxyui::ui_components::button::ButtonVariant;
|
||||
use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
|
||||
use galaxyui::{
|
||||
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 pathfinder_geometry::vector::vec2f;
|
||||
|
||||
use crate::{
|
||||
ai::agent::{conversation::AIConversationId, AIAgentExchangeId},
|
||||
appearance::Appearance,
|
||||
ui_components::dialog::{dialog_styles, Dialog},
|
||||
ui_components::icons::Icon,
|
||||
};
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::agent::AIAgentExchangeId;
|
||||
use crate::appearance::Appearance;
|
||||
use crate::ui_components::dialog::{dialog_styles, Dialog};
|
||||
use crate::ui_components::icons::Icon;
|
||||
|
||||
pub fn init(app: &mut AppContext) {
|
||||
use galaxyui::keymap::macros::*;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use galaxyui::{keymap::EditableBinding, AppContext, Entity, EntityId, SingletonEntity, WindowId};
|
||||
|
||||
use crate::util::bindings::{BindingGroup, CustomAction};
|
||||
use galaxyui::keymap::EditableBinding;
|
||||
use galaxyui::{AppContext, Entity, EntityId, SingletonEntity, WindowId};
|
||||
|
||||
use super::WorkspaceAction;
|
||||
use crate::util::bindings::{BindingGroup, CustomAction};
|
||||
|
||||
pub fn init(app: &mut AppContext) {
|
||||
use galaxyui::keymap::macros::*;
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
//! Tab group data model. Gated at runtime by `FeatureFlag::GroupedTabs`.
|
||||
|
||||
use uuid::Uuid;
|
||||
use warpui::elements::DraggableState;
|
||||
|
||||
use crate::tab::SelectedTabColor;
|
||||
|
||||
/// Stable identity for a tab group.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
pub struct TabGroupId(pub Uuid);
|
||||
|
||||
impl TabGroupId {
|
||||
pub fn new() -> Self {
|
||||
Self(Uuid::new_v4())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TabGroupId {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// A named group of tabs in the vertical tabs panel.
|
||||
/// Member tabs reference their group via `TabData::group_id`.
|
||||
#[derive(Clone)]
|
||||
pub struct TabGroup {
|
||||
pub id: TabGroupId,
|
||||
pub name: Option<String>,
|
||||
pub color: SelectedTabColor,
|
||||
pub collapsed: bool,
|
||||
pub draggable_state: DraggableState,
|
||||
/// True when this whole group is pinned to the front of the tab list.
|
||||
pub pinned: bool,
|
||||
}
|
||||
|
||||
impl TabGroup {
|
||||
/// Creates a new, untitled, expanded tab group with a fresh id.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
id: TabGroupId::new(),
|
||||
name: None,
|
||||
color: SelectedTabColor::default(),
|
||||
collapsed: false,
|
||||
draggable_state: Default::default(),
|
||||
pinned: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TabGroup {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use settings::macros::define_settings_group;
|
||||
use settings::{RespectUserSyncSetting, SupportedPlatforms, SyncToCloud};
|
||||
use galaxy_core::ui::theme::AnsiColorIdentifier;
|
||||
use settings::{macros::define_settings_group, SupportedPlatforms, SyncToCloud};
|
||||
|
||||
#[derive(
|
||||
Default,
|
||||
@@ -200,8 +201,7 @@ settings::macros::implement_setting_for_enum!(
|
||||
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());
|
||||
pub fn color_for_directory(&self, canonical_dir: &Path) -> Option<DirectoryTabColor> {
|
||||
self.0
|
||||
.iter()
|
||||
.filter_map(|(configured_path, color)| {
|
||||
@@ -220,13 +220,19 @@ impl DirectoryTabColors {
|
||||
/// 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);
|
||||
map.insert(canonical_directory_key(path), color);
|
||||
Self(map)
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonicalizes `path` into the string key used in [`DirectoryTabColors`].
|
||||
pub fn canonical_directory_key(path: &Path) -> String {
|
||||
dunce::canonicalize(path)
|
||||
.unwrap_or_else(|_| path.to_path_buf())
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Clone,
|
||||
Debug,
|
||||
@@ -261,12 +267,15 @@ impl HeaderToolbarChipSelection {
|
||||
}
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn contains_item(&self, item: &super::header_toolbar_item::HeaderToolbarItemKind) -> bool {
|
||||
self.left_items().contains(item) || self.right_items().contains(item)
|
||||
}
|
||||
}
|
||||
|
||||
settings::macros::implement_setting_for_enum!(
|
||||
@@ -482,6 +491,24 @@ define_settings_group!(TabSettings, settings: [
|
||||
toml_path: "appearance.vertical_tabs.enabled",
|
||||
description: "Whether to display tabs vertically instead of horizontally.",
|
||||
},
|
||||
show_vertical_tab_panel_in_restored_windows: ShowVerticalTabPanelInRestoredWindows {
|
||||
type: bool,
|
||||
default: false,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "appearance.vertical_tabs.show_panel_in_restored_windows",
|
||||
description: "When restoring a window, open the vertical tabs panel even if it was closed when the session was saved.",
|
||||
},
|
||||
hide_title_bar_search_bar_in_vertical_tabs: HideTitleBarSearchBarInVerticalTabs {
|
||||
type: bool,
|
||||
default: false,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "appearance.vertical_tabs.hide_title_bar_search_bar",
|
||||
description: "When using the vertical tab layout, hide the search bar in the title bar. Search stays available via the command palette and keyboard shortcuts.",
|
||||
},
|
||||
use_latest_user_prompt_as_conversation_title_in_tab_names: UseLatestUserPromptAsConversationTitleInTabNames {
|
||||
type: bool,
|
||||
default: false,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use settings::Setting;
|
||||
|
||||
use super::*;
|
||||
use crate::test_util::settings::initialize_settings_for_tests;
|
||||
use galaxyui::{App, SingletonEntity};
|
||||
use settings::Setting;
|
||||
use crate::workspace::header_toolbar_item::HeaderToolbarItemKind;
|
||||
|
||||
#[test]
|
||||
fn use_latest_user_prompt_as_conversation_title_in_tab_names_defaults_to_false() {
|
||||
@@ -29,3 +30,99 @@ fn use_latest_user_prompt_as_conversation_title_in_tab_names_uses_vertical_tabs_
|
||||
"use_latest_prompt_as_title"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_vertical_tab_panel_in_restored_windows_defaults_to_false() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_settings_for_tests(&mut app);
|
||||
|
||||
TabSettings::handle(&app).read(&app, |settings, _ctx| {
|
||||
assert!(!*settings.show_vertical_tab_panel_in_restored_windows);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_vertical_tab_panel_in_restored_windows_uses_vertical_tabs_path() {
|
||||
assert_eq!(
|
||||
ShowVerticalTabPanelInRestoredWindows::toml_path(),
|
||||
Some("appearance.vertical_tabs.show_panel_in_restored_windows")
|
||||
);
|
||||
assert_eq!(
|
||||
ShowVerticalTabPanelInRestoredWindows::hierarchy(),
|
||||
Some("appearance.vertical_tabs")
|
||||
);
|
||||
assert_eq!(
|
||||
ShowVerticalTabPanelInRestoredWindows::toml_key(),
|
||||
"show_panel_in_restored_windows"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hide_title_bar_search_bar_in_vertical_tabs_defaults_to_false() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_settings_for_tests(&mut app);
|
||||
|
||||
TabSettings::handle(&app).read(&app, |settings, _ctx| {
|
||||
assert!(!*settings.hide_title_bar_search_bar_in_vertical_tabs);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hide_title_bar_search_bar_in_vertical_tabs_uses_vertical_tabs_path() {
|
||||
assert_eq!(
|
||||
HideTitleBarSearchBarInVerticalTabs::toml_path(),
|
||||
Some("appearance.vertical_tabs.hide_title_bar_search_bar")
|
||||
);
|
||||
assert_eq!(
|
||||
HideTitleBarSearchBarInVerticalTabs::hierarchy(),
|
||||
Some("appearance.vertical_tabs")
|
||||
);
|
||||
assert_eq!(
|
||||
HideTitleBarSearchBarInVerticalTabs::toml_key(),
|
||||
"hide_title_bar_search_bar"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_toolbar_chip_selection_default_contains_code_review() {
|
||||
let config = HeaderToolbarChipSelection::Default;
|
||||
assert!(config.contains_item(&HeaderToolbarItemKind::CodeReview));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_toolbar_chip_selection_custom_without_code_review_reports_absent() {
|
||||
let config = HeaderToolbarChipSelection::Custom {
|
||||
left: vec![
|
||||
HeaderToolbarItemKind::TabsPanel,
|
||||
HeaderToolbarItemKind::ToolsPanel,
|
||||
],
|
||||
right: vec![HeaderToolbarItemKind::NotificationsMailbox],
|
||||
};
|
||||
assert!(!config.contains_item(&HeaderToolbarItemKind::CodeReview));
|
||||
assert!(config.contains_item(&HeaderToolbarItemKind::TabsPanel));
|
||||
assert!(config.contains_item(&HeaderToolbarItemKind::ToolsPanel));
|
||||
assert!(config.contains_item(&HeaderToolbarItemKind::NotificationsMailbox));
|
||||
assert!(!config.contains_item(&HeaderToolbarItemKind::AgentManagement));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_toolbar_chip_selection_custom_with_code_review_on_left_reports_present() {
|
||||
let config = HeaderToolbarChipSelection::Custom {
|
||||
left: vec![HeaderToolbarItemKind::CodeReview],
|
||||
right: vec![],
|
||||
};
|
||||
assert!(config.contains_item(&HeaderToolbarItemKind::CodeReview));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_toolbar_chip_selection_custom_empty_reports_all_absent() {
|
||||
let config = HeaderToolbarChipSelection::Custom {
|
||||
left: vec![],
|
||||
right: vec![],
|
||||
};
|
||||
for item in HeaderToolbarItemKind::all_items() {
|
||||
assert!(!config.contains_item(&item));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
use galaxyui::{Entity, ModelContext, SingletonEntity, WindowId};
|
||||
|
||||
use crate::{
|
||||
view_components::{DismissibleToast, ToastType},
|
||||
workspace::WorkspaceAction,
|
||||
};
|
||||
use crate::view_components::{DismissibleToast, ToastType};
|
||||
use crate::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
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
use galaxyui::{
|
||||
elements::MouseStateHandle, AppContext, EntityId, SingletonEntity, ViewContext, ViewHandle,
|
||||
WindowId,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use galaxyui::elements::MouseStateHandle;
|
||||
use galaxyui::{AppContext, EntityId, SingletonEntity, ViewContext, ViewHandle, WindowId};
|
||||
|
||||
use super::OneTimeModalModel;
|
||||
use crate::appearance::Appearance;
|
||||
use crate::pane_group::PaneId;
|
||||
use crate::terminal::TerminalView;
|
||||
use crate::window_settings::WindowSettings;
|
||||
use crate::{
|
||||
appearance::Appearance, pane_group::PaneId, terminal::TerminalView, workspace::Workspace,
|
||||
};
|
||||
use crate::workspace::tab_group::TabGroupId;
|
||||
use crate::workspace::Workspace;
|
||||
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
/// What composes a pane (i.e. the pane group and the pane itself).
|
||||
@@ -122,6 +122,7 @@ pub struct WorkspaceState {
|
||||
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_prompt_suggestions_unavailable_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,
|
||||
@@ -130,6 +131,8 @@ pub struct WorkspaceState {
|
||||
pub is_transcript_details_panel_open: bool,
|
||||
tab_being_renamed: Option<usize>, // The index of the tab being renamed
|
||||
pane_being_renamed: Option<PaneViewLocator>,
|
||||
/// The tab group whose header is currently being renamed inline.
|
||||
tab_group_being_renamed: Option<TabGroupId>,
|
||||
}
|
||||
|
||||
impl WorkspaceState {
|
||||
@@ -147,6 +150,7 @@ impl WorkspaceState {
|
||||
|| self.is_changelog_modal_open
|
||||
|| self.tab_being_renamed.is_some()
|
||||
|| self.pane_being_renamed.is_some()
|
||||
|| self.tab_group_being_renamed.is_some()
|
||||
|| self.is_reward_modal_open
|
||||
|| self.is_launch_config_save_modal_open
|
||||
|| self.is_command_search_open
|
||||
@@ -162,6 +166,7 @@ impl WorkspaceState {
|
||||
|| self.is_codex_modal_open
|
||||
|| self.is_cloud_agent_capacity_modal_open
|
||||
|| self.is_free_tier_limit_hit_modal_open
|
||||
|| self.is_prompt_suggestions_unavailable_modal_open
|
||||
|| self.is_tab_config_params_modal_open
|
||||
|| self.is_session_config_modal_open
|
||||
|| self.is_new_worktree_modal_open
|
||||
@@ -188,6 +193,7 @@ impl WorkspaceState {
|
||||
self.is_changelog_modal_open = false;
|
||||
self.tab_being_renamed = None;
|
||||
self.pane_being_renamed = None;
|
||||
self.tab_group_being_renamed = None;
|
||||
self.is_reward_modal_open = false;
|
||||
self.is_launch_config_save_modal_open = false;
|
||||
self.is_command_search_open = false;
|
||||
@@ -205,6 +211,7 @@ impl WorkspaceState {
|
||||
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_prompt_suggestions_unavailable_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;
|
||||
@@ -231,6 +238,7 @@ impl WorkspaceState {
|
||||
pub fn set_tab_being_renamed(&mut self, index: usize) {
|
||||
self.tab_being_renamed = Some(index);
|
||||
self.pane_being_renamed = None;
|
||||
self.tab_group_being_renamed = None;
|
||||
}
|
||||
|
||||
pub fn clear_tab_being_renamed(&mut self) {
|
||||
@@ -252,6 +260,7 @@ impl WorkspaceState {
|
||||
pub fn set_pane_being_renamed(&mut self, pane: PaneViewLocator) {
|
||||
self.pane_being_renamed = Some(pane);
|
||||
self.tab_being_renamed = None;
|
||||
self.tab_group_being_renamed = None;
|
||||
}
|
||||
|
||||
pub fn clear_pane_being_renamed(&mut self) {
|
||||
@@ -261,6 +270,28 @@ impl WorkspaceState {
|
||||
pub fn pane_being_renamed(&self) -> Option<PaneViewLocator> {
|
||||
self.pane_being_renamed
|
||||
}
|
||||
|
||||
pub fn is_tab_group_being_renamed(&self, group_id: TabGroupId) -> bool {
|
||||
self.tab_group_being_renamed == Some(group_id)
|
||||
}
|
||||
|
||||
pub fn is_any_tab_group_being_renamed(&self) -> bool {
|
||||
self.tab_group_being_renamed.is_some()
|
||||
}
|
||||
|
||||
pub fn set_tab_group_being_renamed(&mut self, group_id: TabGroupId) {
|
||||
self.tab_group_being_renamed = Some(group_id);
|
||||
self.tab_being_renamed = None;
|
||||
self.pane_being_renamed = None;
|
||||
}
|
||||
|
||||
pub fn clear_tab_group_being_renamed(&mut self) {
|
||||
self.tab_group_being_renamed = None;
|
||||
}
|
||||
|
||||
pub fn tab_group_being_renamed(&self) -> Option<TabGroupId> {
|
||||
self.tab_group_being_renamed
|
||||
}
|
||||
}
|
||||
|
||||
/// Used to represent left and right movement for tabs in WorkspaceActions
|
||||
|
||||
+7728
-1634
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
mod view;
|
||||
|
||||
pub use view::{init, AutoHandoffSleepModal, AutoHandoffSleepModalEvent};
|
||||
@@ -0,0 +1,282 @@
|
||||
use pathfinder_color::ColorU;
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use galaxy_core::ui::theme::Fill;
|
||||
use warpui::assets::asset_cache::AssetSource;
|
||||
use warpui::elements::{
|
||||
Align, 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::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, PrimaryTheme, SecondaryTheme,
|
||||
};
|
||||
|
||||
const MODAL_WIDTH: f32 = 420.;
|
||||
const HERO_HEIGHT: f32 = 92.;
|
||||
const HERO_IMAGE_PATH: &str = "async/png/onboarding/auto_handoff_sleep_banner.png";
|
||||
|
||||
pub fn init(app: &mut AppContext) {
|
||||
use warpui::keymap::macros::*;
|
||||
|
||||
app.register_fixed_bindings([FixedBinding::new(
|
||||
"escape",
|
||||
AutoHandoffSleepModalAction::Dismiss,
|
||||
id!(AutoHandoffSleepModal::ui_name()),
|
||||
)]);
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum AutoHandoffSleepModalAction {
|
||||
Enable,
|
||||
Dismiss,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum AutoHandoffSleepModalEvent {
|
||||
/// User clicked "Enable" — turn on auto-handoff-on-sleep and close.
|
||||
Enable,
|
||||
/// User dismissed the modal without enabling.
|
||||
Dismiss,
|
||||
}
|
||||
|
||||
struct CloseButtonTheme;
|
||||
|
||||
impl ActionButtonTheme for CloseButtonTheme {
|
||||
fn background(&self, hovered: bool, appearance: &Appearance) -> Option<Fill> {
|
||||
if hovered {
|
||||
Some(appearance.theme().surface_overlay_1())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn text_color(
|
||||
&self,
|
||||
_hovered: bool,
|
||||
_background: Option<Fill>,
|
||||
_appearance: &Appearance,
|
||||
) -> ColorU {
|
||||
ColorU::white()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AutoHandoffSleepModal {
|
||||
close_button: ViewHandle<ActionButton>,
|
||||
enable_button: ViewHandle<ActionButton>,
|
||||
dismiss_button: ViewHandle<ActionButton>,
|
||||
}
|
||||
|
||||
impl AutoHandoffSleepModal {
|
||||
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(AutoHandoffSleepModalAction::Dismiss))
|
||||
});
|
||||
|
||||
let enable_button = ctx.add_view(|_ctx| {
|
||||
ActionButton::new("Enable", PrimaryTheme)
|
||||
.with_full_width(true)
|
||||
.with_size(ButtonSize::Default)
|
||||
.on_click(|ctx| ctx.dispatch_typed_action(AutoHandoffSleepModalAction::Enable))
|
||||
});
|
||||
|
||||
let dismiss_button = ctx.add_view(|_ctx| {
|
||||
ActionButton::new("Dismiss", SecondaryTheme)
|
||||
.with_full_width(true)
|
||||
.with_size(ButtonSize::Default)
|
||||
.on_click(|ctx| ctx.dispatch_typed_action(AutoHandoffSleepModalAction::Dismiss))
|
||||
});
|
||||
|
||||
Self {
|
||||
close_button,
|
||||
enable_button,
|
||||
dismiss_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> {
|
||||
let red = appearance.theme().terminal_colors().normal.red;
|
||||
let text_color: ColorU = red.into();
|
||||
let background_color = appearance.theme().ansi_overlay_2(red);
|
||||
let text = Text::new_inline(
|
||||
"Run Connection Lost".to_string(),
|
||||
appearance.ui_font_family(),
|
||||
14.,
|
||||
)
|
||||
.with_color(text_color)
|
||||
.finish();
|
||||
ConstrainedBox::new(
|
||||
Container::new(
|
||||
Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_main_axis_size(MainAxisSize::Min)
|
||||
.with_child(text)
|
||||
.finish(),
|
||||
)
|
||||
.with_horizontal_padding(8.)
|
||||
.with_background(Fill::Solid(background_color))
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Percentage(50.)))
|
||||
.finish(),
|
||||
)
|
||||
.with_height(24.)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_title(appearance: &Appearance) -> Box<dyn Element> {
|
||||
Text::new("Enable auto-handoff?", appearance.ui_font_family(), 20.)
|
||||
.with_color(
|
||||
appearance
|
||||
.theme()
|
||||
.main_text_color(appearance.theme().surface_3())
|
||||
.into_solid(),
|
||||
)
|
||||
.with_style(Properties::default().weight(Weight::Semibold))
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_description(appearance: &Appearance) -> Box<dyn Element> {
|
||||
Text::new(
|
||||
"Give Warp the option to automatically move active local agents to the cloud when \
|
||||
your computer sleeps.",
|
||||
appearance.ui_font_family(),
|
||||
14.,
|
||||
)
|
||||
.with_color(
|
||||
appearance
|
||||
.theme()
|
||||
.sub_text_color(appearance.theme().surface_3())
|
||||
.into_solid(),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_body(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
let footer = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_spacing(12.)
|
||||
.with_child(Expanded::new(1., ChildView::new(&self.enable_button).finish()).finish())
|
||||
.with_child(Expanded::new(1., ChildView::new(&self.dismiss_button).finish()).finish())
|
||||
.finish();
|
||||
|
||||
Container::new(
|
||||
Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
||||
.with_spacing(12.)
|
||||
.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))
|
||||
.finish(),
|
||||
)
|
||||
.with_child(Self::render_description(appearance))
|
||||
.with_child(footer)
|
||||
.finish(),
|
||||
)
|
||||
.with_horizontal_padding(32.)
|
||||
.with_vertical_padding(32.)
|
||||
.with_background(appearance.theme().surface_3())
|
||||
.with_corner_radius(CornerRadius::with_bottom(Radius::Pixels(8.)))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for AutoHandoffSleepModal {
|
||||
type Event = AutoHandoffSleepModalEvent;
|
||||
}
|
||||
|
||||
impl View for AutoHandoffSleepModal {
|
||||
fn ui_name() -> &'static str {
|
||||
"AutoHandoffSleepModal"
|
||||
}
|
||||
|
||||
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(appearance.theme().surface_3())
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
|
||||
.finish(),
|
||||
)
|
||||
.with_width(MODAL_WIDTH)
|
||||
.finish();
|
||||
|
||||
Container::new(Align::new(card).finish())
|
||||
.with_background(Fill::Solid(ColorU::new(97, 97, 97, 255)).with_opacity(50))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for AutoHandoffSleepModal {
|
||||
type Action = AutoHandoffSleepModalAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
AutoHandoffSleepModalAction::Enable => {
|
||||
ctx.emit(AutoHandoffSleepModalEvent::Enable);
|
||||
}
|
||||
AutoHandoffSleepModalAction::Dismiss => {
|
||||
ctx.emit(AutoHandoffSleepModalEvent::Dismiss);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,3 @@
|
||||
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 galaxy_core::ui::appearance::Appearance;
|
||||
use galaxy_core::ui::theme::Fill;
|
||||
@@ -28,6 +22,13 @@ use pathfinder_geometry::vector::vec2f;
|
||||
use settings::Setting as _;
|
||||
use thousands::Separable;
|
||||
|
||||
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;
|
||||
|
||||
const BUTTON_DIAMETER: f32 = 20.;
|
||||
const DROPDOWN_WIDTH: f32 = 160.;
|
||||
const MODAL_HEIGHT: f32 = 540.;
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
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 galaxy_core::ui::appearance::Appearance;
|
||||
use galaxy_core::ui::theme::Fill;
|
||||
@@ -25,8 +19,13 @@ use pathfinder_color::ColorU;
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use thousands::Separable;
|
||||
|
||||
use crate::send_telemetry_from_ctx;
|
||||
use crate::TelemetryEvent;
|
||||
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 crate::{send_telemetry_from_ctx, TelemetryEvent};
|
||||
|
||||
const MODAL_WIDTH: f32 = 360.;
|
||||
const MODAL_HEIGHT: f32 = 532.;
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
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 galaxy_core::ui::appearance::Appearance;
|
||||
use galaxy_core::ui::theme::Fill;
|
||||
@@ -20,6 +17,10 @@ use galaxyui::{
|
||||
use pathfinder_color::ColorU;
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
|
||||
use crate::ui_components::blended_colors;
|
||||
use crate::ui_components::icons::Icon;
|
||||
use crate::view_components::action_button::{ActionButton, ActionButtonTheme};
|
||||
|
||||
/// White button theme for the Codex modal CTA.
|
||||
struct WhiteButtonTheme;
|
||||
|
||||
|
||||
@@ -1,31 +1,38 @@
|
||||
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 galaxy_core::ui::color::coloru_with_opacity;
|
||||
use galaxy_core::ui::theme::color::internal_colors;
|
||||
use galaxy_util::path::user_friendly_path;
|
||||
use galaxyui::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,
|
||||
CrossAxisAlignment, DispatchEventResult, Element, EventHandler, Fill as ElementFill, Flex,
|
||||
Highlight, Hoverable, MainAxisAlignment, MainAxisSize, MouseInBehavior, MouseStateHandle,
|
||||
OffsetPositioning, OffsetType, ParentAnchor, ParentElement, ParentOffsetBounds,
|
||||
PositionedElementOffsetBounds, PositioningAxis, Radius, SavePosition, Shrinkable, Stack, Text,
|
||||
XAxisAnchor, YAxisAnchor,
|
||||
};
|
||||
use galaxyui::fonts::{Properties, Weight};
|
||||
use galaxyui::platform::Cursor;
|
||||
use galaxyui::text_layout::TextStyle;
|
||||
use galaxyui::ui_components::components::{UiComponent, UiComponentStyles};
|
||||
use galaxyui::ui_components::text_input::TextInput;
|
||||
use galaxyui::{AppContext, SingletonEntity, ViewHandle};
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
|
||||
use crate::ai::active_agent_views_model::ActiveAgentViewsModel;
|
||||
use crate::ai::agent_conversations_model::{
|
||||
AgentConversationEntry, AgentConversationEntryId, AgentConversationProvenance,
|
||||
};
|
||||
use crate::ai::conversation_status_ui::STATUS_ELEMENT_PADDING;
|
||||
use crate::appearance::Appearance;
|
||||
use crate::drive::sharing::dialog::SharingDialog;
|
||||
use crate::editor::EditorView;
|
||||
use crate::menu::Menu;
|
||||
use crate::ui_components::agent_icon::agent_conversation_entry_icon_variant;
|
||||
use crate::ui_components::icon_with_status::render_icon_with_status;
|
||||
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;
|
||||
|
||||
/// Maximum length for tooltip text before truncation
|
||||
const MAX_TOOLTIP_LENGTH: usize = 80;
|
||||
@@ -36,13 +43,24 @@ const ICON_SPACING: f32 = 4.;
|
||||
/// Offset for the sharing dialog from the item row
|
||||
const DIALOG_OFFSET_PIXELS: f32 = -16.;
|
||||
|
||||
/// Total size of the agent icon-with-status component rendered in each conversation list
|
||||
/// row.
|
||||
const LIST_ITEM_AGENT_SIZE: f32 = 22.;
|
||||
/// Extra overhang past the default overlay position, as a fraction of
|
||||
/// `LIST_ITEM_AGENT_SIZE`. Pushes the badge all the way to the bounding box's BR corner;
|
||||
/// the conversation list reads better with the status sitting slightly further out than
|
||||
/// on the other surfaces.
|
||||
const LIST_ITEM_OVERLAY_EXTRA_OVERHANG: f32 = 0.05;
|
||||
|
||||
/// Generate a position ID for a conversation list item
|
||||
fn conversation_item_position_id(id: &ConversationOrTaskId) -> String {
|
||||
fn conversation_item_position_id(id: &AgentConversationEntryId) -> String {
|
||||
match id {
|
||||
ConversationOrTaskId::ConversationId(conv_id) => {
|
||||
AgentConversationEntryId::Conversation(conv_id) => {
|
||||
format!("conversation_list_item_{conv_id}")
|
||||
}
|
||||
ConversationOrTaskId::TaskId(task_id) => format!("conversation_list_task_{task_id}"),
|
||||
AgentConversationEntryId::AmbientRun(task_id) => {
|
||||
format!("conversation_list_task_{task_id}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +71,7 @@ pub const STATIC_ITEM_MIN_HEIGHT: f32 = 42.;
|
||||
#[derive(Clone, Default)]
|
||||
pub struct ItemState {
|
||||
pub mouse_state: MouseStateHandle,
|
||||
pub title_mouse_state: MouseStateHandle,
|
||||
pub overflow_button_state: MouseStateHandle,
|
||||
}
|
||||
|
||||
@@ -66,7 +85,7 @@ pub enum OverflowMenuDisplay {
|
||||
}
|
||||
|
||||
pub struct ItemProps<'a> {
|
||||
pub conversation: &'a ConversationOrTask<'a>,
|
||||
pub conversation: &'a AgentConversationEntry,
|
||||
pub highlight_indices: Option<&'a Vec<usize>>,
|
||||
pub is_selected: bool,
|
||||
pub is_focused_conversation: bool,
|
||||
@@ -74,7 +93,10 @@ pub struct ItemProps<'a> {
|
||||
pub state: &'a ItemState,
|
||||
pub overflow_menu: &'a ViewHandle<Menu<ConversationListViewAction>>,
|
||||
pub overflow_menu_display: OverflowMenuDisplay,
|
||||
pub conversation_id: ConversationOrTaskId,
|
||||
pub conversation_id: AgentConversationEntryId,
|
||||
pub is_renaming: bool,
|
||||
pub can_rename: bool,
|
||||
pub rename_editor: Option<&'a ViewHandle<EditorView>>,
|
||||
pub sharing_dialog: &'a ViewHandle<SharingDialog>,
|
||||
pub is_share_dialog_open: bool,
|
||||
pub list_position_id: &'a str,
|
||||
@@ -147,7 +169,10 @@ pub fn render_static_item(props: StaticItemProps<'_>, app: &AppContext) -> Box<d
|
||||
},
|
||||
Some(MouseInBehavior {
|
||||
fire_on_synthetic_events: false,
|
||||
fire_when_covered: true,
|
||||
// `fire_when_covered: false` makes hover ignore the row when it's
|
||||
// covered by an overlay (e.g. a modal), so the selection/tooltip
|
||||
// don't leak through — matching how clicks are already blocked.
|
||||
fire_when_covered: false,
|
||||
}),
|
||||
)
|
||||
.finish()
|
||||
@@ -164,6 +189,9 @@ pub fn render_item(props: ItemProps<'_>, app: &AppContext) -> Box<dyn Element> {
|
||||
overflow_menu,
|
||||
overflow_menu_display,
|
||||
conversation_id,
|
||||
is_renaming,
|
||||
can_rename,
|
||||
rename_editor,
|
||||
sharing_dialog,
|
||||
is_share_dialog_open,
|
||||
list_position_id,
|
||||
@@ -176,8 +204,12 @@ pub fn render_item(props: ItemProps<'_>, app: &AppContext) -> Box<dyn Element> {
|
||||
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());
|
||||
let mut title_text = Text::new_inline(
|
||||
conversation.display.title.clone(),
|
||||
font_family,
|
||||
title_font_size,
|
||||
)
|
||||
.with_color(theme.main_text_color(theme.background()).into());
|
||||
|
||||
if let Some(indices) = highlight_indices {
|
||||
if !indices.is_empty() {
|
||||
@@ -194,33 +226,48 @@ pub fn render_item(props: ItemProps<'_>, app: &AppContext) -> Box<dyn Element> {
|
||||
}
|
||||
}
|
||||
|
||||
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_galaxyui_icon(theme.sub_text_color(theme.background()))
|
||||
.finish(),
|
||||
)
|
||||
.with_width(status_element_size)
|
||||
.with_height(status_element_size)
|
||||
.finish()
|
||||
let title_element: Box<dyn Element> =
|
||||
if let Some(rename_editor) = rename_editor.filter(|_| is_renaming) {
|
||||
render_inline_rename_editor(rename_editor, appearance)
|
||||
} else {
|
||||
title_text.finish()
|
||||
};
|
||||
let title_element = if can_rename && !is_renaming {
|
||||
let title_mouse_state = state.title_mouse_state.clone();
|
||||
Hoverable::new(title_mouse_state, move |_| title_element)
|
||||
.on_double_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(ConversationListViewAction::StartRename {
|
||||
id: conversation_id,
|
||||
});
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.finish()
|
||||
} else {
|
||||
render_status_element(&conversation.status(app), font_size, appearance)
|
||||
title_element
|
||||
};
|
||||
|
||||
let status_element_size = font_size + STATUS_ELEMENT_PADDING * 2.;
|
||||
let icon_element = render_icon_with_status(
|
||||
agent_conversation_entry_icon_variant(conversation),
|
||||
LIST_ITEM_AGENT_SIZE,
|
||||
LIST_ITEM_OVERLAY_EXTRA_OVERHANG,
|
||||
theme,
|
||||
theme.background(),
|
||||
);
|
||||
|
||||
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())
|
||||
.with_child(Shrinkable::new(1.0, title_element).finish())
|
||||
.finish(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
let timestamp = Text::new_inline(
|
||||
format_approx_duration_from_now_utc(conversation.last_updated()),
|
||||
format_approx_duration_from_now_utc(conversation.display.last_updated),
|
||||
font_family,
|
||||
font_size - 2.,
|
||||
)
|
||||
@@ -266,10 +313,8 @@ pub fn render_item(props: ItemProps<'_>, app: &AppContext) -> Box<dyn Element> {
|
||||
.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 can_open = conversation.capabilities.can_open;
|
||||
let tooltip_text = truncate_from_end(&conversation.display.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)
|
||||
@@ -287,7 +332,9 @@ pub fn render_item(props: ItemProps<'_>, app: &AppContext) -> Box<dyn Element> {
|
||||
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) {
|
||||
if !is_renaming
|
||||
&& (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());
|
||||
@@ -326,7 +373,10 @@ pub fn render_item(props: ItemProps<'_>, app: &AppContext) -> Box<dyn Element> {
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if !is_renaming
|
||||
&& 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.)
|
||||
@@ -347,7 +397,7 @@ pub fn render_item(props: ItemProps<'_>, app: &AppContext) -> Box<dyn Element> {
|
||||
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.");
|
||||
log::warn!("Could not retrieve the position of the conversation list for overflow menu display.");
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -360,7 +410,7 @@ pub fn render_item(props: ItemProps<'_>, app: &AppContext) -> Box<dyn Element> {
|
||||
})
|
||||
.with_defer_events_to_children();
|
||||
|
||||
let hoverable_element = if open_action.is_some() {
|
||||
let hoverable_element = if can_open && !is_renaming {
|
||||
hoverable
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(move |ctx, _, _| {
|
||||
@@ -381,7 +431,10 @@ pub fn render_item(props: ItemProps<'_>, app: &AppContext) -> Box<dyn Element> {
|
||||
},
|
||||
Some(MouseInBehavior {
|
||||
fire_on_synthetic_events: false,
|
||||
fire_when_covered: true,
|
||||
// `fire_when_covered: false` makes hover ignore the row when
|
||||
// it's covered by an overlay (e.g. a modal), so the
|
||||
// selection/tooltip don't leak through — matching clicks.
|
||||
fire_when_covered: false,
|
||||
}),
|
||||
)
|
||||
.finish();
|
||||
@@ -415,26 +468,48 @@ pub fn render_item(props: ItemProps<'_>, app: &AppContext) -> Box<dyn Element> {
|
||||
SavePosition::new(item_stack.finish(), &position_id).finish()
|
||||
}
|
||||
|
||||
fn render_inline_rename_editor(
|
||||
rename_editor: &ViewHandle<EditorView>,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
TextInput::new(
|
||||
rename_editor.clone(),
|
||||
UiComponentStyles::default()
|
||||
.set_background(ElementFill::None)
|
||||
.set_border_radius(CornerRadius::with_all(Radius::Pixels(0.)))
|
||||
.set_border_width(0.)
|
||||
.set_font_size(appearance.ui_font_size() + 2.),
|
||||
)
|
||||
.build()
|
||||
.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()
|
||||
})
|
||||
}
|
||||
fn format_item_subtext(conversation: &AgentConversationEntry, app: &AppContext) -> Option<String> {
|
||||
if matches!(
|
||||
conversation.provenance,
|
||||
AgentConversationProvenance::AmbientRun
|
||||
) {
|
||||
return conversation
|
||||
.display
|
||||
.source
|
||||
.as_ref()
|
||||
.map(|source| source.display_name().to_string());
|
||||
}
|
||||
|
||||
let live_pwd = conversation
|
||||
.identity
|
||||
.local_conversation_id
|
||||
.and_then(|conversation_id| {
|
||||
ActiveAgentViewsModel::as_ref(app)
|
||||
.get_active_session_for_conversation(conversation_id, app)
|
||||
.and_then(|session| session.as_ref(app).current_working_directory().cloned())
|
||||
});
|
||||
|
||||
let pwd = live_pwd.or_else(|| conversation.display.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()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,39 +1,11 @@
|
||||
use pathfinder_geometry::vector::Vector2F;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::ops::Range;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::ai::active_agent_views_model::{ActiveAgentViewsModel, ConversationOrTaskId};
|
||||
use crate::ai::agent::api::ServerConversationToken;
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::agent_conversations_model::{AgentConversationsModel, ConversationOrTask};
|
||||
use crate::ai::agent_management::telemetry::{AgentManagementTelemetryEvent, OpenedFrom};
|
||||
use crate::ai::blocklist::history_model::BlocklistAIHistoryModel;
|
||||
use crate::appearance::Appearance;
|
||||
use crate::drive::sharing::dialog::SharingDialog;
|
||||
use crate::drive::sharing::ShareableObject;
|
||||
use crate::editor::{
|
||||
EditorView, Event as EditorEvent, PropagateAndNoOpNavigationKeys,
|
||||
PropagateHorizontalNavigationKeys, SingleLineEditorOptions, TextOptions,
|
||||
};
|
||||
use crate::menu::{Event as MenuEvent, Menu, MenuItem, MenuItemFields};
|
||||
use crate::server::telemetry::SharingDialogSource;
|
||||
use crate::view_components::action_button::{ActionButton, ButtonSize, SecondaryTheme};
|
||||
use crate::view_components::DismissibleToast;
|
||||
use crate::workspace::global_actions::ForkedConversationDestination;
|
||||
use crate::workspace::header_toolbar_item::HeaderToolbarItemKind;
|
||||
use crate::workspace::tab_settings::TabSettings;
|
||||
use crate::workspace::view::conversation_list::item::{
|
||||
render_item, render_static_item, ItemProps, ItemState, OverflowMenuDisplay, StaticItemProps,
|
||||
STATIC_ITEM_MIN_HEIGHT,
|
||||
};
|
||||
use crate::workspace::ToastStack;
|
||||
use crate::workspace::WorkspaceAction;
|
||||
use pathfinder_geometry::vector::Vector2F;
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxy_core::send_telemetry_from_ctx;
|
||||
use galaxy_core::ui::Icon;
|
||||
|
||||
use super::view_model::{ConversationEntry, ConversationListViewModel};
|
||||
use galaxy_editor::editor::NavigationKey;
|
||||
use galaxyui::elements::{
|
||||
Border, ChildAnchor, ChildView, Clipped, ConstrainedBox, Container, CornerRadius,
|
||||
@@ -52,6 +24,35 @@ use galaxyui::{
|
||||
ViewContext, ViewHandle, WindowId,
|
||||
};
|
||||
|
||||
use super::view_model::{ConversationEntry, ConversationListViewModel};
|
||||
use crate::ai::active_agent_views_model::{ActiveAgentViewsModel, ConversationOrTaskId};
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::agent_conversations_model::{
|
||||
AgentConversationEntryId, AgentConversationNavigationSubject, AgentConversationsModel,
|
||||
};
|
||||
use crate::ai::agent_management::telemetry::{AgentManagementTelemetryEvent, OpenedFrom};
|
||||
use crate::ai::blocklist::history_model::BlocklistAIHistoryModel;
|
||||
use crate::ai::conversation_rename::rename_conversation;
|
||||
use crate::appearance::Appearance;
|
||||
use crate::drive::sharing::dialog::SharingDialog;
|
||||
use crate::drive::sharing::ShareableObject;
|
||||
use crate::editor::{
|
||||
EditorView, Event as EditorEvent, PropagateAndNoOpNavigationKeys,
|
||||
PropagateHorizontalNavigationKeys, SingleLineEditorOptions, TextOptions,
|
||||
};
|
||||
use crate::menu::{Event as MenuEvent, Menu, MenuItem, MenuItemFields};
|
||||
use crate::server::telemetry::SharingDialogSource;
|
||||
use crate::view_components::action_button::{ActionButton, ButtonSize, SecondaryTheme};
|
||||
use crate::view_components::DismissibleToast;
|
||||
use crate::workspace::global_actions::ForkedConversationDestination;
|
||||
use crate::workspace::header_toolbar_item::HeaderToolbarItemKind;
|
||||
use crate::workspace::tab_settings::TabSettings;
|
||||
use crate::workspace::view::conversation_list::item::{
|
||||
render_item, render_static_item, ItemProps, ItemState, OverflowMenuDisplay, StaticItemProps,
|
||||
STATIC_ITEM_MIN_HEIGHT,
|
||||
};
|
||||
use crate::workspace::{ToastStack, WorkspaceAction};
|
||||
|
||||
const VIEW_ALL_LABEL: &str = "View all";
|
||||
/// Maximum number of past items to show before the user toggles "view all".
|
||||
const INITIAL_MAX_PAST_ITEMS: usize = 10;
|
||||
@@ -60,7 +61,7 @@ const INITIAL_MAX_PAST_ITEMS: usize = 10;
|
||||
struct StateHandles {
|
||||
list_state: UniformListState,
|
||||
scroll_state: ScrollStateHandle,
|
||||
item_states: HashMap<ConversationOrTaskId, ItemState>,
|
||||
item_states: HashMap<AgentConversationEntryId, ItemState>,
|
||||
start_new_conversation_item: ItemState,
|
||||
list_hover: MouseStateHandle,
|
||||
zero_state_button: MouseStateHandle,
|
||||
@@ -93,7 +94,11 @@ pub enum ConversationSection {
|
||||
#[derive(Clone, Debug)]
|
||||
enum ListItem {
|
||||
SectionHeader(ConversationSection),
|
||||
Conversation(ConversationEntry),
|
||||
Conversation {
|
||||
entry: ConversationEntry,
|
||||
/// The section the conversation is rendered under.
|
||||
section: ConversationSection,
|
||||
},
|
||||
/// The "+ New conversation" item at the end of the active section.
|
||||
StartNewConversation,
|
||||
ToggleViewAllButton,
|
||||
@@ -101,7 +106,7 @@ enum ListItem {
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct OverflowMenuState {
|
||||
conversation_id: ConversationOrTaskId,
|
||||
conversation_id: AgentConversationEntryId,
|
||||
/// When `Some`, the menu was opened via right-click and should be
|
||||
/// positioned at the cursor location rather than the kebab button.
|
||||
position: Option<Vector2F>,
|
||||
@@ -114,19 +119,19 @@ pub enum ConversationListViewAction {
|
||||
terminal_view_id: Option<EntityId>,
|
||||
},
|
||||
ToggleOverflowMenu {
|
||||
conversation_id: ConversationOrTaskId,
|
||||
conversation_id: AgentConversationEntryId,
|
||||
/// When `Some`, the menu was opened via right-click and should be
|
||||
/// positioned where the right click took place.
|
||||
position: Option<Vector2F>,
|
||||
},
|
||||
OpenShareDialog {
|
||||
conversation_id: ConversationOrTaskId,
|
||||
conversation_id: AgentConversationEntryId,
|
||||
},
|
||||
DeleteFromOverflowMenu {
|
||||
conversation_id: ConversationOrTaskId,
|
||||
conversation_id: AgentConversationEntryId,
|
||||
},
|
||||
OpenItem {
|
||||
id: ConversationOrTaskId,
|
||||
id: AgentConversationEntryId,
|
||||
},
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
@@ -137,9 +142,14 @@ pub enum ConversationListViewAction {
|
||||
ToggleSection(ConversationSection),
|
||||
ToggleViewAll,
|
||||
ForkConversation {
|
||||
conversation_id: ConversationOrTaskId,
|
||||
conversation_id: AgentConversationEntryId,
|
||||
destination: ForkedConversationDestination,
|
||||
},
|
||||
StartRename {
|
||||
id: AgentConversationEntryId,
|
||||
},
|
||||
FinishRename,
|
||||
CancelRename,
|
||||
}
|
||||
|
||||
pub enum Event {
|
||||
@@ -162,8 +172,10 @@ pub struct ConversationListView {
|
||||
overflow_menu_state: Option<OverflowMenuState>,
|
||||
/// Sharing dialog for conversations.
|
||||
sharing_dialog: ViewHandle<SharingDialog>,
|
||||
rename_editor: ViewHandle<EditorView>,
|
||||
renaming_conversation_id: Option<AIConversationId>,
|
||||
/// Track which conversation the share dialog is open for.
|
||||
share_dialog_open_for: Option<ConversationOrTaskId>,
|
||||
share_dialog_open_for: Option<AgentConversationEntryId>,
|
||||
selected_index: Option<usize>,
|
||||
collapsed_sections: HashSet<ConversationSection>,
|
||||
/// Cached flat list of items (headers + conversations) for rendering and navigation.
|
||||
@@ -232,6 +244,30 @@ impl ConversationListView {
|
||||
ctx.subscribe_to_view(&query_editor, |me, _handle, event, ctx| {
|
||||
me.handle_query_editor_event(event, ctx);
|
||||
});
|
||||
let rename_editor = ctx.add_typed_action_view(|ctx| {
|
||||
let appearance = Appearance::as_ref(ctx);
|
||||
EditorView::single_line(
|
||||
SingleLineEditorOptions {
|
||||
text: TextOptions::ui_text(Some(appearance.ui_font_size() + 2.), appearance),
|
||||
select_all_on_focus: true,
|
||||
clear_selections_on_blur: true,
|
||||
propagate_and_no_op_vertical_navigation_keys:
|
||||
PropagateAndNoOpNavigationKeys::Always,
|
||||
propagate_horizontal_navigation_keys: PropagateHorizontalNavigationKeys::Always,
|
||||
..Default::default()
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
ctx.subscribe_to_view(&rename_editor, |me, _, event, ctx| match event {
|
||||
EditorEvent::Blurred | EditorEvent::Enter => {
|
||||
me.finish_rename(ctx);
|
||||
}
|
||||
EditorEvent::Escape => {
|
||||
me.cancel_rename(ctx);
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
|
||||
// We use this as both the "view all" and "show less" button
|
||||
// (switching out the text on-toggle).
|
||||
@@ -276,6 +312,8 @@ impl ConversationListView {
|
||||
item_overflow_menu,
|
||||
overflow_menu_state: None,
|
||||
sharing_dialog,
|
||||
rename_editor,
|
||||
renaming_conversation_id: None,
|
||||
share_dialog_open_for: None,
|
||||
selected_index: None,
|
||||
collapsed_sections: HashSet::new(),
|
||||
@@ -291,11 +329,15 @@ impl ConversationListView {
|
||||
/// Rebuilds the flat list of items based on sections and collapse state.
|
||||
fn rebuild_list_items(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
let active_views_model = ActiveAgentViewsModel::as_ref(ctx);
|
||||
let active_ids = if FeatureFlag::ActiveConversationRequiresInteraction.is_enabled() {
|
||||
active_views_model.get_all_active_conversation_ids(ctx)
|
||||
} else {
|
||||
active_views_model.get_all_open_conversation_ids(ctx)
|
||||
};
|
||||
let active_ids: HashSet<_> =
|
||||
if FeatureFlag::ActiveConversationRequiresInteraction.is_enabled() {
|
||||
active_views_model.get_all_active_conversation_ids(ctx)
|
||||
} else {
|
||||
active_views_model.get_all_open_conversation_ids(ctx)
|
||||
}
|
||||
.into_iter()
|
||||
.map(AgentConversationEntryId::from)
|
||||
.collect();
|
||||
|
||||
let focused_new_conversation =
|
||||
active_views_model.maybe_get_focused_new_conversation(ctx.window_id(), ctx);
|
||||
@@ -305,33 +347,58 @@ impl ConversationListView {
|
||||
let mut active_items = Vec::new();
|
||||
let mut past_items = Vec::new();
|
||||
for entry in model.filtered_items() {
|
||||
let list_item = ListItem::Conversation(entry.clone());
|
||||
if active_ids.contains(&entry.id) {
|
||||
active_items.push(list_item);
|
||||
let local_conversation_entry_id = model
|
||||
.get_item_by_id(&entry.id, ctx)
|
||||
.and_then(|entry| entry.identity.local_conversation_id)
|
||||
.map(AgentConversationEntryId::Conversation);
|
||||
let is_active = active_ids.contains(&entry.id)
|
||||
|| local_conversation_entry_id.is_some_and(|id| active_ids.contains(&id));
|
||||
if is_active {
|
||||
active_items.push(ListItem::Conversation {
|
||||
entry: entry.clone(),
|
||||
section: ConversationSection::Active,
|
||||
});
|
||||
} else {
|
||||
past_items.push(list_item);
|
||||
past_items.push(ListItem::Conversation {
|
||||
entry: entry.clone(),
|
||||
section: ConversationSection::Past,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// If the focused conversation is a new/empty conversation that's not already in the list,
|
||||
// add it as a regular conversation entry so it participates in the sort.
|
||||
if let Some(new_conv_id) = focused_new_conversation {
|
||||
let conv_id = ConversationOrTaskId::ConversationId(new_conv_id);
|
||||
let already_in_list = active_items
|
||||
.iter()
|
||||
.any(|item| matches!(item, ListItem::Conversation(entry) if entry.id == conv_id));
|
||||
let conv_id = AgentConversationEntryId::Conversation(new_conv_id);
|
||||
let already_in_list = active_items.iter().any(
|
||||
|item| matches!(item, ListItem::Conversation { entry, .. } if entry.id == conv_id),
|
||||
);
|
||||
if !already_in_list {
|
||||
active_items.push(ListItem::Conversation(ConversationEntry {
|
||||
id: conv_id,
|
||||
highlight_indices: vec![],
|
||||
}));
|
||||
active_items.push(ListItem::Conversation {
|
||||
entry: ConversationEntry {
|
||||
id: conv_id,
|
||||
highlight_indices: vec![],
|
||||
},
|
||||
section: ConversationSection::Active,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort active items by last opened time (most recently opened first).
|
||||
active_items.sort_by(|a, b| {
|
||||
let get_time = |item: &ListItem| match item {
|
||||
ListItem::Conversation(entry) => active_views_model.get_last_opened_time(&entry.id),
|
||||
ListItem::Conversation { entry, .. } => {
|
||||
let entry_time = active_views_model
|
||||
.get_last_opened_time(&ConversationOrTaskId::from(entry.id));
|
||||
let local_time = model
|
||||
.get_item_by_id(&entry.id, ctx)
|
||||
.and_then(|item| item.identity.local_conversation_id)
|
||||
.and_then(|id| {
|
||||
active_views_model
|
||||
.get_last_opened_time(&ConversationOrTaskId::ConversationId(id))
|
||||
});
|
||||
entry_time.max(local_time)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
get_time(b).cmp(&get_time(a))
|
||||
@@ -389,16 +456,31 @@ impl ConversationListView {
|
||||
self.list_items.get(index)
|
||||
}
|
||||
|
||||
/// Finds the flat index of a conversation or task by ID, or None if not found.
|
||||
fn get_index_of_conversation_id(&self, conversation_id: ConversationOrTaskId) -> Option<usize> {
|
||||
fn get_index_of_conversation_id(
|
||||
&self,
|
||||
conversation_id: AgentConversationEntryId,
|
||||
) -> Option<usize> {
|
||||
self.list_items.iter().position(|item| match item {
|
||||
ListItem::Conversation(entry) => entry.id == conversation_id,
|
||||
ListItem::Conversation { entry, .. } => entry.id == conversation_id,
|
||||
ListItem::SectionHeader(_)
|
||||
| ListItem::StartNewConversation
|
||||
| ListItem::ToggleViewAllButton => false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the given entry is currently shown in the Active section.
|
||||
fn is_in_active_section(&self, conversation_id: AgentConversationEntryId) -> bool {
|
||||
self.list_items.iter().any(|item| {
|
||||
matches!(
|
||||
item,
|
||||
ListItem::Conversation {
|
||||
entry,
|
||||
section: ConversationSection::Active,
|
||||
} if entry.id == conversation_id
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn on_left_panel_focused(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
// Focus the search bar when the panel is opened.
|
||||
ctx.focus(&self.query_editor);
|
||||
@@ -406,8 +488,9 @@ impl ConversationListView {
|
||||
// Select the focused conversation if there is one.
|
||||
let focused_conversation =
|
||||
ActiveAgentViewsModel::as_ref(ctx).get_focused_conversation(ctx.window_id());
|
||||
self.selected_index =
|
||||
focused_conversation.and_then(|id| self.get_index_of_conversation_id(id));
|
||||
self.selected_index = focused_conversation
|
||||
.map(AgentConversationEntryId::from)
|
||||
.and_then(|id| self.get_index_of_conversation_id(id));
|
||||
|
||||
if let Some(index) = self.selected_index {
|
||||
self.state_handles.list_state.scroll_to(index);
|
||||
@@ -444,7 +527,7 @@ impl ConversationListView {
|
||||
|
||||
fn is_selectable(&self, index: usize) -> bool {
|
||||
self.get_list_item(index).is_some_and(|item| match item {
|
||||
ListItem::Conversation(_) | ListItem::StartNewConversation => true,
|
||||
ListItem::Conversation { .. } | ListItem::StartNewConversation => true,
|
||||
ListItem::SectionHeader(_) | ListItem::ToggleViewAllButton => false,
|
||||
})
|
||||
}
|
||||
@@ -531,10 +614,9 @@ impl ConversationListView {
|
||||
self.focus_query_editor(ctx);
|
||||
}
|
||||
|
||||
/// Send telemetry for opening a conversation or task
|
||||
fn send_open_telemetry(id: &ConversationOrTaskId, ctx: &mut ViewContext<Self>) {
|
||||
fn send_open_telemetry(id: &AgentConversationEntryId, ctx: &mut ViewContext<Self>) {
|
||||
match id {
|
||||
ConversationOrTaskId::ConversationId(conversation_id) => {
|
||||
AgentConversationEntryId::Conversation(conversation_id) => {
|
||||
send_telemetry_from_ctx!(
|
||||
AgentManagementTelemetryEvent::ConversationOpened {
|
||||
conversation_id: conversation_id.to_string(),
|
||||
@@ -543,7 +625,7 @@ impl ConversationListView {
|
||||
ctx
|
||||
);
|
||||
}
|
||||
ConversationOrTaskId::TaskId(task_id) => {
|
||||
AgentConversationEntryId::AmbientRun(task_id) => {
|
||||
send_telemetry_from_ctx!(
|
||||
AgentManagementTelemetryEvent::CloudRunOpened {
|
||||
task_id: task_id.to_string(),
|
||||
@@ -569,14 +651,12 @@ impl ConversationListView {
|
||||
ListItem::StartNewConversation => {
|
||||
ctx.emit(Event::NewConversationInNewTab);
|
||||
}
|
||||
ListItem::Conversation(entry) => {
|
||||
let model = self.view_model.as_ref(ctx);
|
||||
let Some(item) = model.get_item_by_id(&entry.id, ctx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Use shared logic from ConversationOrTask to determine click action
|
||||
if let Some(action) = item.get_open_action(None, ctx) {
|
||||
ListItem::Conversation { entry, .. } => {
|
||||
if let Some(action) = AgentConversationsModel::resolve_open_action(
|
||||
AgentConversationNavigationSubject::Entry(entry.id),
|
||||
None,
|
||||
ctx,
|
||||
) {
|
||||
Self::send_open_telemetry(&entry.id, ctx);
|
||||
ctx.dispatch_typed_action(&action);
|
||||
}
|
||||
@@ -614,6 +694,58 @@ impl ConversationListView {
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn start_rename(&mut self, id: AgentConversationEntryId, ctx: &mut ViewContext<Self>) {
|
||||
let Some(entry) = self.view_model.as_ref(ctx).get_item_by_id(&id, ctx) else {
|
||||
return;
|
||||
};
|
||||
let Some(conversation_id) = entry.identity.local_conversation_id else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Renaming requires the conversation to be loaded in the history model, which holds
|
||||
// for active conversations and any conversation open in an agent view (an open
|
||||
// conversation stays in the Past section until a prompt is sent).
|
||||
let is_open = ActiveAgentViewsModel::as_ref(ctx)
|
||||
.get_terminal_view_id_for_conversation(conversation_id, ctx)
|
||||
.is_some();
|
||||
if !self.is_in_active_section(id) && !is_open {
|
||||
return;
|
||||
}
|
||||
|
||||
self.overflow_menu_state = None;
|
||||
self.renaming_conversation_id = Some(conversation_id);
|
||||
self.selected_index = self.get_index_of_conversation_id(id);
|
||||
let title = entry.display.title;
|
||||
self.rename_editor.update(ctx, |editor, ctx| {
|
||||
editor.clear_buffer_and_reset_undo_stack(ctx);
|
||||
editor.insert_selected_text(&title, ctx);
|
||||
});
|
||||
ctx.focus(&self.rename_editor);
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn finish_rename(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
let Some(conversation_id) = self.renaming_conversation_id.take() else {
|
||||
return;
|
||||
};
|
||||
let title = self.rename_editor.as_ref(ctx).buffer_text(ctx);
|
||||
self.rename_editor.update(ctx, |editor, ctx| {
|
||||
editor.clear_buffer_and_reset_undo_stack(ctx);
|
||||
});
|
||||
rename_conversation(conversation_id, title, ctx);
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn cancel_rename(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
if self.renaming_conversation_id.take().is_none() {
|
||||
return;
|
||||
}
|
||||
self.rename_editor.update(ctx, |editor, ctx| {
|
||||
editor.clear_buffer_and_reset_undo_stack(ctx);
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn toggle_section_collapse(
|
||||
&mut self,
|
||||
section: ConversationSection,
|
||||
@@ -851,6 +983,7 @@ impl TypedActionView for ConversationListView {
|
||||
terminal_view_id,
|
||||
} => {
|
||||
let window_id = ctx.window_id();
|
||||
// A conversation can only be deleted once it's done.
|
||||
let conversation_is_done = BlocklistAIHistoryModel::as_ref(ctx)
|
||||
.conversation(conversation_id)
|
||||
.is_none_or(|c| c.status().is_done());
|
||||
@@ -867,12 +1000,12 @@ impl TypedActionView for ConversationListView {
|
||||
return;
|
||||
}
|
||||
|
||||
let id = ConversationOrTaskId::ConversationId(*conversation_id);
|
||||
let id = AgentConversationEntryId::Conversation(*conversation_id);
|
||||
let conversation_title = self
|
||||
.view_model
|
||||
.as_ref(ctx)
|
||||
.get_item_by_id(&id, ctx)
|
||||
.map(|c| c.title(ctx).to_string())
|
||||
.map(|entry| entry.display.title)
|
||||
.unwrap_or_else(|| "Conversation".to_string());
|
||||
ctx.emit(Event::ShowDeleteConfirmationDialog {
|
||||
conversation_id: *conversation_id,
|
||||
@@ -896,41 +1029,27 @@ impl TypedActionView for ConversationListView {
|
||||
});
|
||||
|
||||
let conversation_id = *conversation_id;
|
||||
let is_ambient_agent_conversation =
|
||||
matches!(conversation_id, ConversationOrTaskId::TaskId(_));
|
||||
let Some(entry) = self
|
||||
.view_model
|
||||
.as_ref(ctx)
|
||||
.get_item_by_id(&conversation_id, ctx)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut delete_item = MenuItemFields::new("Delete")
|
||||
.with_override_text_color(Appearance::as_ref(ctx).theme().ansi_fg_red())
|
||||
.with_on_select_action(ConversationListViewAction::DeleteFromOverflowMenu {
|
||||
conversation_id,
|
||||
})
|
||||
.with_disabled(is_ambient_agent_conversation);
|
||||
if is_ambient_agent_conversation {
|
||||
delete_item = delete_item
|
||||
.with_tooltip("Ambient agent conversations cannot be deleted");
|
||||
.with_disabled(!entry.capabilities.can_delete);
|
||||
if !entry.capabilities.can_delete {
|
||||
delete_item =
|
||||
delete_item.with_tooltip("This conversation cannot be deleted");
|
||||
}
|
||||
|
||||
// Check if conversation is shareable:
|
||||
// - For tasks: check if there's an associated conversation_id
|
||||
// - For conversations: check if synced to cloud
|
||||
let is_shareable = match conversation_id {
|
||||
ConversationOrTaskId::TaskId(task_id) => {
|
||||
if let Some(ConversationOrTask::Task(task)) =
|
||||
AgentConversationsModel::as_ref(ctx).get_task(&task_id)
|
||||
{
|
||||
task.conversation_id.is_some()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
ConversationOrTaskId::ConversationId(conv_id) => {
|
||||
BlocklistAIHistoryModel::as_ref(ctx)
|
||||
.can_conversation_be_shared(&conv_id)
|
||||
}
|
||||
};
|
||||
|
||||
// Only show share item if the conversation is shareable
|
||||
let share_item = if is_shareable {
|
||||
let share_item = if entry.capabilities.can_share {
|
||||
Some(
|
||||
MenuItemFields::new("Share conversation")
|
||||
.with_on_select_action(
|
||||
@@ -944,7 +1063,7 @@ impl TypedActionView for ConversationListView {
|
||||
|
||||
let fork_items: Option<[MenuItem<ConversationListViewAction>; 2]> =
|
||||
// Forking from a closed ambient agent conversation is not supported at this point.
|
||||
if !is_ambient_agent_conversation {
|
||||
if entry.capabilities.can_fork_locally {
|
||||
Some([
|
||||
MenuItemFields::new("Fork in new pane")
|
||||
.with_on_select_action(
|
||||
@@ -988,27 +1107,13 @@ impl TypedActionView for ConversationListView {
|
||||
ConversationListViewAction::OpenShareDialog { conversation_id } => {
|
||||
// Clear selection state when opening share dialog
|
||||
self.selected_index = None;
|
||||
|
||||
// Resolve the AIConversationId for the shareable object
|
||||
let ai_conversation_id: Option<AIConversationId> = match conversation_id {
|
||||
ConversationOrTaskId::TaskId(task_id) => {
|
||||
// For tasks, look up the associated conversation_id by server token
|
||||
if let Some(ConversationOrTask::Task(task)) =
|
||||
AgentConversationsModel::as_ref(ctx).get_task(task_id)
|
||||
{
|
||||
task.conversation_id.as_ref().and_then(|token_str| {
|
||||
let server_token = ServerConversationToken::new(token_str.clone());
|
||||
BlocklistAIHistoryModel::as_ref(ctx)
|
||||
.find_conversation_id_by_server_token(&server_token)
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
ConversationOrTaskId::ConversationId(conv_id) => Some(*conv_id),
|
||||
};
|
||||
|
||||
let Some(ai_conversation_id) = ai_conversation_id else {
|
||||
let Some(ai_conversation_id) = self
|
||||
.view_model
|
||||
.as_ref(ctx)
|
||||
.get_item_by_id(conversation_id, ctx)
|
||||
.filter(|entry| entry.capabilities.can_share)
|
||||
.and_then(|entry| entry.identity.local_conversation_id)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -1025,16 +1130,25 @@ impl TypedActionView for ConversationListView {
|
||||
ctx.notify();
|
||||
}
|
||||
ConversationListViewAction::DeleteFromOverflowMenu { conversation_id } => {
|
||||
let ConversationOrTaskId::ConversationId(ai_conversation_id) = conversation_id
|
||||
let Some(entry) = self
|
||||
.view_model
|
||||
.as_ref(ctx)
|
||||
.get_item_by_id(conversation_id, ctx)
|
||||
else {
|
||||
// For now, delete is only implemented for non-ambient conversations.
|
||||
return;
|
||||
};
|
||||
let Some(ai_conversation_id) = entry.identity.local_conversation_id else {
|
||||
return;
|
||||
};
|
||||
if !entry.capabilities.can_delete {
|
||||
return;
|
||||
};
|
||||
|
||||
let conversation =
|
||||
BlocklistAIHistoryModel::as_ref(ctx).conversation(ai_conversation_id);
|
||||
BlocklistAIHistoryModel::as_ref(ctx).conversation(&ai_conversation_id);
|
||||
|
||||
if let Some(conversation) = conversation {
|
||||
// Same gate as the deletion path above.
|
||||
if !conversation.status().is_done() && !conversation.is_empty() {
|
||||
let window_id = ctx.window_id();
|
||||
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
|
||||
@@ -1053,29 +1167,21 @@ impl TypedActionView for ConversationListView {
|
||||
|
||||
self.selected_index = None;
|
||||
|
||||
let item = self
|
||||
.view_model
|
||||
.as_ref(ctx)
|
||||
.get_item_by_id(conversation_id, ctx);
|
||||
let terminal_view_id = item
|
||||
.as_ref()
|
||||
.and_then(|item| item.navigation_data().and_then(|nav| nav.terminal_view_id));
|
||||
let conversation_title = item
|
||||
.as_ref()
|
||||
.map(|c| c.title(ctx).to_string())
|
||||
.unwrap_or_else(|| "Conversation".to_string());
|
||||
let terminal_view_id = ActiveAgentViewsModel::as_ref(ctx)
|
||||
.get_terminal_view_id_for_conversation(ai_conversation_id, ctx);
|
||||
let conversation_title = entry.display.title;
|
||||
ctx.emit(Event::ShowDeleteConfirmationDialog {
|
||||
conversation_id: *ai_conversation_id,
|
||||
conversation_id: ai_conversation_id,
|
||||
conversation_title,
|
||||
terminal_view_id,
|
||||
});
|
||||
}
|
||||
ConversationListViewAction::OpenItem { id } => {
|
||||
let model = self.view_model.as_ref(ctx);
|
||||
let Some(item) = model.get_item_by_id(id, ctx) else {
|
||||
return;
|
||||
};
|
||||
let Some(action) = item.get_open_action(None, ctx) else {
|
||||
let Some(action) = AgentConversationsModel::resolve_open_action(
|
||||
AgentConversationNavigationSubject::Entry(*id),
|
||||
None,
|
||||
ctx,
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -1139,20 +1245,35 @@ impl TypedActionView for ConversationListView {
|
||||
conversation_id,
|
||||
destination,
|
||||
} => {
|
||||
let ConversationOrTaskId::ConversationId(ai_conversation_id) = conversation_id
|
||||
let Some(ai_conversation_id) = self
|
||||
.view_model
|
||||
.as_ref(ctx)
|
||||
.get_item_by_id(conversation_id, ctx)
|
||||
.filter(|entry| entry.capabilities.can_fork_locally)
|
||||
.and_then(|entry| entry.identity.local_conversation_id)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
ctx.dispatch_typed_action(&WorkspaceAction::ForkAIConversation {
|
||||
conversation_id: *ai_conversation_id,
|
||||
conversation_id: ai_conversation_id,
|
||||
fork_from_exchange: None,
|
||||
summarize_after_fork: false,
|
||||
summarization_prompt: None,
|
||||
initial_prompt: None,
|
||||
initial_attachments: vec![],
|
||||
destination: *destination,
|
||||
});
|
||||
}
|
||||
ConversationListViewAction::StartRename { id } => {
|
||||
self.start_rename(*id, ctx);
|
||||
}
|
||||
ConversationListViewAction::FinishRename => {
|
||||
self.finish_rename(ctx);
|
||||
}
|
||||
ConversationListViewAction::CancelRename => {
|
||||
self.cancel_rename(ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1203,9 +1324,14 @@ impl View for ConversationListView {
|
||||
let list_items = self.list_items.clone();
|
||||
let overflow_menu = self.item_overflow_menu.clone();
|
||||
let overflow_menu_state = self.overflow_menu_state;
|
||||
let focused_conversation =
|
||||
ActiveAgentViewsModel::as_ref(app).get_focused_conversation(self.window_id);
|
||||
let focused_conversation = ActiveAgentViewsModel::as_ref(app)
|
||||
.get_focused_conversation(self.window_id)
|
||||
.map(AgentConversationEntryId::from);
|
||||
let sharing_dialog = self.sharing_dialog.clone();
|
||||
let rename_editor = self.rename_editor.clone();
|
||||
let renaming_conversation_id = self.renaming_conversation_id;
|
||||
let open_conversation_ids =
|
||||
ActiveAgentViewsModel::as_ref(app).get_all_open_conversation_ids(app);
|
||||
let share_dialog_open_for = self.share_dialog_open_for;
|
||||
let list_position_id = self.get_position_id();
|
||||
let tooltip_opens_right = TabSettings::as_ref(app)
|
||||
@@ -1245,16 +1371,35 @@ impl View for ConversationListView {
|
||||
app,
|
||||
))
|
||||
}
|
||||
ListItem::Conversation(entry) => {
|
||||
ListItem::Conversation { entry, section } => {
|
||||
let conversation = model.get_item_by_id(&entry.id, app)?;
|
||||
let is_focused_conversation = focused_conversation
|
||||
.is_some_and(|focused| entry.id == focused);
|
||||
let local_conversation_entry_id = conversation
|
||||
.identity
|
||||
.local_conversation_id
|
||||
.map(AgentConversationEntryId::Conversation);
|
||||
let is_focused_conversation =
|
||||
focused_conversation.is_some_and(|focused| {
|
||||
entry.id == focused
|
||||
|| local_conversation_entry_id == Some(focused)
|
||||
});
|
||||
let state = item_states.get(&entry.id)?;
|
||||
let highlight_ref = if entry.highlight_indices.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(&entry.highlight_indices)
|
||||
};
|
||||
let local_conversation_id =
|
||||
conversation.identity.local_conversation_id;
|
||||
let is_renaming = renaming_conversation_id.is_some()
|
||||
&& local_conversation_id == renaming_conversation_id;
|
||||
// Renaming is allowed for active conversations and for open
|
||||
// ones (an open conversation stays in the Past section until
|
||||
// a prompt is sent).
|
||||
let can_rename = local_conversation_id.is_some_and(|id| {
|
||||
*section == ConversationSection::Active
|
||||
|| open_conversation_ids
|
||||
.contains(&ConversationOrTaskId::ConversationId(id))
|
||||
});
|
||||
|
||||
let overflow_menu_display = match overflow_menu_state {
|
||||
Some(s) if s.conversation_id == entry.id => {
|
||||
@@ -1279,6 +1424,9 @@ impl View for ConversationListView {
|
||||
overflow_menu: &overflow_menu,
|
||||
overflow_menu_display,
|
||||
conversation_id: entry.id,
|
||||
is_renaming,
|
||||
can_rename,
|
||||
rename_editor: is_renaming.then_some(&rename_editor),
|
||||
sharing_dialog: &sharing_dialog,
|
||||
is_share_dialog_open,
|
||||
list_position_id: &list_position_id,
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
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 galaxyui::{AppContext, Entity, ModelContext, ModelHandle, SingletonEntity};
|
||||
|
||||
use crate::ai::agent_conversations_model::{
|
||||
AgentConversationEntry, AgentConversationEntryId, AgentConversationsModel,
|
||||
AgentConversationsModelEvent, AgentManagementFilters, ArtifactFilter, ConversationUpdateKind,
|
||||
CreatedOnFilter, CreatorFilter, OwnerFilter, SourceFilter, StatusFilter,
|
||||
};
|
||||
|
||||
pub struct ConversationListViewModelEvent;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ConversationEntry {
|
||||
pub id: ConversationOrTaskId,
|
||||
pub id: AgentConversationEntryId,
|
||||
pub highlight_indices: Vec<usize>,
|
||||
}
|
||||
|
||||
pub struct ConversationListViewModel {
|
||||
conversations_model: ModelHandle<AgentConversationsModel>,
|
||||
cached_conversation_or_task_ids: Vec<ConversationOrTaskId>,
|
||||
cached_entry_ids: Vec<AgentConversationEntryId>,
|
||||
filtered_items: Vec<ConversationEntry>,
|
||||
search_query: String,
|
||||
}
|
||||
@@ -30,20 +30,27 @@ 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| {
|
||||
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 => {
|
||||
| AgentConversationsModelEvent::TasksUpdated => {
|
||||
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);
|
||||
AgentConversationsModelEvent::ConversationUpdated { kind } => {
|
||||
if matches!(
|
||||
kind,
|
||||
ConversationUpdateKind::MetadataChanged
|
||||
| ConversationUpdateKind::TitleChanged
|
||||
) {
|
||||
me.refresh_cached_items(ctx);
|
||||
} else {
|
||||
ctx.emit(ConversationListViewModelEvent);
|
||||
}
|
||||
}
|
||||
// Artifact updates don't affect the conversation list
|
||||
AgentConversationsModelEvent::ConversationArtifactsUpdated { .. } => {}
|
||||
@@ -52,7 +59,7 @@ impl ConversationListViewModel {
|
||||
|
||||
let mut model = Self {
|
||||
conversations_model,
|
||||
cached_conversation_or_task_ids: Vec::new(),
|
||||
cached_entry_ids: Vec::new(),
|
||||
filtered_items: Vec::new(),
|
||||
search_query: String::new(),
|
||||
};
|
||||
@@ -62,15 +69,15 @@ impl ConversationListViewModel {
|
||||
|
||||
/// Rebuilds the cached list of IDs from the current task/conversation set.
|
||||
///
|
||||
/// The cache stores only `ConversationOrTaskId`s; per-item fields like
|
||||
/// The cache stores only `AgentConversationEntryId`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(
|
||||
self.cached_entry_ids = model
|
||||
.get_entries(
|
||||
&AgentManagementFilters {
|
||||
owners: OwnerFilter::PersonalOnly,
|
||||
status: StatusFilter::All,
|
||||
@@ -83,28 +90,9 @@ impl ConversationListViewModel {
|
||||
},
|
||||
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)
|
||||
}
|
||||
})
|
||||
.into_iter()
|
||||
.filter(|entry| entry.capabilities.can_open)
|
||||
.map(|entry| entry.id)
|
||||
.collect();
|
||||
|
||||
self.apply_search_filter(ctx);
|
||||
@@ -127,7 +115,7 @@ impl ConversationListViewModel {
|
||||
|
||||
if search_query.is_empty() {
|
||||
self.filtered_items = self
|
||||
.cached_conversation_or_task_ids
|
||||
.cached_entry_ids
|
||||
.iter()
|
||||
.map(|id| ConversationEntry {
|
||||
id: *id,
|
||||
@@ -136,27 +124,22 @@ impl ConversationListViewModel {
|
||||
.collect();
|
||||
} else {
|
||||
let mut matched_items: Vec<(i64, ConversationEntry)> = self
|
||||
.cached_conversation_or_task_ids
|
||||
.cached_entry_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)
|
||||
}
|
||||
}?;
|
||||
let item = conversations_model.get_entry_by_id(id, ctx)?;
|
||||
|
||||
match_indices_case_insensitive(&item.title(ctx), &search_query).map(|result| {
|
||||
(
|
||||
result.score,
|
||||
ConversationEntry {
|
||||
id: *id,
|
||||
highlight_indices: result.matched_indices,
|
||||
},
|
||||
)
|
||||
})
|
||||
match_indices_case_insensitive(&item.display.title, &search_query).map(
|
||||
|result| {
|
||||
(
|
||||
result.score,
|
||||
ConversationEntry {
|
||||
id: *id,
|
||||
highlight_indices: result.matched_indices,
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -167,7 +150,7 @@ impl ConversationListViewModel {
|
||||
|
||||
/// 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()
|
||||
self.cached_entry_ids.len()
|
||||
}
|
||||
|
||||
/// Returns the filtered items with their highlight indices.
|
||||
@@ -175,20 +158,17 @@ impl ConversationListViewModel {
|
||||
&self.filtered_items
|
||||
}
|
||||
|
||||
/// Look up a conversation or task by ID.
|
||||
pub fn get_item_by_id<'a>(
|
||||
/// Look up a normalized conversation entry by ID.
|
||||
pub fn get_item_by_id(
|
||||
&self,
|
||||
id: &ConversationOrTaskId,
|
||||
ctx: &'a AppContext,
|
||||
) -> Option<ConversationOrTask<'a>> {
|
||||
id: &AgentConversationEntryId,
|
||||
ctx: &AppContext,
|
||||
) -> Option<AgentConversationEntry> {
|
||||
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),
|
||||
}
|
||||
model.get_entry_by_id(id, ctx)
|
||||
}
|
||||
|
||||
pub fn current_ids(&self) -> impl Iterator<Item = &ConversationOrTaskId> {
|
||||
pub fn current_ids(&self) -> impl Iterator<Item = &AgentConversationEntryId> {
|
||||
self.filtered_items.iter().map(|item| &item.id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
use galaxy_cli::RecoveryMechanism;
|
||||
use galaxyui::{AppContext, SingletonEntity as _, ViewContext};
|
||||
|
||||
use crate::crash_recovery::CrashRecovery;
|
||||
|
||||
use super::{Workspace, WorkspaceBannerFields};
|
||||
use crate::crash_recovery::CrashRecovery;
|
||||
|
||||
pub fn banner_metadata(ctx: &AppContext) -> Option<WorkspaceBannerFields> {
|
||||
let crash_recovery = CrashRecovery::as_ref(ctx);
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
use pathfinder_color::ColorU;
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use serde_json::{json, Value};
|
||||
use strum_macros::{EnumDiscriminants, EnumIter};
|
||||
use galaxy_core::send_telemetry_from_ctx;
|
||||
use galaxy_core::telemetry::{EnablementState, TelemetryEvent, TelemetryEventDesc};
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use galaxy_core::ui::theme::Fill;
|
||||
use warpui::elements::{
|
||||
Align, Border, ChildAnchor, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment,
|
||||
DropShadow, Flex, FormattedTextElement, 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::auth::AuthStateProvider;
|
||||
use crate::settings_view::SettingsSection;
|
||||
use crate::ui_components::blended_colors;
|
||||
use crate::workspace::WorkspaceAction;
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
|
||||
const MODAL_WIDTH: f32 = 480.;
|
||||
const CORNER_RADIUS: f32 = 12.;
|
||||
const PANEL_PADDING: f32 = 24.;
|
||||
const CLOSE_BUTTON_DIAMETER: f32 = 20.;
|
||||
|
||||
const NOTICE_TITLE_TEXT: &str = "Warp is no longer providing inference on the free plan.";
|
||||
const NOTICE_BODY_TEXT: &str = "To keep using Warp's AI features, please upgrade to a paid plan, \
|
||||
bring your own API key or endpoint, or log in with your Grok subscription.";
|
||||
const NOTICE_BONUS_CREDITS_TEXT: &str = "If you have any unused bonus credits, AI will keep \
|
||||
working until these run out.";
|
||||
|
||||
const PROMPT_SUGGESTIONS_TITLE_TEXT: &str = "How to use AI features in Warp";
|
||||
const PROMPT_SUGGESTIONS_BODY_TEXT: &str = "To use AI features in Warp, subscribe to a paid plan, \
|
||||
add an API key (OpenAI, Anthropic, or Google), add a custom inference endpoint (OpenRouter, \
|
||||
LiteLLM), or log in using your SuperGrok subscription.";
|
||||
|
||||
/// Which surface opened the modal. Selects the copy and disambiguates telemetry;
|
||||
/// the layout and CTAs are identical across variants.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FreeAiRemovalModalVariant {
|
||||
/// One-time notice shown to Free users when Warp-provided AI is removed from their plan.
|
||||
Notice,
|
||||
/// Shown on demand when a Free user activates Prompt Suggestions while out of credits.
|
||||
PromptSuggestions,
|
||||
}
|
||||
|
||||
impl FreeAiRemovalModalVariant {
|
||||
fn title(self) -> &'static str {
|
||||
match self {
|
||||
Self::Notice => NOTICE_TITLE_TEXT,
|
||||
Self::PromptSuggestions => PROMPT_SUGGESTIONS_TITLE_TEXT,
|
||||
}
|
||||
}
|
||||
|
||||
fn body(self) -> &'static str {
|
||||
match self {
|
||||
Self::Notice => NOTICE_BODY_TEXT,
|
||||
Self::PromptSuggestions => PROMPT_SUGGESTIONS_BODY_TEXT,
|
||||
}
|
||||
}
|
||||
|
||||
/// Secondary note rendered under the body. The on-demand Prompt Suggestions
|
||||
/// variant only fires once the user is already out of credits, so the
|
||||
/// bonus-credits note doesn't apply there.
|
||||
fn secondary(self) -> Option<&'static str> {
|
||||
match self {
|
||||
Self::Notice => Some(NOTICE_BONUS_CREDITS_TEXT),
|
||||
Self::PromptSuggestions => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Notice => "notice",
|
||||
Self::PromptSuggestions => "prompt_suggestions",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init(app: &mut AppContext) {
|
||||
use warpui::keymap::macros::*;
|
||||
|
||||
app.register_fixed_bindings([FixedBinding::new(
|
||||
"escape",
|
||||
FreeAiRemovalModalAction::Close,
|
||||
id!("FreeAiRemovalModal"),
|
||||
)]);
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum FreeAiRemovalModalAction {
|
||||
Close,
|
||||
SetUpByok,
|
||||
Upgrade,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum FreeAiRemovalModalEvent {
|
||||
Close,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct StateHandles {
|
||||
close_button: MouseStateHandle,
|
||||
byok_button: MouseStateHandle,
|
||||
upgrade_button: MouseStateHandle,
|
||||
}
|
||||
|
||||
/// Notice shown to Free-plan users about the removal of Warp-provided AI. The
|
||||
/// `variant` selects the copy: a one-time rollout notice, or an on-demand prompt
|
||||
/// when a Free user activates a gated feature (e.g. Prompt Suggestions).
|
||||
pub struct FreeAiRemovalModal {
|
||||
variant: FreeAiRemovalModalVariant,
|
||||
state_handles: StateHandles,
|
||||
}
|
||||
|
||||
impl FreeAiRemovalModal {
|
||||
pub fn new(variant: FreeAiRemovalModalVariant, _ctx: &mut ViewContext<Self>) -> Self {
|
||||
Self {
|
||||
variant,
|
||||
state_handles: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn upgrade_url(ctx: &ViewContext<Self>) -> String {
|
||||
if let Some(team) = UserWorkspaces::as_ref(ctx).current_team() {
|
||||
UserWorkspaces::upgrade_link_for_team(team.uid)
|
||||
} else {
|
||||
let user_id = AuthStateProvider::as_ref(ctx)
|
||||
.get()
|
||||
.user_id()
|
||||
.unwrap_or_default();
|
||||
UserWorkspaces::upgrade_link(user_id)
|
||||
}
|
||||
}
|
||||
|
||||
fn render_buttons(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
let byok_button = appearance
|
||||
.ui_builder()
|
||||
.button(
|
||||
ButtonVariant::Secondary,
|
||||
self.state_handles.byok_button.clone(),
|
||||
)
|
||||
.with_style(UiComponentStyles {
|
||||
font_size: Some(14.),
|
||||
height: Some(32.),
|
||||
..Default::default()
|
||||
})
|
||||
.with_centered_text_label("Bring your own AI".to_string())
|
||||
.build()
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(FreeAiRemovalModalAction::SetUpByok);
|
||||
})
|
||||
.finish();
|
||||
|
||||
let upgrade_button = appearance
|
||||
.ui_builder()
|
||||
.button(
|
||||
ButtonVariant::Accent,
|
||||
self.state_handles.upgrade_button.clone(),
|
||||
)
|
||||
.with_style(UiComponentStyles {
|
||||
font_size: Some(14.),
|
||||
height: Some(32.),
|
||||
..Default::default()
|
||||
})
|
||||
.with_centered_text_label("View pricing".to_string())
|
||||
.build()
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(FreeAiRemovalModalAction::Upgrade);
|
||||
})
|
||||
.finish();
|
||||
|
||||
Flex::row()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_main_axis_alignment(MainAxisAlignment::End)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_spacing(8.)
|
||||
.with_child(byok_button)
|
||||
.with_child(upgrade_button)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_close_button(&self, appearance: &Appearance) -> Box<dyn Element> {
|
||||
appearance
|
||||
.ui_builder()
|
||||
.close_button(
|
||||
CLOSE_BUTTON_DIAMETER,
|
||||
self.state_handles.close_button.clone(),
|
||||
)
|
||||
.build()
|
||||
.on_click(|ctx, _, _| ctx.dispatch_typed_action(FreeAiRemovalModalAction::Close))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for FreeAiRemovalModal {
|
||||
type Event = FreeAiRemovalModalEvent;
|
||||
}
|
||||
|
||||
impl View for FreeAiRemovalModal {
|
||||
fn ui_name() -> &'static str {
|
||||
"FreeAiRemovalModal"
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
let bg = blended_colors::neutral_1(theme);
|
||||
let font_family = appearance.ui_font_family();
|
||||
|
||||
let title = FormattedTextElement::from_str(self.variant.title(), font_family, 18.)
|
||||
.with_color(blended_colors::text_main(theme, bg))
|
||||
.with_weight(Weight::Bold)
|
||||
.finish();
|
||||
|
||||
let body_color = blended_colors::text_sub(theme, bg);
|
||||
let body = FormattedTextElement::from_str(self.variant.body(), font_family, 14.)
|
||||
.with_color(body_color)
|
||||
.finish();
|
||||
|
||||
let mut content = Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
||||
.with_child(Container::new(title).with_margin_bottom(12.).finish());
|
||||
|
||||
// Tighten the gap below the body when a secondary note follows; otherwise
|
||||
// keep the full gap above the buttons.
|
||||
let body_margin_bottom = if self.variant.secondary().is_some() {
|
||||
8.
|
||||
} else {
|
||||
20.
|
||||
};
|
||||
content.add_child(
|
||||
Container::new(body)
|
||||
.with_margin_bottom(body_margin_bottom)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
if let Some(secondary_text) = self.variant.secondary() {
|
||||
let secondary = FormattedTextElement::from_str(secondary_text, font_family, 14.)
|
||||
.with_color(body_color)
|
||||
.finish();
|
||||
content.add_child(Container::new(secondary).with_margin_bottom(20.).finish());
|
||||
}
|
||||
|
||||
let content = content.with_child(self.render_buttons(appearance)).finish();
|
||||
|
||||
let mut modal = Stack::new();
|
||||
modal.add_child(
|
||||
Container::new(
|
||||
ConstrainedBox::new(content)
|
||||
.with_width(MODAL_WIDTH)
|
||||
.finish(),
|
||||
)
|
||||
.with_background_color(bg)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(CORNER_RADIUS)))
|
||||
.with_border(Border::all(1.).with_border_fill(theme.outline()))
|
||||
.with_uniform_padding(PANEL_PADDING)
|
||||
.with_drop_shadow(DropShadow::default())
|
||||
.finish(),
|
||||
);
|
||||
modal.add_positioned_child(
|
||||
self.render_close_button(appearance),
|
||||
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 FreeAiRemovalModal {
|
||||
type Action = FreeAiRemovalModalAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
FreeAiRemovalModalAction::Close => {
|
||||
send_telemetry_from_ctx!(
|
||||
FreeAiRemovalModalTelemetryEvent::Dismissed {
|
||||
variant: self.variant,
|
||||
},
|
||||
ctx
|
||||
);
|
||||
ctx.emit(FreeAiRemovalModalEvent::Close);
|
||||
}
|
||||
FreeAiRemovalModalAction::SetUpByok => {
|
||||
send_telemetry_from_ctx!(
|
||||
FreeAiRemovalModalTelemetryEvent::CtaClicked {
|
||||
variant: self.variant,
|
||||
cta: FreeAiRemovalModalCta::SetUpByok,
|
||||
},
|
||||
ctx
|
||||
);
|
||||
// Deferred so the close-driven refocus below doesn't steal focus from
|
||||
// the settings page this opens.
|
||||
ctx.dispatch_typed_action_deferred(WorkspaceAction::ShowSettingsPageWithSearch {
|
||||
search_query: "api".to_string(),
|
||||
section: Some(SettingsSection::WarpAgent),
|
||||
});
|
||||
ctx.emit(FreeAiRemovalModalEvent::Close);
|
||||
}
|
||||
FreeAiRemovalModalAction::Upgrade => {
|
||||
send_telemetry_from_ctx!(
|
||||
FreeAiRemovalModalTelemetryEvent::CtaClicked {
|
||||
variant: self.variant,
|
||||
cta: FreeAiRemovalModalCta::Upgrade,
|
||||
},
|
||||
ctx
|
||||
);
|
||||
let upgrade_url = Self::upgrade_url(ctx);
|
||||
ctx.open_url(&upgrade_url);
|
||||
ctx.emit(FreeAiRemovalModalEvent::Close);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum FreeAiRemovalModalCta {
|
||||
SetUpByok,
|
||||
Upgrade,
|
||||
}
|
||||
|
||||
impl FreeAiRemovalModalCta {
|
||||
fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::SetUpByok => "set_up_byok",
|
||||
Self::Upgrade => "upgrade",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, EnumDiscriminants)]
|
||||
#[strum_discriminants(derive(EnumIter))]
|
||||
pub enum FreeAiRemovalModalTelemetryEvent {
|
||||
Shown {
|
||||
variant: FreeAiRemovalModalVariant,
|
||||
},
|
||||
Dismissed {
|
||||
variant: FreeAiRemovalModalVariant,
|
||||
},
|
||||
CtaClicked {
|
||||
variant: FreeAiRemovalModalVariant,
|
||||
cta: FreeAiRemovalModalCta,
|
||||
},
|
||||
}
|
||||
|
||||
impl TelemetryEvent for FreeAiRemovalModalTelemetryEvent {
|
||||
fn name(&self) -> &'static str {
|
||||
FreeAiRemovalModalTelemetryEventDiscriminants::from(self).name()
|
||||
}
|
||||
|
||||
fn payload(&self) -> Option<Value> {
|
||||
match self {
|
||||
Self::Shown { variant } | Self::Dismissed { variant } => Some(json!({
|
||||
"variant": variant.as_str(),
|
||||
})),
|
||||
Self::CtaClicked { variant, cta } => Some(json!({
|
||||
"variant": variant.as_str(),
|
||||
"cta": cta.as_str(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
FreeAiRemovalModalTelemetryEventDiscriminants::from(self).description()
|
||||
}
|
||||
|
||||
fn enablement_state(&self) -> EnablementState {
|
||||
FreeAiRemovalModalTelemetryEventDiscriminants::from(self).enablement_state()
|
||||
}
|
||||
|
||||
fn contains_ugc(&self) -> bool {
|
||||
match self {
|
||||
Self::Shown { .. } | Self::Dismissed { .. } | Self::CtaClicked { .. } => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn event_descs() -> impl Iterator<Item = Box<dyn TelemetryEventDesc>> {
|
||||
galaxy_core::telemetry::enum_events::<Self>()
|
||||
}
|
||||
}
|
||||
|
||||
impl TelemetryEventDesc for FreeAiRemovalModalTelemetryEventDiscriminants {
|
||||
fn name(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Shown => "FreeAiRemovalModal.Shown",
|
||||
Self::Dismissed => "FreeAiRemovalModal.Dismissed",
|
||||
Self::CtaClicked => "FreeAiRemovalModal.CtaClicked",
|
||||
}
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Shown => "The free AI removal notice modal was shown to the user",
|
||||
Self::Dismissed => "The user dismissed the free AI removal notice modal",
|
||||
Self::CtaClicked => "The user clicked a CTA in the free AI removal notice modal",
|
||||
}
|
||||
}
|
||||
|
||||
fn enablement_state(&self) -> EnablementState {
|
||||
match self {
|
||||
Self::Shown | Self::Dismissed | Self::CtaClicked => EnablementState::Always,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
galaxy_core::register_telemetry_event!(FreeAiRemovalModalTelemetryEvent);
|
||||
@@ -1,10 +1,3 @@
|
||||
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 galaxy_core::send_telemetry_from_ctx;
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
@@ -27,6 +20,14 @@ use pathfinder_color::ColorU;
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use thousands::Separable;
|
||||
|
||||
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;
|
||||
|
||||
const BUTTON_DIAMETER: f32 = 20.;
|
||||
const MODAL_HEIGHT: f32 = 440.;
|
||||
const LEFT_PANEL_WIDTH: f32 = 360.;
|
||||
@@ -71,6 +72,7 @@ impl FreeTierLimitHitModal {
|
||||
ctx.emit(FreeTierLimitHitModalEvent::MaybeOpen);
|
||||
}
|
||||
AIRequestUsageModelEvent::RequestBonusRefunded { .. } => {}
|
||||
AIRequestUsageModelEvent::AmbientCreditsBannerDismissed => {}
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -1,8 +1,25 @@
|
||||
use warp_ripgrep::search::Submatch;
|
||||
use warp_util::local_or_remote_path::LocalOrRemotePath;
|
||||
|
||||
pub struct SearchConfig {
|
||||
pub use_regex: bool,
|
||||
pub use_case_sensitivity: bool,
|
||||
}
|
||||
|
||||
/// A single global search match: one line in one file, which may live on
|
||||
/// the local filesystem or on a remote host.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GlobalSearchMatch {
|
||||
pub location: LocalOrRemotePath,
|
||||
pub line_number: u32,
|
||||
/// Original 1-based character column in the file. This is captured
|
||||
/// before display-only whitespace trimming so opening a result navigates
|
||||
/// to the correct location.
|
||||
pub column_num: Option<usize>,
|
||||
pub line_text: String,
|
||||
pub submatches: Vec<Submatch>,
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_family = "wasm"), path = "model.rs")]
|
||||
#[cfg_attr(target_family = "wasm", path = "model_wasm.rs")]
|
||||
pub mod model;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use crate::workspace::view::global_search::view::GlobalSearchEvent;
|
||||
use crate::workspace::view::global_search::SearchConfig;
|
||||
use std::collections::HashMap;
|
||||
use std::future::Future;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::Result;
|
||||
use futures::StreamExt as _;
|
||||
use galaxy_ripgrep::search::{Match as RipgrepMatch, Submatch};
|
||||
@@ -8,15 +10,62 @@ use galaxyui::{Entity, ModelContext, ModelSpawner};
|
||||
use instant::Instant;
|
||||
use num_traits::SaturatingSub;
|
||||
use regex::escape;
|
||||
use std::path::PathBuf;
|
||||
use remote_server::manager::{HostRequestError, RemoteServerManager, RipgrepSearchParams};
|
||||
use remote_server::proto::RipgrepSearchSuccess;
|
||||
use remote_server::protocol::RequestId;
|
||||
use remote_server::HostId;
|
||||
use string_offset::ByteOffset;
|
||||
use galaxy_util::local_or_remote_path::LocalOrRemotePath;
|
||||
use galaxy_util::remote_path::RemotePath;
|
||||
use galaxy_util::standardized_path::StandardizedPath;
|
||||
use galaxyui::{Entity, ModelContext, ModelSpawner, SingletonEntity};
|
||||
|
||||
use crate::workspace::view::global_search::view::GlobalSearchEvent;
|
||||
use crate::workspace::view::global_search::{GlobalSearchMatch, SearchConfig};
|
||||
|
||||
const START_BATCH_AFTER_COUNT: usize = 50;
|
||||
const MAX_BATCH_SIZE: usize = 512;
|
||||
const MAX_BATCH_AGE_MS: u64 = 4000;
|
||||
|
||||
/// Client-requested cap on remote matches per host. The daemon clamps this
|
||||
/// to its own server-side cap; both bound the single-frame response size.
|
||||
const REMOTE_MAX_MATCH_COUNT: u32 = 5_000;
|
||||
|
||||
/// Aggregate state for one logical search across all of its sources
|
||||
/// (one local ripgrep run plus one remote request per searched host).
|
||||
struct ActiveSearch {
|
||||
search_id: u32,
|
||||
remaining_sources: usize,
|
||||
completed_sources: usize,
|
||||
local_source_failed: bool,
|
||||
remote_source_failures: usize,
|
||||
total_match_count: usize,
|
||||
/// True when any remote source hit the server-side match cap.
|
||||
capped: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum SearchSource {
|
||||
Local,
|
||||
Remote,
|
||||
}
|
||||
|
||||
/// Result of one search source (the local ripgrep run, or one remote
|
||||
/// host's request) that ran to completion.
|
||||
struct SourceResult {
|
||||
match_count: usize,
|
||||
capped: bool,
|
||||
}
|
||||
|
||||
pub struct GlobalSearch {
|
||||
search_handle: Option<SpawnedFutureHandle>,
|
||||
/// Spawned local/remote search tasks for the current search.
|
||||
search_handles: Vec<SpawnedFutureHandle>,
|
||||
/// Request ids of remote searches started for the current search, so
|
||||
/// they can be aborted daemon-side when the query changes. May contain
|
||||
/// ids of already-resolved requests; aborting those is a no-op.
|
||||
in_flight_remote_requests: Vec<RequestId>,
|
||||
/// Aggregate completion state for the current search.
|
||||
active_search: Option<ActiveSearch>,
|
||||
// track the search ID so that we only show results for the current search
|
||||
next_search_id: u32,
|
||||
}
|
||||
@@ -28,7 +77,7 @@ impl Entity for GlobalSearch {
|
||||
async fn flush_batch(
|
||||
spawner: &ModelSpawner<GlobalSearch>,
|
||||
search_id: u32,
|
||||
batch: &mut Vec<RipgrepMatch>,
|
||||
batch: &mut Vec<GlobalSearchMatch>,
|
||||
) {
|
||||
if batch.is_empty() {
|
||||
return;
|
||||
@@ -46,35 +95,47 @@ async fn flush_batch(
|
||||
impl GlobalSearch {
|
||||
pub fn new() -> Self {
|
||||
GlobalSearch {
|
||||
search_handle: None,
|
||||
search_handles: Vec::new(),
|
||||
in_flight_remote_requests: Vec::new(),
|
||||
active_search: None,
|
||||
next_search_id: 1,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn abort_search(&mut self) {
|
||||
if let Some(handle) = self.search_handle.take() {
|
||||
pub fn abort_search(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
for handle in self.search_handles.drain(..) {
|
||||
handle.abort();
|
||||
}
|
||||
self.active_search = None;
|
||||
|
||||
// Cancel in-flight remote searches daemon-side as well: queries
|
||||
// change on every debounced edit, so without this the daemon piles
|
||||
// up wasted ripgrep runs.
|
||||
let request_ids = std::mem::take(&mut self.in_flight_remote_requests);
|
||||
if !request_ids.is_empty() {
|
||||
RemoteServerManager::handle(ctx).update(ctx, |manager, _| {
|
||||
for request_id in &request_ids {
|
||||
manager.abort_host_request(request_id);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run_search(
|
||||
&mut self,
|
||||
pattern: String,
|
||||
roots: Vec<PathBuf>,
|
||||
roots: Vec<LocalOrRemotePath>,
|
||||
search_config: SearchConfig,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
if let Some(handle) = self.search_handle.take() {
|
||||
if !self.search_handles.is_empty() {
|
||||
log::info!("GlobalSearch: aborting previous search");
|
||||
handle.abort();
|
||||
}
|
||||
self.abort_search(ctx);
|
||||
|
||||
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 {
|
||||
@@ -83,36 +144,263 @@ impl GlobalSearch {
|
||||
let ignore_case = !search_config.use_case_sensitivity;
|
||||
let multiline = effective_pattern.contains('\n');
|
||||
|
||||
let handle = ctx.spawn(
|
||||
// Split roots into the local filesystem source and one remote
|
||||
// source per host.
|
||||
let mut local_roots: Vec<PathBuf> = Vec::new();
|
||||
let mut remote_roots: HashMap<HostId, Vec<StandardizedPath>> = HashMap::new();
|
||||
for root in roots {
|
||||
match root {
|
||||
LocalOrRemotePath::Local(path) => local_roots.push(path),
|
||||
LocalOrRemotePath::Remote(remote) => {
|
||||
remote_roots
|
||||
.entry(remote.host_id)
|
||||
.or_default()
|
||||
.push(remote.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let remote_host_count = remote_roots.len();
|
||||
ctx.emit(GlobalSearchEvent::Started {
|
||||
search_id,
|
||||
remote_host_count,
|
||||
});
|
||||
let source_count = usize::from(!local_roots.is_empty()) + remote_roots.len();
|
||||
if source_count == 0 {
|
||||
ctx.emit(GlobalSearchEvent::Completed {
|
||||
search_id,
|
||||
total_match_count: 0,
|
||||
capped: false,
|
||||
local_source_failed: false,
|
||||
remote_source_failures: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
self.active_search = Some(ActiveSearch {
|
||||
search_id,
|
||||
remaining_sources: source_count,
|
||||
completed_sources: 0,
|
||||
local_source_failed: false,
|
||||
remote_source_failures: 0,
|
||||
total_match_count: 0,
|
||||
capped: false,
|
||||
});
|
||||
|
||||
if !local_roots.is_empty() {
|
||||
self.spawn_local_search(
|
||||
search_id,
|
||||
effective_pattern.clone(),
|
||||
local_roots,
|
||||
ignore_case,
|
||||
multiline,
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
|
||||
for (host_id, paths) in remote_roots {
|
||||
let params = RipgrepSearchParams {
|
||||
pattern: effective_pattern.clone(),
|
||||
roots: paths,
|
||||
ignore_case,
|
||||
multiline,
|
||||
max_matches: REMOTE_MAX_MATCH_COUNT,
|
||||
};
|
||||
self.spawn_remote_search(search_id, host_id, params, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_local_search(
|
||||
&mut self,
|
||||
search_id: u32,
|
||||
pattern: String,
|
||||
roots: Vec<PathBuf>,
|
||||
ignore_case: bool,
|
||||
multiline: bool,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let spawner = ctx.spawner();
|
||||
self.spawn_source(
|
||||
search_id,
|
||||
SearchSource::Local,
|
||||
async move {
|
||||
Self::run_galaxy_ripgrep_cli(
|
||||
let result = Self::run_galaxy_ripgrep_cli(
|
||||
search_id,
|
||||
effective_pattern,
|
||||
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: galaxy_ripgrep CLI search failed or aborted: {err}");
|
||||
ctx.emit(GlobalSearchEvent::Failed {
|
||||
search_id,
|
||||
error: "Global search failed.".to_string(),
|
||||
});
|
||||
.await;
|
||||
match result {
|
||||
Ok(match_count) => Some(SourceResult {
|
||||
match_count,
|
||||
capped: false,
|
||||
}),
|
||||
Err(err) => {
|
||||
log::error!(
|
||||
"GlobalSearch: galaxy_ripgrep CLI search failed or aborted: {err}"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
|
||||
self.search_handle = Some(handle);
|
||||
fn spawn_remote_search(
|
||||
&mut self,
|
||||
search_id: u32,
|
||||
host_id: HostId,
|
||||
params: RipgrepSearchParams,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let pending = RemoteServerManager::handle(ctx).update(ctx, |manager, _| {
|
||||
manager.start_ripgrep_search(&host_id, params)
|
||||
});
|
||||
self.in_flight_remote_requests
|
||||
.push(pending.request_id().clone());
|
||||
let spawner = ctx.spawner();
|
||||
self.spawn_source(
|
||||
search_id,
|
||||
SearchSource::Remote,
|
||||
async move {
|
||||
match pending.result().await {
|
||||
Ok(success) => {
|
||||
let capped = success.capped;
|
||||
let mut items = Self::remote_matches_to_global(&host_id, success);
|
||||
let match_count = items.len();
|
||||
flush_batch(&spawner, search_id, &mut items).await;
|
||||
Some(SourceResult {
|
||||
match_count,
|
||||
capped,
|
||||
})
|
||||
}
|
||||
// An abort is initiated by a newer search (or a reset),
|
||||
// which already replaced the aggregate state; the stale
|
||||
// search-id guard drops this outcome regardless.
|
||||
Err(HostRequestError::Aborted) => None,
|
||||
Err(err) => {
|
||||
log::warn!("GlobalSearch: remote search failed for host {host_id}: {err}");
|
||||
None
|
||||
}
|
||||
}
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
|
||||
/// Spawns one search source (the local ripgrep run, or one remote
|
||||
/// host's request) and routes its outcome into the shared completion
|
||||
/// accounting. Sources emit their matches via `Progress`/`ProgressBatch`
|
||||
/// while running and log their own failures.
|
||||
fn spawn_source(
|
||||
&mut self,
|
||||
search_id: u32,
|
||||
source_kind: SearchSource,
|
||||
source: impl Future<Output = Option<SourceResult>> + Send + 'static,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let task = ctx.spawn(source, move |me, outcome, ctx| {
|
||||
me.handle_source_completed(search_id, source_kind, outcome, ctx);
|
||||
});
|
||||
self.search_handles.push(task);
|
||||
}
|
||||
|
||||
/// Converts a remote search response into per-submatch result rows,
|
||||
/// attaching the originating host to each match location.
|
||||
fn remote_matches_to_global(
|
||||
host_id: &HostId,
|
||||
success: RipgrepSearchSuccess,
|
||||
) -> Vec<GlobalSearchMatch> {
|
||||
success
|
||||
.matches
|
||||
.into_iter()
|
||||
.filter_map(|m| {
|
||||
let path = match StandardizedPath::try_new(&m.file_path) {
|
||||
Ok(path) => path,
|
||||
Err(err) => {
|
||||
log::warn!("GlobalSearch: dropping remote match with invalid path: {err}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let submatches = m
|
||||
.submatches
|
||||
.into_iter()
|
||||
.map(|s| Submatch {
|
||||
byte_start: ByteOffset::from(s.byte_start as usize),
|
||||
byte_end: ByteOffset::from(s.byte_end as usize),
|
||||
})
|
||||
.collect();
|
||||
Some(GlobalSearchMatch {
|
||||
location: LocalOrRemotePath::Remote(RemotePath::new(host_id.clone(), path)),
|
||||
line_number: m.line_number,
|
||||
column_num: None,
|
||||
line_text: m.line_text,
|
||||
submatches,
|
||||
})
|
||||
})
|
||||
.flat_map(Self::expand_submatches)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Records the completion of one search source (`None` when the source
|
||||
/// failed; the source already logged the failure). When all sources have
|
||||
/// finished, emits `Completed` (or `Failed` when every source failed).
|
||||
fn handle_source_completed(
|
||||
&mut self,
|
||||
search_id: u32,
|
||||
source_kind: SearchSource,
|
||||
outcome: Option<SourceResult>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let Some(active) = self.active_search.as_mut() else {
|
||||
return;
|
||||
};
|
||||
if active.search_id != search_id {
|
||||
return;
|
||||
}
|
||||
|
||||
match outcome {
|
||||
Some(SourceResult {
|
||||
match_count,
|
||||
capped,
|
||||
}) => {
|
||||
active.completed_sources += 1;
|
||||
active.total_match_count += match_count;
|
||||
active.capped |= capped;
|
||||
}
|
||||
None => match source_kind {
|
||||
SearchSource::Local => active.local_source_failed = true,
|
||||
SearchSource::Remote => active.remote_source_failures += 1,
|
||||
},
|
||||
}
|
||||
|
||||
active.remaining_sources = active.remaining_sources.saturating_sub(1);
|
||||
if active.remaining_sources > 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let active = self
|
||||
.active_search
|
||||
.take()
|
||||
.expect("active search was checked above");
|
||||
if active.completed_sources == 0 {
|
||||
ctx.emit(GlobalSearchEvent::Failed {
|
||||
search_id,
|
||||
error: "Global search failed.".to_string(),
|
||||
});
|
||||
} else {
|
||||
ctx.emit(GlobalSearchEvent::Completed {
|
||||
search_id,
|
||||
total_match_count: active.total_match_count,
|
||||
capped: active.capped,
|
||||
local_source_failed: active.local_source_failed,
|
||||
remote_source_failures: active.remote_source_failures,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_galaxy_ripgrep_cli(
|
||||
@@ -135,14 +423,14 @@ impl GlobalSearch {
|
||||
|
||||
let mut total_match_count: usize = 0;
|
||||
let mut num_unbatched_emitted: usize = 0;
|
||||
let mut batch: Vec<RipgrepMatch> = Vec::new();
|
||||
let mut batch: Vec<GlobalSearchMatch> = 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) {
|
||||
for per_submatch in Self::expand_submatches(Self::local_match_to_global(raw_match)) {
|
||||
total_match_count += 1;
|
||||
|
||||
if num_unbatched_emitted < START_BATCH_AFTER_COUNT {
|
||||
@@ -178,40 +466,65 @@ impl GlobalSearch {
|
||||
Ok(total_match_count)
|
||||
}
|
||||
|
||||
/// Expand a single ripgrep match (which may contain multiple submatches
|
||||
fn local_match_to_global(m: RipgrepMatch) -> GlobalSearchMatch {
|
||||
GlobalSearchMatch {
|
||||
location: LocalOrRemotePath::Local(m.file_path),
|
||||
line_number: m.line_number,
|
||||
column_num: None,
|
||||
line_text: m.line_text,
|
||||
submatches: m.submatches,
|
||||
}
|
||||
}
|
||||
|
||||
/// Expand a single 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> {
|
||||
fn expand_submatches(m: GlobalSearchMatch) -> Vec<GlobalSearchMatch> {
|
||||
if m.submatches.len() <= 1 {
|
||||
let submatch = m.submatches.into_iter().next();
|
||||
let column_num = Self::column_from_submatch(&m.line_text, submatch.as_ref());
|
||||
return vec![Self::trim_leading_whitespace_for_submatch(
|
||||
&m.line_text,
|
||||
m.file_path,
|
||||
m.location,
|
||||
m.line_number,
|
||||
m.submatches.into_iter().next(),
|
||||
column_num,
|
||||
submatch,
|
||||
)];
|
||||
}
|
||||
|
||||
m.submatches
|
||||
.into_iter()
|
||||
.map(|sub| {
|
||||
let column_num = Self::column_from_submatch(&m.line_text, Some(&sub));
|
||||
Self::trim_leading_whitespace_for_submatch(
|
||||
&m.line_text,
|
||||
m.file_path.clone(),
|
||||
m.location.clone(),
|
||||
m.line_number,
|
||||
column_num,
|
||||
Some(sub),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Returns the original 1-based character column for a submatch.
|
||||
fn column_from_submatch(line_text: &str, submatch: Option<&Submatch>) -> Option<usize> {
|
||||
let byte_start = submatch?.byte_start.as_usize();
|
||||
if byte_start > line_text.len() || !line_text.is_char_boundary(byte_start) {
|
||||
return None;
|
||||
}
|
||||
Some(line_text[..byte_start].chars().count() + 1)
|
||||
}
|
||||
|
||||
/// 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,
|
||||
location: LocalOrRemotePath,
|
||||
line_number: u32,
|
||||
column_num: Option<usize>,
|
||||
submatch: Option<Submatch>,
|
||||
) -> RipgrepMatch {
|
||||
) -> GlobalSearchMatch {
|
||||
let submatch_start = submatch
|
||||
.as_ref()
|
||||
.map(|s| s.byte_start)
|
||||
@@ -239,9 +552,10 @@ impl GlobalSearch {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
RipgrepMatch {
|
||||
file_path,
|
||||
GlobalSearchMatch {
|
||||
location,
|
||||
line_number,
|
||||
column_num,
|
||||
line_text: trimmed_line,
|
||||
submatches,
|
||||
}
|
||||
@@ -253,3 +567,7 @@ impl Default for GlobalSearch {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "model_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
use remote_server::proto::{RipgrepSearchMatch, RipgrepSearchSubmatch, RipgrepSearchSuccess};
|
||||
use remote_server::HostId;
|
||||
use warp_util::local_or_remote_path::LocalOrRemotePath;
|
||||
|
||||
use super::GlobalSearch;
|
||||
|
||||
fn host() -> HostId {
|
||||
HostId::new("test-host".to_string())
|
||||
}
|
||||
|
||||
fn proto_match(
|
||||
path: &str,
|
||||
line_number: u32,
|
||||
line_text: &str,
|
||||
submatches: Vec<(u64, u64)>,
|
||||
) -> RipgrepSearchMatch {
|
||||
RipgrepSearchMatch {
|
||||
file_path: path.to_string(),
|
||||
line_number,
|
||||
line_text: line_text.to_string(),
|
||||
submatches: submatches
|
||||
.into_iter()
|
||||
.map(|(byte_start, byte_end)| RipgrepSearchSubmatch {
|
||||
byte_start,
|
||||
byte_end,
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_matches_become_remote_locations_on_the_host() {
|
||||
let success = RipgrepSearchSuccess {
|
||||
matches: vec![proto_match(
|
||||
"/repo/src/main.rs",
|
||||
7,
|
||||
"fn main() {}",
|
||||
vec![(3, 7)],
|
||||
)],
|
||||
capped: false,
|
||||
};
|
||||
|
||||
let results = GlobalSearch::remote_matches_to_global(&host(), success);
|
||||
|
||||
assert_eq!(results.len(), 1);
|
||||
match &results[0].location {
|
||||
LocalOrRemotePath::Remote(remote) => {
|
||||
assert_eq!(remote.host_id, host());
|
||||
assert_eq!(remote.path.as_str(), "/repo/src/main.rs");
|
||||
}
|
||||
LocalOrRemotePath::Local(_) => panic!("expected a remote location"),
|
||||
}
|
||||
assert_eq!(results[0].line_number, 7);
|
||||
assert_eq!(results[0].column_num, Some(4));
|
||||
assert_eq!(results[0].line_text, "fn main() {}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_matches_expand_one_row_per_submatch() {
|
||||
let success = RipgrepSearchSuccess {
|
||||
matches: vec![proto_match(
|
||||
"/repo/a.rs",
|
||||
1,
|
||||
"foo foo",
|
||||
vec![(0, 3), (4, 7)],
|
||||
)],
|
||||
capped: false,
|
||||
};
|
||||
|
||||
let results = GlobalSearch::remote_matches_to_global(&host(), success);
|
||||
|
||||
assert_eq!(results.len(), 2);
|
||||
assert!(results.iter().all(|m| m.submatches.len() == 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_matches_with_invalid_paths_are_dropped() {
|
||||
let success = RipgrepSearchSuccess {
|
||||
matches: vec![
|
||||
proto_match("relative/path.rs", 1, "x", vec![(0, 1)]),
|
||||
proto_match("/repo/ok.rs", 2, "x", vec![(0, 1)]),
|
||||
],
|
||||
capped: false,
|
||||
};
|
||||
|
||||
let results = GlobalSearch::remote_matches_to_global(&host(), success);
|
||||
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].location.display_path(), "/repo/ok.rs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_match_leading_whitespace_is_trimmed_per_submatch() {
|
||||
// Leading whitespace before the submatch is trimmed and offsets adjusted,
|
||||
// matching local search behavior.
|
||||
let success = RipgrepSearchSuccess {
|
||||
matches: vec![proto_match("/repo/a.rs", 1, " foo", vec![(4, 7)])],
|
||||
capped: false,
|
||||
};
|
||||
|
||||
let results = GlobalSearch::remote_matches_to_global(&host(), success);
|
||||
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].line_text, "foo");
|
||||
assert_eq!(results[0].column_num, Some(5));
|
||||
assert_eq!(results[0].submatches[0].byte_start.as_usize(), 0);
|
||||
assert_eq!(results[0].submatches[0].byte_end.as_usize(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_match_column_counts_characters_not_bytes() {
|
||||
let success = RipgrepSearchSuccess {
|
||||
matches: vec![proto_match("/repo/a.rs", 1, "€foo", vec![(3, 6)])],
|
||||
capped: false,
|
||||
};
|
||||
|
||||
let results = GlobalSearch::remote_matches_to_global(&host(), success);
|
||||
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].column_num, Some(2));
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
use std::path::PathBuf;
|
||||
use warp_util::local_or_remote_path::LocalOrRemotePath;
|
||||
use warpui::{Entity, ModelContext};
|
||||
|
||||
use crate::workspace::view::global_search::view::GlobalSearchEvent;
|
||||
use crate::workspace::view::global_search::SearchConfig;
|
||||
use galaxyui::{Entity, ModelContext};
|
||||
|
||||
pub struct GlobalSearch {}
|
||||
|
||||
@@ -15,12 +15,12 @@ impl GlobalSearch {
|
||||
GlobalSearch {}
|
||||
}
|
||||
|
||||
pub fn abort_search(&mut self) {}
|
||||
pub fn abort_search(&mut self, _ctx: &mut ModelContext<Self>) {}
|
||||
|
||||
pub fn run_search(
|
||||
&mut self,
|
||||
_pattern: String,
|
||||
_root: Vec<PathBuf>,
|
||||
_roots: Vec<LocalOrRemotePath>,
|
||||
_search_config: SearchConfig,
|
||||
_ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
|
||||
@@ -5,34 +5,22 @@ use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::coding_panel_enablement_state::CodingPanelEnablementState;
|
||||
|
||||
use async_channel::Sender;
|
||||
use galaxy_editor::editor::NavigationKey;
|
||||
use galaxy_ripgrep::search::{Match as RipgrepMatch, Submatch};
|
||||
use instant::Instant;
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use remote_server::HostId;
|
||||
use string_offset::{ByteOffset, CharCounter};
|
||||
|
||||
use crate::code::icon_from_file_path;
|
||||
use crate::debounce::debounce;
|
||||
use crate::editor::{
|
||||
EditorOptions, EditorView, Event as EditorEvent, InteractionState,
|
||||
PropagateAndNoOpNavigationKeys, PropagateHorizontalNavigationKeys, TextOptions,
|
||||
};
|
||||
use crate::search::ItemHighlightState as SearchHighlightState;
|
||||
use crate::ui_components::blended_colors;
|
||||
use crate::ui_components::icons::Icon as UiIcon;
|
||||
use crate::ui_components::item_highlight::{ImageOrIcon, ItemHighlightState};
|
||||
use crate::ui_components::render_file_search_row::{render_file_search_row, FileSearchRowOptions};
|
||||
use crate::view_components::action_button::{ActionButton, ButtonSize, NakedTheme};
|
||||
use crate::workspace::view::global_search::model::GlobalSearch;
|
||||
use crate::workspace::view::global_search::SearchConfig;
|
||||
use crate::TelemetryEvent;
|
||||
use galaxy_core::r#async::debounce;
|
||||
use galaxy_core::send_telemetry_from_ctx;
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use galaxy_core::ui::theme::color::internal_colors;
|
||||
use galaxy_core::ui::theme::{AnsiColorIdentifier, Fill as ThemeFill};
|
||||
use galaxy_core::ui::Icon;
|
||||
use galaxy_editor::editor::NavigationKey;
|
||||
use galaxy_ripgrep::search::Submatch;
|
||||
use galaxy_util::local_or_remote_path::LocalOrRemotePath;
|
||||
use galaxy_util::remote_path::RemotePath;
|
||||
use galaxy_util::standardized_path::StandardizedPath;
|
||||
use galaxyui::elements::{
|
||||
Border, ChildAnchor, ChildView, Clipped, ConstrainedBox, Container, CornerRadius,
|
||||
CrossAxisAlignment, DispatchEventResult, Empty, EventHandler, Fill, Flex, FormattedTextElement,
|
||||
@@ -52,6 +40,23 @@ use galaxyui::{
|
||||
ViewHandle, WeakViewHandle,
|
||||
};
|
||||
|
||||
use crate::code::icon_from_file_path;
|
||||
use crate::coding_panel_enablement_state::CodingPanelEnablementState;
|
||||
use crate::editor::{
|
||||
EditorOptions, EditorView, Event as EditorEvent, InteractionState,
|
||||
PropagateAndNoOpNavigationKeys, PropagateHorizontalNavigationKeys, TextOptions,
|
||||
};
|
||||
use crate::search::ItemHighlightState as SearchHighlightState;
|
||||
use crate::ui_components::blended_colors;
|
||||
use crate::ui_components::icons::Icon as UiIcon;
|
||||
use crate::ui_components::item_highlight::{ImageOrIcon, ItemHighlightState};
|
||||
use crate::ui_components::render_file_search_row::{render_file_search_row, FileSearchRowOptions};
|
||||
use crate::util::path::{display_name_with_host, display_path_with_host};
|
||||
use crate::view_components::action_button::{ActionButton, ButtonSize, NakedTheme};
|
||||
use crate::workspace::view::global_search::model::GlobalSearch;
|
||||
use crate::workspace::view::global_search::{GlobalSearchMatch, SearchConfig};
|
||||
use crate::TelemetryEvent;
|
||||
|
||||
const BORDER_RADIUS: f32 = 6.;
|
||||
const BORDER_WIDTH: f32 = 1.;
|
||||
const DO_NOT_TRUNCATE_CHAR_COUNT: usize = 40;
|
||||
@@ -77,19 +82,19 @@ enum FocusMode {
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum GlobalSearchAction {
|
||||
SelectRow {
|
||||
directory_path: PathBuf,
|
||||
file_path: PathBuf,
|
||||
directory_path: LocalOrRemotePath,
|
||||
file_path: LocalOrRemotePath,
|
||||
match_index: Option<usize>,
|
||||
},
|
||||
ToggleFileCollapsed {
|
||||
directory_path: PathBuf,
|
||||
file_path: PathBuf,
|
||||
directory_path: LocalOrRemotePath,
|
||||
file_path: LocalOrRemotePath,
|
||||
},
|
||||
ToggleDirectoryCollapsed {
|
||||
directory_path: PathBuf,
|
||||
directory_path: LocalOrRemotePath,
|
||||
},
|
||||
OpenMatch {
|
||||
path: PathBuf,
|
||||
location: LocalOrRemotePath,
|
||||
line_number: u32,
|
||||
column_num: Option<usize>,
|
||||
},
|
||||
@@ -108,18 +113,27 @@ pub enum GlobalSearchAction {
|
||||
pub enum GlobalSearchEvent {
|
||||
Started {
|
||||
search_id: u32,
|
||||
remote_host_count: usize,
|
||||
},
|
||||
Progress {
|
||||
search_id: u32,
|
||||
result: RipgrepMatch,
|
||||
result: GlobalSearchMatch,
|
||||
},
|
||||
ProgressBatch {
|
||||
search_id: u32,
|
||||
items: Vec<RipgrepMatch>,
|
||||
items: Vec<GlobalSearchMatch>,
|
||||
},
|
||||
Completed {
|
||||
search_id: u32,
|
||||
total_match_count: usize,
|
||||
/// True when a remote source hit the server-side match cap.
|
||||
capped: bool,
|
||||
/// Whether the local search source failed while another source
|
||||
/// completed. Results from the surviving sources remain valid.
|
||||
local_source_failed: bool,
|
||||
/// Number of remote host search sources that failed while another
|
||||
/// source completed. Results from the surviving sources remain valid.
|
||||
remote_source_failures: usize,
|
||||
},
|
||||
Failed {
|
||||
search_id: u32,
|
||||
@@ -130,7 +144,7 @@ pub enum GlobalSearchEvent {
|
||||
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
|
||||
pub enum Event {
|
||||
OpenMatch {
|
||||
path: PathBuf,
|
||||
location: LocalOrRemotePath,
|
||||
line_number: u32,
|
||||
column_num: Option<usize>,
|
||||
},
|
||||
@@ -167,14 +181,14 @@ enum RowIndexType {
|
||||
|
||||
/// A root directory containing matched files.
|
||||
struct DirectoryEntry {
|
||||
path: PathBuf,
|
||||
path: LocalOrRemotePath,
|
||||
is_collapsed: bool,
|
||||
mouse_state: MouseStateHandle,
|
||||
matched_paths: MatchedPaths,
|
||||
}
|
||||
|
||||
impl DirectoryEntry {
|
||||
fn new(path: PathBuf) -> Self {
|
||||
fn new(path: LocalOrRemotePath) -> Self {
|
||||
Self {
|
||||
path,
|
||||
is_collapsed: false,
|
||||
@@ -207,7 +221,7 @@ impl DirectoryEntry {
|
||||
/// Collection of matched files within a directory.
|
||||
struct MatchedPaths {
|
||||
paths: Vec<MatchedPath>,
|
||||
index_by_path: HashMap<PathBuf, usize>,
|
||||
index_by_path: HashMap<LocalOrRemotePath, usize>,
|
||||
}
|
||||
|
||||
impl MatchedPaths {
|
||||
@@ -223,21 +237,21 @@ impl MatchedPaths {
|
||||
self.paths.iter().map(|p| p.visible_count()).sum()
|
||||
}
|
||||
|
||||
/// Gets or creates a MatchedPath entry for the given file path.
|
||||
/// Gets or creates a MatchedPath entry for the given file location.
|
||||
/// Returns a mutable reference to the entry and its index.
|
||||
fn get_or_create(&mut self, path: &Path) -> (&mut MatchedPath, usize) {
|
||||
fn get_or_create(&mut self, path: &LocalOrRemotePath) -> (&mut MatchedPath, usize) {
|
||||
if let Some(&index) = self.index_by_path.get(path) {
|
||||
(&mut self.paths[index], index)
|
||||
} else {
|
||||
let index = self.paths.len();
|
||||
self.paths.push(MatchedPath::new(path.to_path_buf()));
|
||||
self.index_by_path.insert(path.to_path_buf(), index);
|
||||
self.paths.push(MatchedPath::new(path.clone()));
|
||||
self.index_by_path.insert(path.clone(), index);
|
||||
(&mut self.paths[index], index)
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets a mutable MatchedPath entry by file path.
|
||||
fn get_mut(&mut self, path: &Path) -> Option<&mut MatchedPath> {
|
||||
/// Gets a mutable MatchedPath entry by file location.
|
||||
fn get_mut(&mut self, path: &LocalOrRemotePath) -> Option<&mut MatchedPath> {
|
||||
self.index_by_path
|
||||
.get(path)
|
||||
.copied()
|
||||
@@ -247,14 +261,14 @@ impl MatchedPaths {
|
||||
|
||||
/// A file containing matches.
|
||||
struct MatchedPath {
|
||||
path: PathBuf,
|
||||
path: LocalOrRemotePath,
|
||||
is_collapsed: bool,
|
||||
mouse_state: MouseStateHandle,
|
||||
matches: Vec<Match>,
|
||||
}
|
||||
|
||||
impl MatchedPath {
|
||||
fn new(path: PathBuf) -> Self {
|
||||
fn new(path: LocalOrRemotePath) -> Self {
|
||||
Self {
|
||||
path,
|
||||
is_collapsed: false,
|
||||
@@ -279,15 +293,22 @@ impl MatchedPath {
|
||||
struct Match {
|
||||
line_text: String,
|
||||
line_number: u32,
|
||||
column_num: Option<usize>,
|
||||
submatches: Vec<Submatch>,
|
||||
mouse_state: MouseStateHandle,
|
||||
}
|
||||
|
||||
impl Match {
|
||||
fn new(line_text: String, line_number: u32, submatches: Vec<Submatch>) -> Self {
|
||||
fn new(
|
||||
line_text: String,
|
||||
line_number: u32,
|
||||
column_num: Option<usize>,
|
||||
submatches: Vec<Submatch>,
|
||||
) -> Self {
|
||||
Self {
|
||||
line_text,
|
||||
line_number,
|
||||
column_num,
|
||||
submatches,
|
||||
mouse_state: MouseStateHandle::default(),
|
||||
}
|
||||
@@ -299,17 +320,20 @@ pub struct GlobalSearchView {
|
||||
query_editor: ViewHandle<EditorView>,
|
||||
query_change_tx: Sender<()>,
|
||||
/// All terminal working directories for display grouping (preserved as-is)
|
||||
root_directories: Vec<PathBuf>,
|
||||
/// Deduplicated roots for ripgrep search (excludes nested subdirectories)
|
||||
search_roots: Vec<PathBuf>,
|
||||
root_directories: Vec<LocalOrRemotePath>,
|
||||
/// Deduplicated roots for search (excludes nested subdirectories)
|
||||
search_roots: Vec<LocalOrRemotePath>,
|
||||
last_searched_pattern: Option<String>,
|
||||
directory_entries: Vec<DirectoryEntry>,
|
||||
directory_path_to_directory_index_entry: HashMap<PathBuf, usize>,
|
||||
directory_path_to_directory_index_entry: HashMap<LocalOrRemotePath, usize>,
|
||||
selected_row: Option<RowIndex>,
|
||||
total_match_count: usize,
|
||||
is_search_in_progress: bool,
|
||||
capped_matches: bool,
|
||||
last_error: Option<String>,
|
||||
/// When the current search started, for completion telemetry.
|
||||
search_started_at: Option<Instant>,
|
||||
active_search_remote_host_count: usize,
|
||||
scroll_state: ScrollStateHandle,
|
||||
uniform_list_state: UniformListState,
|
||||
handle: WeakViewHandle<GlobalSearchView>,
|
||||
@@ -353,12 +377,12 @@ impl TypedActionView for GlobalSearchView {
|
||||
self.toggle_directory_collapsed(directory_path, ctx);
|
||||
}
|
||||
GlobalSearchAction::OpenMatch {
|
||||
path,
|
||||
location,
|
||||
line_number,
|
||||
column_num,
|
||||
} => {
|
||||
ctx.emit(Event::OpenMatch {
|
||||
path: path.clone(),
|
||||
location: location.clone(),
|
||||
line_number: *line_number,
|
||||
column_num: *column_num,
|
||||
});
|
||||
@@ -486,24 +510,6 @@ impl TypedActionView for GlobalSearchView {
|
||||
}
|
||||
|
||||
impl GlobalSearchView {
|
||||
/// Calculate the 1-indexed column number from the first submatch.
|
||||
/// Returns None if there are no submatches or the line is empty.
|
||||
fn column_from_submatches(line_text: &str, submatches: &[Submatch]) -> Option<usize> {
|
||||
if line_text.is_empty() || submatches.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let first_submatch = &submatches[0];
|
||||
let max_byte = ByteOffset::from(line_text.len());
|
||||
let start_b = first_submatch.byte_start.min(max_byte);
|
||||
|
||||
let mut char_counter = CharCounter::new(line_text);
|
||||
let start_char = char_counter.char_offset(start_b)?;
|
||||
|
||||
// Return 1-indexed column number
|
||||
Some(start_char.as_usize() + 1)
|
||||
}
|
||||
|
||||
/// Convert submatch byte ranges into character indices for highlighting.
|
||||
fn highlight_indices_from_submatches(line_text: &str, submatches: &[Submatch]) -> Vec<usize> {
|
||||
if line_text.is_empty() || submatches.is_empty() {
|
||||
@@ -705,6 +711,8 @@ impl GlobalSearchView {
|
||||
is_search_in_progress: false,
|
||||
capped_matches: false,
|
||||
last_error: None,
|
||||
search_started_at: None,
|
||||
active_search_remote_host_count: 0,
|
||||
scroll_state: ScrollStateHandle::default(),
|
||||
uniform_list_state: UniformListState::new(),
|
||||
handle,
|
||||
@@ -746,32 +754,30 @@ impl GlobalSearchView {
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
/// Returns an iterator over all directory paths where the given file path should appear.
|
||||
/// A file matches a directory if the file path starts with that directory.
|
||||
/// Returns an iterator over all directory locations where the given file should appear.
|
||||
/// A file matches a directory if the file location starts with that directory
|
||||
/// (remote files only match directories on the same host).
|
||||
fn find_matching_directories<'a>(
|
||||
&'a self,
|
||||
file_path: &'a Path,
|
||||
) -> impl Iterator<Item = &'a PathBuf> {
|
||||
location: &'a LocalOrRemotePath,
|
||||
) -> impl Iterator<Item = &'a LocalOrRemotePath> {
|
||||
self.root_directories
|
||||
.iter()
|
||||
.filter(move |root| file_path.starts_with(root))
|
||||
.filter(move |root| location.starts_with(root))
|
||||
}
|
||||
|
||||
fn apply_progress_item(&mut self, result: RipgrepMatch, ctx: &mut ViewContext<Self>) {
|
||||
fn apply_progress_item(&mut self, result: GlobalSearchMatch, ctx: &mut ViewContext<Self>) {
|
||||
if self.total_match_count >= MAX_MATCH_COUNT {
|
||||
return;
|
||||
}
|
||||
|
||||
let file_path = result.file_path.clone();
|
||||
let location = result.location.clone();
|
||||
|
||||
// Find all directories that this file belongs to
|
||||
let mut matching_directories = self.find_matching_directories(&file_path).peekable();
|
||||
let mut matching_directories = self.find_matching_directories(&location).peekable();
|
||||
if matching_directories.peek().is_none() {
|
||||
// File doesn't match any root directory, skip it
|
||||
let file_path_name = file_path
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy())
|
||||
.unwrap_or_else(|| std::borrow::Cow::Borrowed("<unknown>"));
|
||||
let file_path_name = location.file_name().unwrap_or("<unknown>");
|
||||
log::warn!("[Global search] file {file_path_name} was not found in directories");
|
||||
return;
|
||||
}
|
||||
@@ -795,12 +801,13 @@ impl GlobalSearchView {
|
||||
|
||||
// Get or create the matched path entry within this directory
|
||||
let dir_entry = &mut directory_entries[dir_index];
|
||||
let (matched_path, _path_index) = dir_entry.matched_paths.get_or_create(&file_path);
|
||||
let (matched_path, _path_index) = dir_entry.matched_paths.get_or_create(&location);
|
||||
|
||||
// Add the match
|
||||
matched_path.matches.push(Match::new(
|
||||
result.line_text.clone(),
|
||||
result.line_number,
|
||||
result.column_num,
|
||||
result.submatches.clone(),
|
||||
));
|
||||
}
|
||||
@@ -813,11 +820,16 @@ impl GlobalSearchView {
|
||||
}
|
||||
fn abort_search(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.capped_matches = true;
|
||||
self.cancel_search(ctx);
|
||||
}
|
||||
|
||||
fn cancel_search(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.is_search_in_progress = false;
|
||||
self.current_search_id = None;
|
||||
self.search_started_at = None;
|
||||
|
||||
self.find_model.update(ctx, |model, _| {
|
||||
model.abort_search();
|
||||
self.find_model.update(ctx, |model, model_ctx| {
|
||||
model.abort_search(model_ctx);
|
||||
});
|
||||
}
|
||||
fn handle_debounced_query_change(&mut self, _event: (), ctx: &mut ViewContext<Self>) {
|
||||
@@ -840,8 +852,7 @@ impl GlobalSearchView {
|
||||
| EditorEvent::BufferReinitialized => {
|
||||
let query_text = self.query_editor.as_ref(ctx).buffer_text(ctx);
|
||||
if query_text.is_empty() {
|
||||
self.current_search_id = None;
|
||||
self.is_search_in_progress = false;
|
||||
self.cancel_search(ctx);
|
||||
self.reset_search_state(true);
|
||||
ctx.notify();
|
||||
return;
|
||||
@@ -881,8 +892,7 @@ impl GlobalSearchView {
|
||||
|
||||
let pattern = self.query_editor.as_ref(ctx).buffer_text(ctx);
|
||||
if pattern.is_empty() {
|
||||
self.current_search_id = None;
|
||||
self.is_search_in_progress = false;
|
||||
self.cancel_search(ctx);
|
||||
self.reset_search_state(true);
|
||||
ctx.notify();
|
||||
return;
|
||||
@@ -924,10 +934,15 @@ impl GlobalSearchView {
|
||||
|
||||
fn handle_find_model_event(&mut self, event: &GlobalSearchEvent, ctx: &mut ViewContext<Self>) {
|
||||
match event {
|
||||
GlobalSearchEvent::Started { search_id } => {
|
||||
GlobalSearchEvent::Started {
|
||||
search_id,
|
||||
remote_host_count,
|
||||
} => {
|
||||
send_telemetry_from_ctx!(TelemetryEvent::GlobalSearchQueryStarted, ctx);
|
||||
|
||||
self.current_search_id = Some(*search_id);
|
||||
self.search_started_at = Some(Instant::now());
|
||||
self.active_search_remote_host_count = *remote_host_count;
|
||||
|
||||
self.is_search_in_progress = true;
|
||||
self.reset_search_state(false);
|
||||
@@ -958,6 +973,9 @@ impl GlobalSearchView {
|
||||
GlobalSearchEvent::Completed {
|
||||
search_id,
|
||||
total_match_count,
|
||||
capped,
|
||||
local_source_failed,
|
||||
remote_source_failures,
|
||||
} => {
|
||||
if Some(*search_id) != self.current_search_id {
|
||||
return;
|
||||
@@ -965,6 +983,21 @@ impl GlobalSearchView {
|
||||
|
||||
self.is_search_in_progress = false;
|
||||
self.total_match_count = *total_match_count;
|
||||
self.capped_matches |= capped;
|
||||
|
||||
if let Some(started_at) = self.search_started_at.take() {
|
||||
send_telemetry_from_ctx!(
|
||||
TelemetryEvent::GlobalSearchQueryCompleted {
|
||||
duration_ms: started_at.elapsed().as_millis() as u64,
|
||||
remote_host_count: self.active_search_remote_host_count,
|
||||
total_match_count: *total_match_count,
|
||||
capped: self.capped_matches,
|
||||
local_source_failed: *local_source_failed,
|
||||
remote_source_failures: *remote_source_failures,
|
||||
},
|
||||
ctx
|
||||
);
|
||||
}
|
||||
ctx.notify();
|
||||
}
|
||||
GlobalSearchEvent::Failed { search_id, error } => {
|
||||
@@ -973,6 +1006,7 @@ impl GlobalSearchView {
|
||||
}
|
||||
|
||||
self.is_search_in_progress = false;
|
||||
self.search_started_at = None;
|
||||
self.reset_search_state(false);
|
||||
self.last_error = Some(error.clone());
|
||||
ctx.notify();
|
||||
@@ -980,11 +1014,53 @@ impl GlobalSearchView {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_root_directories(&mut self, roots: Vec<PathBuf>, _ctx: &mut ViewContext<Self>) {
|
||||
pub fn set_root_directories(
|
||||
&mut self,
|
||||
roots: Vec<LocalOrRemotePath>,
|
||||
_ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
// Ancestor-dedup search roots so we don't search the same file twice
|
||||
// when terminal directories are nested (e.g. `~/code` + `~/code/a`).
|
||||
// Shared with `FileTreeView` for consistency.
|
||||
self.search_roots = galaxy_util::path::group_roots_by_common_ancestor(&roots).roots;
|
||||
// Local and remote roots share `group_roots_by_common_ancestor` with
|
||||
// `FileTreeView` for consistency; remote roots are grouped per host
|
||||
// and deduped within each host independently.
|
||||
let local_roots: Vec<PathBuf> = roots
|
||||
.iter()
|
||||
.filter_map(|root| root.to_local_path().map(Path::to_path_buf))
|
||||
.collect();
|
||||
let deduped_local = galaxy_util::path::group_roots_by_common_ancestor(&local_roots).roots;
|
||||
|
||||
let mut remote_roots_by_host: Vec<(HostId, Vec<StandardizedPath>)> = Vec::new();
|
||||
for root in &roots {
|
||||
let LocalOrRemotePath::Remote(remote) = root else {
|
||||
continue;
|
||||
};
|
||||
match remote_roots_by_host
|
||||
.iter_mut()
|
||||
.find(|(host_id, _)| host_id == &remote.host_id)
|
||||
{
|
||||
Some((_, paths)) => paths.push(remote.path.clone()),
|
||||
None => {
|
||||
remote_roots_by_host.push((remote.host_id.clone(), vec![remote.path.clone()]))
|
||||
}
|
||||
}
|
||||
}
|
||||
let deduped_remote = remote_roots_by_host
|
||||
.into_iter()
|
||||
.flat_map(|(host_id, paths)| {
|
||||
galaxy_util::path::group_roots_by_common_ancestor(&paths)
|
||||
.roots
|
||||
.into_iter()
|
||||
.map(move |path| {
|
||||
LocalOrRemotePath::Remote(RemotePath::new(host_id.clone(), path))
|
||||
})
|
||||
});
|
||||
|
||||
self.search_roots = deduped_local
|
||||
.into_iter()
|
||||
.map(LocalOrRemotePath::Local)
|
||||
.chain(deduped_remote)
|
||||
.collect();
|
||||
self.root_directories = roots;
|
||||
}
|
||||
|
||||
@@ -1030,7 +1106,7 @@ impl GlobalSearchView {
|
||||
|
||||
match &row_index.index_type {
|
||||
RowIndexType::DirectoryHeader => {
|
||||
self.render_directory_header_from_entry(index, dir_entry, appearance, theme)
|
||||
self.render_directory_header_from_entry(index, dir_entry, appearance, theme, app)
|
||||
}
|
||||
RowIndexType::FileHeader { path_index } => {
|
||||
let Some(matched_path) = dir_entry.matched_paths.paths.get(*path_index) else {
|
||||
@@ -1071,7 +1147,7 @@ impl GlobalSearchView {
|
||||
fn render_file_header(
|
||||
&self,
|
||||
index: usize,
|
||||
directory_path: &Path,
|
||||
directory_path: &LocalOrRemotePath,
|
||||
matched_path: &MatchedPath,
|
||||
appearance: &Appearance,
|
||||
theme: &galaxy_core::ui::theme::GalaxyTheme,
|
||||
@@ -1083,14 +1159,14 @@ impl GlobalSearchView {
|
||||
let file_path = matched_path.path.clone();
|
||||
let match_count = matched_path.matches.len();
|
||||
|
||||
let directory_path_for_select = directory_path.to_path_buf();
|
||||
let directory_path_for_select = directory_path.clone();
|
||||
let file_path_clone = file_path.clone();
|
||||
let directory_path_for_toggle = directory_path.to_path_buf();
|
||||
let directory_path_for_toggle = directory_path.clone();
|
||||
|
||||
let display_path = file_path
|
||||
.strip_prefix(directory_path)
|
||||
.unwrap_or(file_path.as_path());
|
||||
let display_path = display_path.to_path_buf();
|
||||
let display_path = directory_path
|
||||
.strip_repo_prefix(&file_path)
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from(file_path.display_path()));
|
||||
|
||||
Hoverable::new(file_mouse_state, move |mouse_state| {
|
||||
let item_highlight_state = ItemHighlightState::new(is_selected, mouse_state);
|
||||
@@ -1113,7 +1189,7 @@ impl GlobalSearchView {
|
||||
.finish();
|
||||
let chevron_container = Container::new(chevron_icon).with_margin_right(8.).finish();
|
||||
|
||||
let tooltip_text = file_path.to_string_lossy().to_string();
|
||||
let tooltip_text = file_path.display_path();
|
||||
|
||||
let header_text_fill = match list_highlight_state {
|
||||
ItemHighlightState::None => {
|
||||
@@ -1150,7 +1226,7 @@ impl GlobalSearchView {
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
|
||||
.finish();
|
||||
|
||||
let icon_from_file_path = icon_from_file_path(&file_path.to_string_lossy(), appearance)
|
||||
let icon_from_file_path = icon_from_file_path(&file_path.display_path(), appearance)
|
||||
.map(ImageOrIcon::Image)
|
||||
.unwrap_or(ImageOrIcon::Icon(Icon::File));
|
||||
|
||||
@@ -1244,7 +1320,7 @@ impl GlobalSearchView {
|
||||
fn render_match_row(
|
||||
&self,
|
||||
index: usize,
|
||||
directory_path: &Path,
|
||||
directory_path: &LocalOrRemotePath,
|
||||
matched_path: &MatchedPath,
|
||||
matched: &Match,
|
||||
match_index: usize,
|
||||
@@ -1253,17 +1329,14 @@ impl GlobalSearchView {
|
||||
) -> Box<dyn Element> {
|
||||
let is_selected = self.is_row_at_index_selected(index);
|
||||
let line_number = matched.line_number;
|
||||
let column_num = matched.column_num;
|
||||
let line_text = matched.line_text.clone();
|
||||
let submatches = matched.submatches.clone();
|
||||
let mouse_state = matched.mouse_state.clone();
|
||||
|
||||
let directory_path_for_select = directory_path.to_path_buf();
|
||||
let directory_path_for_select = directory_path.clone();
|
||||
let file_path_for_select = matched_path.path.clone();
|
||||
let path_for_click = matched_path.path.clone();
|
||||
|
||||
// Clone for the on_click closure since line_text and submatches are moved into Hoverable
|
||||
let line_text_for_click = line_text.clone();
|
||||
let submatches_for_click = submatches.clone();
|
||||
let location_for_click = matched_path.path.clone();
|
||||
|
||||
Hoverable::new(mouse_state, move |mouse_state| {
|
||||
let list_highlight_state = ItemHighlightState::new(is_selected, mouse_state);
|
||||
@@ -1319,12 +1392,8 @@ impl GlobalSearchView {
|
||||
file_path: file_path_for_select.clone(),
|
||||
match_index: Some(match_index),
|
||||
});
|
||||
let column_num = GlobalSearchView::column_from_submatches(
|
||||
&line_text_for_click,
|
||||
&submatches_for_click,
|
||||
);
|
||||
ctx.dispatch_typed_action(GlobalSearchAction::OpenMatch {
|
||||
path: path_for_click.clone(),
|
||||
location: location_for_click.clone(),
|
||||
line_number,
|
||||
column_num,
|
||||
});
|
||||
@@ -1483,18 +1552,21 @@ impl GlobalSearchView {
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// Gets or creates a DirectoryEntry for the given path.
|
||||
/// Gets or creates a DirectoryEntry for the given location.
|
||||
/// Returns a mutable reference to the entry and its index.
|
||||
#[allow(dead_code)] // Will be used in later PRs
|
||||
fn get_or_create_directory_entry(&mut self, path: &Path) -> (&mut DirectoryEntry, usize) {
|
||||
fn get_or_create_directory_entry(
|
||||
&mut self,
|
||||
path: &LocalOrRemotePath,
|
||||
) -> (&mut DirectoryEntry, usize) {
|
||||
if let Some(&index) = self.directory_path_to_directory_index_entry.get(path) {
|
||||
(&mut self.directory_entries[index], index)
|
||||
} else {
|
||||
let index = self.directory_entries.len();
|
||||
self.directory_entries
|
||||
.push(DirectoryEntry::new(path.to_path_buf()));
|
||||
.push(DirectoryEntry::new(path.clone()));
|
||||
self.directory_path_to_directory_index_entry
|
||||
.insert(path.to_path_buf(), index);
|
||||
.insert(path.clone(), index);
|
||||
(&mut self.directory_entries[index], index)
|
||||
}
|
||||
}
|
||||
@@ -1503,8 +1575,8 @@ impl GlobalSearchView {
|
||||
/// Returns None if the paths are not found
|
||||
fn path_to_row_index(
|
||||
&self,
|
||||
directory_path: &Path,
|
||||
file_path: &Path,
|
||||
directory_path: &LocalOrRemotePath,
|
||||
file_path: &LocalOrRemotePath,
|
||||
match_index: Option<usize>,
|
||||
) -> Option<RowIndex> {
|
||||
let &directory_index = self
|
||||
@@ -1527,15 +1599,15 @@ impl GlobalSearchView {
|
||||
})
|
||||
}
|
||||
|
||||
/// Gets the directory path for a given RowIndex.
|
||||
fn directory_path_for_row_index(&self, row: &RowIndex) -> Option<&PathBuf> {
|
||||
/// Gets the directory location for a given RowIndex.
|
||||
fn directory_path_for_row_index(&self, row: &RowIndex) -> Option<&LocalOrRemotePath> {
|
||||
self.directory_entries
|
||||
.get(row.directory_index)
|
||||
.map(|e| &e.path)
|
||||
}
|
||||
|
||||
/// Gets the file path for a given RowIndex (if it refers to a file or match).
|
||||
fn file_path_for_row_index(&self, row: &RowIndex) -> Option<&PathBuf> {
|
||||
/// Gets the file location for a given RowIndex (if it refers to a file or match).
|
||||
fn file_path_for_row_index(&self, row: &RowIndex) -> Option<&LocalOrRemotePath> {
|
||||
let dir_entry = self.directory_entries.get(row.directory_index)?;
|
||||
match &row.index_type {
|
||||
RowIndexType::DirectoryHeader => None,
|
||||
@@ -1724,12 +1796,10 @@ impl GlobalSearchView {
|
||||
let Some(matched) = matched_path.matches.get(*match_index) else {
|
||||
return;
|
||||
};
|
||||
let column_num =
|
||||
Self::column_from_submatches(&matched.line_text, &matched.submatches);
|
||||
ctx.emit(Event::OpenMatch {
|
||||
path: matched_path.path.clone(),
|
||||
location: matched_path.path.clone(),
|
||||
line_number: matched.line_number,
|
||||
column_num,
|
||||
column_num: matched.column_num,
|
||||
});
|
||||
}
|
||||
RowIndexType::DirectoryHeader => {
|
||||
@@ -1740,7 +1810,11 @@ impl GlobalSearchView {
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle_directory_collapsed(&mut self, directory_path: &Path, ctx: &mut ViewContext<Self>) {
|
||||
fn toggle_directory_collapsed(
|
||||
&mut self,
|
||||
directory_path: &LocalOrRemotePath,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
let Some(&dir_idx) = self
|
||||
.directory_path_to_directory_index_entry
|
||||
.get(directory_path)
|
||||
@@ -1774,8 +1848,8 @@ impl GlobalSearchView {
|
||||
|
||||
fn toggle_file_collapsed(
|
||||
&mut self,
|
||||
directory_path: &Path,
|
||||
file_path: &PathBuf,
|
||||
directory_path: &LocalOrRemotePath,
|
||||
file_path: &LocalOrRemotePath,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
// Get directory index
|
||||
@@ -1853,7 +1927,8 @@ impl GlobalSearchView {
|
||||
index: usize,
|
||||
dir_entry: &DirectoryEntry,
|
||||
appearance: &Appearance,
|
||||
theme: &galaxy_core::ui::theme::GalaxyTheme,
|
||||
theme: &galaxy_core::ui::theme::WarpTheme,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let is_selected = self.is_row_at_index_selected(index);
|
||||
let mouse_state = dir_entry.mouse_state.clone();
|
||||
@@ -1861,15 +1936,13 @@ impl GlobalSearchView {
|
||||
let is_collapsed = dir_entry.is_collapsed;
|
||||
let directory_path = &dir_entry.path;
|
||||
|
||||
// Get the display name (last component of the path)
|
||||
let display_name = directory_path
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| directory_path.to_string_lossy().to_string());
|
||||
let directory_path = directory_path.clone();
|
||||
let display_name = if directory_path.display_name().is_empty() {
|
||||
display_path_with_host(directory_path, false, app)
|
||||
} else {
|
||||
display_name_with_host(directory_path, app)
|
||||
};
|
||||
let directory_path_for_click = directory_path.clone();
|
||||
|
||||
let tooltip_text = directory_path.to_string_lossy().to_string();
|
||||
let tooltip_text = display_path_with_host(directory_path, false, app);
|
||||
|
||||
Hoverable::new(mouse_state, move |mouse_state| {
|
||||
let list_highlight_state = ItemHighlightState::new(is_selected, mouse_state);
|
||||
@@ -1995,8 +2068,15 @@ impl View for GlobalSearchView {
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
match self.enablement {
|
||||
CodingPanelEnablementState::RemoteSession { .. } => {
|
||||
return self.render_remote_state(app);
|
||||
CodingPanelEnablementState::PendingRemoteSession => {
|
||||
return self.render_remote_loading_state(app);
|
||||
}
|
||||
CodingPanelEnablementState::RemoteSession { has_remote_server } => {
|
||||
// Remote-server sessions can search via the daemon; sessions
|
||||
// without one (tmux / subshell SSH) stay unavailable.
|
||||
if !has_remote_server {
|
||||
return self.render_remote_state(app);
|
||||
}
|
||||
}
|
||||
CodingPanelEnablementState::UnsupportedSession => {
|
||||
return self.render_unsupported_session_state(app);
|
||||
@@ -2066,8 +2146,10 @@ impl View for GlobalSearchView {
|
||||
let files = self.unique_match_count();
|
||||
let file_word = if files == 1 { "file" } else { "files" };
|
||||
|
||||
let message = if self.is_search_in_progress && self.total_match_count == 0 {
|
||||
"".to_string()
|
||||
let message = if let Some(error) = &self.last_error {
|
||||
error.clone()
|
||||
} else if self.is_search_in_progress && self.total_match_count == 0 {
|
||||
"Searching…".to_string()
|
||||
} else if !self.is_search_in_progress && self.total_match_count == 0 {
|
||||
"No results found. Review your gitignore files.".to_string()
|
||||
} else {
|
||||
@@ -2256,7 +2338,7 @@ impl GlobalSearchView {
|
||||
self.render_zero_state(
|
||||
Icon::AlertTriangle,
|
||||
"Global search unavailable",
|
||||
"Global search requries access to your local workspace. Open a new session or navigate to an active session to view.",
|
||||
"Global search requires access to your local workspace. Open a new session or navigate to an active session to view.",
|
||||
app,
|
||||
)
|
||||
}
|
||||
@@ -2265,7 +2347,15 @@ impl GlobalSearchView {
|
||||
self.render_zero_state(
|
||||
Icon::AlertTriangle,
|
||||
"Global search unavailable",
|
||||
"Global search requires access to your local workspace, which isn't supported in remote sessions",
|
||||
"Global search isn't available for this remote session.",
|
||||
app,
|
||||
)
|
||||
}
|
||||
fn render_remote_loading_state(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
self.render_zero_state(
|
||||
Icon::Loading,
|
||||
"Connecting to remote session",
|
||||
"Global search will be available once the connection is ready.",
|
||||
app,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use warpui::ViewContext;
|
||||
|
||||
use super::Slide;
|
||||
use crate::server::telemetry::TelemetryEvent;
|
||||
use galaxyui::ViewContext;
|
||||
use std::rc::Rc;
|
||||
|
||||
/// A callback function for custom CTA button actions.
|
||||
type CustomCallback<S> = Rc<dyn Fn(&mut ViewContext<super::LaunchModal<S>>)>;
|
||||
|
||||
@@ -2,14 +2,12 @@
|
||||
pub mod cta_button;
|
||||
pub mod oz_launch;
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use markdown_parser::{parse_markdown, FormattedText, FormattedTextLine};
|
||||
// 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 pathfinder_color::ColorU;
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use galaxy_core::ui::theme::Fill;
|
||||
use galaxyui::assets::asset_cache::AssetSource;
|
||||
@@ -28,9 +26,12 @@ use galaxyui::ui_components::components::UiComponent;
|
||||
use galaxyui::{
|
||||
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
|
||||
};
|
||||
use markdown_parser::{parse_markdown, FormattedText, FormattedTextLine};
|
||||
use pathfinder_color::ColorU;
|
||||
use std::collections::HashMap;
|
||||
|
||||
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};
|
||||
|
||||
pub fn init<S: Slide>(app: &mut AppContext) {
|
||||
use galaxyui::keymap::macros::*;
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
use asset_macro::bundled_or_fetched_asset;
|
||||
use markdown_parser::{FormattedTextFragment, FormattedTextLine};
|
||||
use galaxy_core::send_telemetry_from_ctx;
|
||||
use warpui::assets::asset_cache::AssetSource;
|
||||
use warpui::{AppContext, SingletonEntity};
|
||||
|
||||
use super::{CTAButton, CheckboxConfig, LaunchModalEvent, Slide};
|
||||
use crate::ai::ambient_agents::telemetry::{CloudAgentTelemetryEvent, CloudModeEntryPoint};
|
||||
use crate::terminal::view::OnboardingIntention;
|
||||
@@ -6,11 +12,6 @@ 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 galaxy_core::send_telemetry_from_ctx;
|
||||
use galaxyui::assets::asset_cache::AssetSource;
|
||||
use galaxyui::{AppContext, SingletonEntity};
|
||||
use markdown_parser::{FormattedTextFragment, FormattedTextLine};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum OzLaunchSlide {
|
||||
|
||||
@@ -1,38 +1,54 @@
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use galaxy_core::send_telemetry_from_ctx;
|
||||
use galaxy_core::ui::theme::color::internal_colors;
|
||||
use galaxy_core::{send_telemetry_from_ctx, ui::Icon};
|
||||
use galaxy_core::ui::Icon;
|
||||
use galaxy_util::path::LineAndColumnArg;
|
||||
use galaxyui::elements::{
|
||||
resizable_state_handle, ChildView, ConstrainedBox, Container, CrossAxisAlignment, DragBarSide,
|
||||
Element, Empty, Flex, MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement,
|
||||
Resizable, ResizableStateHandle, Shrinkable,
|
||||
};
|
||||
use galaxyui::platform::Cursor;
|
||||
use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
|
||||
use galaxyui::{
|
||||
elements::{
|
||||
resizable_state_handle, ChildView, ConstrainedBox, Container, CrossAxisAlignment,
|
||||
DragBarSide, Element, Empty, Flex, MainAxisAlignment, MainAxisSize, MouseStateHandle,
|
||||
ParentElement, Resizable, ResizableStateHandle, Shrinkable,
|
||||
},
|
||||
platform::Cursor,
|
||||
ui_components::components::{Coords, UiComponent, UiComponentStyles},
|
||||
AppContext, Entity, FocusContext, ModelHandle, SingletonEntity, TypedActionView, View,
|
||||
ViewContext, ViewHandle, WeakViewHandle,
|
||||
};
|
||||
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::agent_conversations_model::AgentConversationsModel;
|
||||
use crate::appearance::Appearance;
|
||||
use crate::code::buffer_location::LocalOrRemotePath;
|
||||
#[cfg(feature = "local_fs")]
|
||||
use crate::code::file_tree::FileTreeEvent;
|
||||
use crate::code::file_tree::FileTreeView;
|
||||
use crate::coding_panel_enablement_state::CodingPanelEnablementState;
|
||||
use crate::drive::panel::{DrivePanel, DrivePanelEvent};
|
||||
use crate::drive::panel::{
|
||||
DrivePanel, DrivePanelEvent, MAX_SIDEBAR_WIDTH_RATIO, MIN_SIDEBAR_WIDTH,
|
||||
};
|
||||
use crate::pane_group::pane::view::header::components::HEADER_EDGE_PADDING;
|
||||
use crate::pane_group::pane::view::header::PANE_HEADER_HEIGHT;
|
||||
use crate::pane_group::working_directories::WorkingDirectory;
|
||||
use crate::pane_group::{PaneGroup, WorkingDirectoriesEvent, WorkingDirectoriesModel};
|
||||
use crate::pane_group::{
|
||||
PaneGroup, WorkingDirectoriesEvent, WorkingDirectoriesModel, {self},
|
||||
};
|
||||
#[cfg(feature = "local_fs")]
|
||||
use crate::server::telemetry::CodePanelsFileOpenEntrypoint;
|
||||
use crate::server::telemetry::{FileTreeSource, WarpDriveSource};
|
||||
use crate::settings_view::keybindings::{KeybindingChangedEvent, KeybindingChangedNotifier};
|
||||
use crate::terminal::resizable_data::{ModalType, ResizableData};
|
||||
use crate::ui_components::buttons::{icon_button, icon_button_with_color};
|
||||
use crate::ui_components::icons;
|
||||
use crate::util::bindings::keybinding_name_to_display_string;
|
||||
#[cfg(feature = "local_fs")]
|
||||
use crate::util::file::external_editor::EditorSettings;
|
||||
#[cfg(feature = "local_fs")]
|
||||
use crate::util::openable_file_type::resolve_file_target_with_editor_choice;
|
||||
use crate::util::openable_file_type::FileTarget;
|
||||
#[cfg(feature = "local_fs")]
|
||||
use crate::util::openable_file_type::{
|
||||
is_markdown_file, resolve_file_target_with_editor_choice, EditorLayout,
|
||||
};
|
||||
use crate::workspace::view::conversation_list::view::{
|
||||
ConversationListView, Event as ConversationListViewEvent,
|
||||
};
|
||||
@@ -45,28 +61,15 @@ use crate::workspace::view::{
|
||||
OPEN_GLOBAL_SEARCH_BINDING_NAME, TOGGLE_CONVERSATION_LIST_VIEW_BINDING_NAME,
|
||||
TOGGLE_PROJECT_EXPLORER_BINDING_NAME, TOGGLE_WARP_DRIVE_BINDING_NAME,
|
||||
};
|
||||
use crate::{
|
||||
appearance::Appearance,
|
||||
code::file_tree::FileTreeView,
|
||||
drive::panel::{MAX_SIDEBAR_WIDTH_RATIO, MIN_SIDEBAR_WIDTH},
|
||||
pane_group::pane::view::header::{components::HEADER_EDGE_PADDING, PANE_HEADER_HEIGHT},
|
||||
pane_group::{self},
|
||||
terminal::resizable_data::{ModalType, ResizableData},
|
||||
ui_components::{
|
||||
buttons::{icon_button, icon_button_with_color},
|
||||
icons,
|
||||
},
|
||||
util::bindings::keybinding_name_to_display_string,
|
||||
workspace::WorkspaceAction,
|
||||
TelemetryEvent,
|
||||
};
|
||||
use crate::workspace::WorkspaceAction;
|
||||
use crate::TelemetryEvent;
|
||||
|
||||
#[derive(Default)]
|
||||
struct MouseStateHandles {
|
||||
project_explorer_button: MouseStateHandle,
|
||||
conversation_list_view_button: MouseStateHandle,
|
||||
global_search_button: MouseStateHandle,
|
||||
warp_drive_button: MouseStateHandle,
|
||||
conversation_list_view_button: MouseStateHandle,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -77,13 +80,14 @@ pub enum LeftPanelAction {
|
||||
ConversationListView,
|
||||
}
|
||||
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
pub enum LeftPanelEvent {
|
||||
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
|
||||
FileTree(pane_group::Event),
|
||||
WarpDrive(DrivePanelEvent),
|
||||
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
|
||||
OpenFileWithTarget {
|
||||
path: PathBuf,
|
||||
location: LocalOrRemotePath,
|
||||
target: FileTarget,
|
||||
line_col: Option<LineAndColumnArg>,
|
||||
},
|
||||
@@ -106,9 +110,10 @@ pub enum ToolPanelView {
|
||||
/// Encapsulates the active view state to enforce that all mutations go through
|
||||
/// `active_view_state::set`, which handles necessary side effects.
|
||||
mod active_view_state {
|
||||
use super::ToolPanelView;
|
||||
use galaxyui::ViewContext;
|
||||
|
||||
use super::ToolPanelView;
|
||||
|
||||
pub struct ActiveViewState(ToolPanelView);
|
||||
|
||||
impl ActiveViewState {
|
||||
@@ -270,27 +275,45 @@ impl LeftPanelView {
|
||||
}
|
||||
let has_terminal_session = directories.iter().any(|dir| dir.terminal_id.is_some());
|
||||
|
||||
// Update GlobalSearchView root directories based on all working directories
|
||||
let roots: Vec<PathBuf> = directories.iter().map(|d| d.path.clone()).collect();
|
||||
// Split directories into local and remote.
|
||||
let local_paths: Vec<PathBuf> = directories
|
||||
.iter()
|
||||
.filter_map(|d| d.path.to_local_path().map(|p| p.to_path_buf()))
|
||||
.collect();
|
||||
#[allow(unused_variables)]
|
||||
let remote_repos: Vec<repo_metadata::RemoteRepositoryIdentifier> = directories
|
||||
.iter()
|
||||
.filter_map(|d| match &d.path {
|
||||
LocalOrRemotePath::Remote(remote_path) => {
|
||||
Some(repo_metadata::RemoteRepositoryIdentifier::new(
|
||||
remote_path.host_id.clone(),
|
||||
remote_path.path.clone(),
|
||||
))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Update GlobalSearchView root directories (local + remote).
|
||||
let all_directories: Vec<LocalOrRemotePath> =
|
||||
directories.iter().map(|d| d.path.clone()).collect();
|
||||
let global_search_view =
|
||||
me.get_or_create_global_search_view_for_pane_group(active_pane_group.id(), ctx);
|
||||
global_search_view.update(ctx, |view, view_ctx| {
|
||||
view.set_root_directories(roots, view_ctx);
|
||||
view.set_root_directories(all_directories, view_ctx);
|
||||
});
|
||||
|
||||
let directories: Vec<PathBuf> =
|
||||
directories.iter().map(|dir| dir.path.clone()).collect();
|
||||
|
||||
// Directories are already in display order (most recent first) from the model
|
||||
let directories = deduplicate_by_directory_name(directories);
|
||||
let local_directories = deduplicate_by_directory_name(local_paths);
|
||||
let file_tree_view =
|
||||
me.get_or_create_file_tree_view_for_pane_group(active_pane_group.id(), ctx);
|
||||
|
||||
let is_visible =
|
||||
active_pane_group.as_ref(ctx).left_panel_open && me.is_file_tree_active();
|
||||
file_tree_view.update(ctx, |view, ctx| {
|
||||
view.set_root_directories(directories, ctx);
|
||||
view.set_root_directories(local_directories, ctx);
|
||||
#[cfg(feature = "local_fs")]
|
||||
view.set_remote_root_directories(&remote_repos, ctx);
|
||||
view.set_has_terminal_session(has_terminal_session, ctx);
|
||||
view.set_is_active(is_visible, ctx);
|
||||
|
||||
@@ -591,26 +614,44 @@ impl LeftPanelView {
|
||||
.iter()
|
||||
.any(|dir| dir.terminal_id.is_some());
|
||||
|
||||
// Update GlobalSearchView root directories based on all working directories
|
||||
let roots: Vec<PathBuf> = active_directories.iter().map(|d| d.path.clone()).collect();
|
||||
// Split directories into local and remote.
|
||||
let local_paths: Vec<PathBuf> = active_directories
|
||||
.iter()
|
||||
.filter_map(|d| d.path.to_local_path().map(|p| p.to_path_buf()))
|
||||
.collect();
|
||||
#[allow(unused_variables)]
|
||||
let remote_repos: Vec<repo_metadata::RemoteRepositoryIdentifier> = active_directories
|
||||
.iter()
|
||||
.filter_map(|d| match &d.path {
|
||||
LocalOrRemotePath::Remote(remote_path) => {
|
||||
Some(repo_metadata::RemoteRepositoryIdentifier::new(
|
||||
remote_path.host_id.clone(),
|
||||
remote_path.path.clone(),
|
||||
))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Update GlobalSearchView root directories (local + remote).
|
||||
let all_directories: Vec<LocalOrRemotePath> =
|
||||
active_directories.iter().map(|d| d.path.clone()).collect();
|
||||
let global_search_view =
|
||||
self.get_or_create_global_search_view_for_pane_group(pane_group_id, ctx);
|
||||
global_search_view.update(ctx, |view, view_ctx| {
|
||||
view.set_root_directories(roots, view_ctx);
|
||||
view.set_root_directories(all_directories, view_ctx);
|
||||
});
|
||||
|
||||
let directories: Vec<PathBuf> = active_directories
|
||||
.iter()
|
||||
.map(|dir| dir.path.clone())
|
||||
.collect();
|
||||
let directories = deduplicate_by_directory_name(directories);
|
||||
let local_directories = deduplicate_by_directory_name(local_paths);
|
||||
let active_file_model = pane_group.as_ref(ctx).active_file_model().clone();
|
||||
|
||||
let file_tree_view = self.get_or_create_file_tree_view_for_pane_group(pane_group_id, ctx);
|
||||
let left_panel_open = pane_group.as_ref(ctx).left_panel_open;
|
||||
let is_visible = left_panel_open && self.is_file_tree_active();
|
||||
file_tree_view.update(ctx, |view, ctx| {
|
||||
view.set_root_directories(directories, ctx);
|
||||
view.set_root_directories(local_directories, ctx);
|
||||
#[cfg(feature = "local_fs")]
|
||||
view.set_remote_root_directories(&remote_repos, ctx);
|
||||
view.set_has_terminal_session(has_terminal_session, ctx);
|
||||
view.set_active_file_model(active_file_model, ctx);
|
||||
view.set_is_active(is_visible, ctx);
|
||||
@@ -701,7 +742,7 @@ impl LeftPanelView {
|
||||
) {
|
||||
match event {
|
||||
GlobalSearchViewEvent::OpenMatch {
|
||||
path,
|
||||
location,
|
||||
line_number,
|
||||
column_num,
|
||||
} => {
|
||||
@@ -711,13 +752,27 @@ impl LeftPanelView {
|
||||
};
|
||||
|
||||
let settings = EditorSettings::as_ref(ctx);
|
||||
let target = resolve_file_target_with_editor_choice(
|
||||
path,
|
||||
*settings.open_code_panels_file_editor,
|
||||
*settings.prefer_markdown_viewer,
|
||||
*settings.open_file_layout,
|
||||
None,
|
||||
);
|
||||
let target = match location {
|
||||
LocalOrRemotePath::Local(path) => resolve_file_target_with_editor_choice(
|
||||
path,
|
||||
*settings.open_code_panels_file_editor,
|
||||
*settings.prefer_markdown_viewer,
|
||||
*settings.open_file_layout,
|
||||
None,
|
||||
),
|
||||
// Local-fs-based target resolution can't inspect remote
|
||||
// files; mirror the file tree's remote handling (code
|
||||
// editor, or markdown viewer by extension + preference).
|
||||
LocalOrRemotePath::Remote(remote) => {
|
||||
let is_markdown =
|
||||
is_markdown_file(std::path::Path::new(remote.path.as_str()));
|
||||
if is_markdown && *settings.prefer_markdown_viewer {
|
||||
FileTarget::MarkdownViewer(EditorLayout::SplitPane)
|
||||
} else {
|
||||
FileTarget::CodeEditor(EditorLayout::SplitPane)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
send_telemetry_from_ctx!(
|
||||
TelemetryEvent::CodePanelsFileOpened {
|
||||
@@ -728,7 +783,7 @@ impl LeftPanelView {
|
||||
);
|
||||
|
||||
ctx.emit(LeftPanelEvent::OpenFileWithTarget {
|
||||
path: path.clone(),
|
||||
location: location.clone(),
|
||||
target,
|
||||
line_col: Some(line_col),
|
||||
});
|
||||
@@ -761,7 +816,7 @@ impl LeftPanelView {
|
||||
line_col,
|
||||
} => {
|
||||
ctx.emit(LeftPanelEvent::OpenFileWithTarget {
|
||||
path: path.clone(),
|
||||
location: path.clone(),
|
||||
target: target.clone(),
|
||||
line_col: *line_col,
|
||||
});
|
||||
@@ -1083,11 +1138,11 @@ impl View for LeftPanelView {
|
||||
|
||||
let mouse_state_handles = vec![
|
||||
self.mouse_state_handles.project_explorer_button.clone(),
|
||||
self.mouse_state_handles.global_search_button.clone(),
|
||||
self.mouse_state_handles.warp_drive_button.clone(),
|
||||
self.mouse_state_handles
|
||||
.conversation_list_view_button
|
||||
.clone(),
|
||||
self.mouse_state_handles.global_search_button.clone(),
|
||||
self.mouse_state_handles.warp_drive_button.clone(),
|
||||
];
|
||||
|
||||
// If there is only one button in the toolbelt row,
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use onboarding::{ProjectOnboardingSettings, SelectedSettings};
|
||||
use galaxy_core::execution_mode::AppExecutionMode;
|
||||
use warpui::{SingletonEntity as _, ViewContext};
|
||||
|
||||
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 galaxyui::{SingletonEntity as _, ViewContext};
|
||||
use onboarding::{ProjectOnboardingSettings, SelectedSettings};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use crate::{terminal, FeatureFlag};
|
||||
|
||||
/// Configuration for starting the agent onboarding tutorial.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -87,6 +89,12 @@ impl Workspace {
|
||||
tutorial: OnboardingTutorial,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
// Onboarding requires a real user to interact with it; skip when running
|
||||
// in a headless mode like the SDK/CLI.
|
||||
if !AppExecutionMode::as_ref(ctx).can_show_onboarding() {
|
||||
return;
|
||||
}
|
||||
|
||||
match tutorial {
|
||||
OnboardingTutorial::InitProject {
|
||||
ref path,
|
||||
@@ -143,6 +151,12 @@ impl Workspace {
|
||||
intention: OnboardingIntention,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
// Onboarding requires a real user to interact with it; skip when running
|
||||
// in a headless mode like the SDK/CLI.
|
||||
if !AppExecutionMode::as_ref(ctx).can_show_onboarding() {
|
||||
return;
|
||||
}
|
||||
|
||||
// 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()
|
||||
@@ -237,7 +251,12 @@ impl Workspace {
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn should_show_agent_onboarding(&self, _ctx: &mut ViewContext<Self>) -> bool {
|
||||
pub(crate) fn should_show_agent_onboarding(&self, ctx: &mut ViewContext<Self>) -> bool {
|
||||
// Onboarding requires a real user to interact with it; suppress when
|
||||
// running in a headless mode like the SDK/CLI.
|
||||
if !AppExecutionMode::as_ref(ctx).can_show_onboarding() {
|
||||
return false;
|
||||
}
|
||||
FeatureFlag::AgentOnboarding.is_enabled()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
use galaxy_core::ui::theme::{phenomenon::PhenomenonStyle, Fill};
|
||||
use markdown_parser::{
|
||||
FormattedText, FormattedTextFragment, FormattedTextLine, FormattedTextStyles, Hyperlink,
|
||||
};
|
||||
use pathfinder_color::ColorU;
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use galaxy_core::ui::theme::phenomenon::PhenomenonStyle;
|
||||
use galaxy_core::ui::theme::Fill;
|
||||
use galaxyui::assets::asset_cache::AssetSource;
|
||||
use galaxyui::elements::{
|
||||
Align, CacheOption, ChildAnchor, ChildView, ConstrainedBox, Container, CornerRadius,
|
||||
@@ -14,8 +20,6 @@ use galaxyui::{
|
||||
use markdown_parser::{
|
||||
FormattedText, FormattedTextFragment, FormattedTextLine, FormattedTextStyles, Hyperlink,
|
||||
};
|
||||
use pathfinder_color::ColorU;
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
|
||||
use crate::appearance::Appearance;
|
||||
use crate::ui_components::icons::Icon;
|
||||
@@ -190,15 +194,23 @@ impl OpenWarpLaunchModal {
|
||||
}
|
||||
|
||||
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(),
|
||||
let text = Text::new_inline("New".to_string(), appearance.ui_font_family(), 14.)
|
||||
.with_color(PhenomenonStyle::modal_badge_text())
|
||||
.finish();
|
||||
ConstrainedBox::new(
|
||||
Container::new(
|
||||
Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_main_axis_size(MainAxisSize::Min)
|
||||
.with_child(text)
|
||||
.finish(),
|
||||
)
|
||||
.with_horizontal_padding(8.)
|
||||
.with_background(Fill::Solid(PhenomenonStyle::modal_badge_background()))
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Percentage(50.)))
|
||||
.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()))
|
||||
.with_height(24.)
|
||||
.finish()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
mod view;
|
||||
|
||||
pub use view::{init, OrchestrationLaunchModal, OrchestrationLaunchModalEvent};
|
||||
@@ -0,0 +1,445 @@
|
||||
use pathfinder_color::ColorU;
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use galaxy_core::ui::theme::Fill;
|
||||
use warpui::assets::asset_cache::AssetSource;
|
||||
use warpui::elements::{
|
||||
Align, CacheOption, ChildAnchor, ChildView, Clipped, ConstrainedBox, Container, CornerRadius,
|
||||
CrossAxisAlignment, Expanded, Flex, 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/orchestration_launch_banner.png";
|
||||
const LEARN_MORE_URL: &str = "https://www.warp.dev/blog/multi-harness-cloud-agent-orchestration";
|
||||
fn modal_background(appearance: &Appearance) -> Fill {
|
||||
appearance.theme().surface_3()
|
||||
}
|
||||
|
||||
fn modal_text_main(appearance: &Appearance) -> ColorU {
|
||||
appearance
|
||||
.theme()
|
||||
.main_text_color(modal_background(appearance))
|
||||
.into_solid()
|
||||
}
|
||||
|
||||
fn modal_text_sub(appearance: &Appearance) -> ColorU {
|
||||
appearance
|
||||
.theme()
|
||||
.sub_text_color(modal_background(appearance))
|
||||
.into_solid()
|
||||
}
|
||||
|
||||
fn modal_overlay_1(appearance: &Appearance) -> Fill {
|
||||
appearance.theme().surface_overlay_1()
|
||||
}
|
||||
|
||||
fn modal_overlay_2(appearance: &Appearance) -> Fill {
|
||||
appearance.theme().surface_overlay_2()
|
||||
}
|
||||
|
||||
fn modal_terminal_magenta(appearance: &Appearance) -> ColorU {
|
||||
appearance.theme().terminal_colors().normal.magenta.into()
|
||||
}
|
||||
|
||||
fn modal_terminal_magenta_overlay_1(appearance: &Appearance) -> ColorU {
|
||||
let magenta = appearance.theme().terminal_colors().normal.magenta;
|
||||
appearance.theme().ansi_overlay_1(magenta)
|
||||
}
|
||||
|
||||
struct FeatureItem {
|
||||
icon: Icon,
|
||||
title: &'static str,
|
||||
description: &'static str,
|
||||
badge: Option<&'static str>,
|
||||
}
|
||||
|
||||
const FEATURE_ITEMS: &[FeatureItem] = &[
|
||||
FeatureItem {
|
||||
icon: Icon::Cloud,
|
||||
title: "Run any agent harness in the cloud",
|
||||
description: "Use Oz to spin up Claude Code or Codex agents in the cloud; Oz will help you track and steer the agents.",
|
||||
badge: None,
|
||||
},
|
||||
FeatureItem {
|
||||
icon: Icon::Atom,
|
||||
title: "Multi-agent orchestration",
|
||||
description: "Warp Agents will now orchestrate swarms of subagents, allowing you to parallelize tasks.",
|
||||
badge: None,
|
||||
},
|
||||
FeatureItem {
|
||||
icon: Icon::Cognition,
|
||||
title: "Agent Memory",
|
||||
description: "Agents will now store and access long-term memories, enabling self-improvement over time.",
|
||||
badge: Some("Research preview"),
|
||||
},
|
||||
];
|
||||
|
||||
pub fn init(app: &mut AppContext) {
|
||||
use warpui::keymap::macros::*;
|
||||
|
||||
app.register_fixed_bindings([FixedBinding::new(
|
||||
"escape",
|
||||
OrchestrationLaunchModalAction::Close,
|
||||
id!(OrchestrationLaunchModal::ui_name()),
|
||||
)]);
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum OrchestrationLaunchModalAction {
|
||||
Close,
|
||||
LearnMore,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum OrchestrationLaunchModalEvent {
|
||||
Close,
|
||||
}
|
||||
|
||||
struct CloseButtonTheme;
|
||||
|
||||
impl ActionButtonTheme for CloseButtonTheme {
|
||||
fn background(&self, hovered: bool, appearance: &Appearance) -> Option<Fill> {
|
||||
if hovered {
|
||||
Some(modal_overlay_1(appearance))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn text_color(
|
||||
&self,
|
||||
_hovered: bool,
|
||||
_background: Option<Fill>,
|
||||
_appearance: &Appearance,
|
||||
) -> ColorU {
|
||||
ColorU::white()
|
||||
}
|
||||
}
|
||||
|
||||
struct LearnMoreButtonTheme;
|
||||
|
||||
impl ActionButtonTheme for LearnMoreButtonTheme {
|
||||
fn background(&self, hovered: bool, appearance: &Appearance) -> Option<Fill> {
|
||||
if hovered {
|
||||
Some(modal_overlay_2(appearance))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn text_color(
|
||||
&self,
|
||||
_hovered: bool,
|
||||
_background: Option<Fill>,
|
||||
appearance: &Appearance,
|
||||
) -> ColorU {
|
||||
modal_text_main(appearance)
|
||||
}
|
||||
|
||||
fn border(&self, appearance: &Appearance) -> Option<ColorU> {
|
||||
Some(appearance.theme().outline().into_solid())
|
||||
}
|
||||
}
|
||||
|
||||
struct CtaButtonTheme;
|
||||
|
||||
impl ActionButtonTheme for CtaButtonTheme {
|
||||
fn background(&self, _hovered: bool, appearance: &Appearance) -> Option<Fill> {
|
||||
Some(Fill::Solid(appearance.theme().foreground().into_solid()))
|
||||
}
|
||||
|
||||
fn text_color(
|
||||
&self,
|
||||
_hovered: bool,
|
||||
_background: Option<Fill>,
|
||||
appearance: &Appearance,
|
||||
) -> ColorU {
|
||||
appearance.theme().background().into_solid()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct OrchestrationLaunchModal {
|
||||
close_button: ViewHandle<ActionButton>,
|
||||
learn_more_button: ViewHandle<ActionButton>,
|
||||
go_to_warp_button: ViewHandle<ActionButton>,
|
||||
}
|
||||
|
||||
impl OrchestrationLaunchModal {
|
||||
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(OrchestrationLaunchModalAction::Close))
|
||||
});
|
||||
|
||||
let learn_more_button = ctx.add_view(|_ctx| {
|
||||
ActionButton::new("Learn more", LearnMoreButtonTheme)
|
||||
.with_icon(Icon::LinkExternal)
|
||||
.with_full_width(true)
|
||||
.on_click(|ctx| {
|
||||
ctx.dispatch_typed_action(OrchestrationLaunchModalAction::LearnMore)
|
||||
})
|
||||
});
|
||||
|
||||
let go_to_warp_button = ctx.add_view(|_ctx| {
|
||||
ActionButton::new("Close", CtaButtonTheme)
|
||||
.with_full_width(true)
|
||||
.on_click(|ctx| ctx.dispatch_typed_action(OrchestrationLaunchModalAction::Close))
|
||||
});
|
||||
|
||||
Self {
|
||||
close_button,
|
||||
learn_more_button,
|
||||
go_to_warp_button,
|
||||
}
|
||||
}
|
||||
|
||||
fn render_hero(&self) -> Box<dyn Element> {
|
||||
let hero = Clipped::new(
|
||||
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(),
|
||||
)
|
||||
.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> {
|
||||
let text_color = modal_terminal_magenta(appearance);
|
||||
let background_color = modal_terminal_magenta_overlay_1(appearance);
|
||||
let text = Text::new_inline("New".to_string(), appearance.ui_font_family(), 14.)
|
||||
.with_color(text_color)
|
||||
.finish();
|
||||
ConstrainedBox::new(
|
||||
Container::new(
|
||||
Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_main_axis_size(MainAxisSize::Min)
|
||||
.with_child(text)
|
||||
.finish(),
|
||||
)
|
||||
.with_horizontal_padding(8.)
|
||||
.with_background(Fill::Solid(background_color))
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Percentage(50.)))
|
||||
.finish(),
|
||||
)
|
||||
.with_height(24.)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_title(appearance: &Appearance) -> Box<dyn Element> {
|
||||
Text::new(
|
||||
"Orchestrate any agent, anywhere",
|
||||
appearance.ui_font_family(),
|
||||
20.,
|
||||
)
|
||||
.with_color(modal_text_main(appearance))
|
||||
.with_style(Properties::default().weight(Weight::Semibold))
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_description(appearance: &Appearance) -> Box<dyn Element> {
|
||||
Text::new(
|
||||
"We've made major improvements to Warp's cloud agent orchestration platform, Oz.",
|
||||
appearance.ui_font_family(),
|
||||
14.,
|
||||
)
|
||||
.with_color(modal_text_sub(appearance))
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_feature_badge(label: &'static str, appearance: &Appearance) -> Box<dyn Element> {
|
||||
let font_family = appearance.ui_font_family();
|
||||
let color = modal_text_sub(appearance);
|
||||
Container::new(
|
||||
Text::new_inline(label.to_string(), font_family, 11.)
|
||||
.with_color(color)
|
||||
.finish(),
|
||||
)
|
||||
.with_horizontal_padding(6.)
|
||||
.with_vertical_padding(2.)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
|
||||
.with_background(modal_overlay_1(appearance))
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_feature_row(&self, item: &FeatureItem, appearance: &Appearance) -> Box<dyn Element> {
|
||||
let icon_el = ConstrainedBox::new(
|
||||
item.icon
|
||||
.to_warpui_icon(Fill::Solid(modal_text_sub(appearance)))
|
||||
.finish(),
|
||||
)
|
||||
.with_width(16.)
|
||||
.with_height(16.)
|
||||
.finish();
|
||||
|
||||
let mut title_row = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_spacing(6.);
|
||||
title_row.add_child(
|
||||
Text::new_inline(item.title.to_string(), appearance.ui_font_family(), 14.)
|
||||
.with_color(modal_text_main(appearance))
|
||||
.finish(),
|
||||
);
|
||||
if let Some(badge_label) = item.badge {
|
||||
title_row.add_child(Self::render_feature_badge(badge_label, appearance));
|
||||
}
|
||||
|
||||
let text_col = Flex::column()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
||||
.with_spacing(2.)
|
||||
.with_child(title_row.finish())
|
||||
.with_child(
|
||||
Text::new(item.description, appearance.ui_font_family(), 14.)
|
||||
.with_color(modal_text_sub(appearance))
|
||||
.finish(),
|
||||
)
|
||||
.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 footer = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_spacing(8.)
|
||||
.with_child(
|
||||
Expanded::new(1., ChildView::new(&self.learn_more_button).finish()).finish(),
|
||||
)
|
||||
.with_child(
|
||||
Expanded::new(1., ChildView::new(&self.go_to_warp_button).finish()).finish(),
|
||||
)
|
||||
.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(footer).with_margin_top(32.).finish())
|
||||
.finish(),
|
||||
)
|
||||
.with_horizontal_padding(32.)
|
||||
.with_vertical_padding(32.)
|
||||
.with_background(modal_background(appearance))
|
||||
.with_corner_radius(CornerRadius::with_bottom(Radius::Pixels(8.)))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for OrchestrationLaunchModal {
|
||||
type Event = OrchestrationLaunchModalEvent;
|
||||
}
|
||||
|
||||
impl View for OrchestrationLaunchModal {
|
||||
fn ui_name() -> &'static str {
|
||||
"OrchestrationLaunchModal"
|
||||
}
|
||||
|
||||
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(modal_background(appearance))
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
|
||||
.finish(),
|
||||
)
|
||||
.with_width(MODAL_WIDTH)
|
||||
.finish();
|
||||
|
||||
Container::new(Align::new(card).finish())
|
||||
.with_background(Fill::Solid(ColorU::new(97, 97, 97, 255)).with_opacity(50))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for OrchestrationLaunchModal {
|
||||
type Action = OrchestrationLaunchModalAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
OrchestrationLaunchModalAction::Close => {
|
||||
ctx.emit(OrchestrationLaunchModalEvent::Close);
|
||||
}
|
||||
OrchestrationLaunchModalAction::LearnMore => {
|
||||
ctx.open_url(LEARN_MORE_URL);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,64 +1,63 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use dunce::canonicalize;
|
||||
use itertools::Itertools;
|
||||
use pathfinder_color::ColorU;
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxy_core::ui::Icon;
|
||||
use warp_util::path::LineAndColumnArg;
|
||||
use warpui::elements::{
|
||||
resizable_state_handle, ChildAnchor, ChildView, Clipped, ConstrainedBox, Container,
|
||||
CrossAxisAlignment, DragBarSide, Element, Empty, Flex, MainAxisAlignment, MainAxisSize,
|
||||
MouseStateHandle, ParentElement, PositionedElementAnchor, Resizable, ResizableStateHandle,
|
||||
Shrinkable, Text,
|
||||
};
|
||||
use warpui::fonts::{Properties, Weight};
|
||||
use warpui::keymap::EditableBinding;
|
||||
use warpui::platform::Cursor;
|
||||
use warpui::ui_components::components::UiComponent;
|
||||
use warpui::{
|
||||
AppContext, Entity, EntityId, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext,
|
||||
ViewHandle, WeakViewHandle,
|
||||
};
|
||||
|
||||
use crate::ai::agent::AgentReviewCommentBatch;
|
||||
use crate::appearance::{Appearance, AppearanceEvent};
|
||||
use crate::code::buffer_location::LocalOrRemotePath;
|
||||
use crate::code_review::code_review_header::HEADER_BUTTON_PADDING;
|
||||
#[cfg(feature = "local_fs")]
|
||||
use crate::code_review::code_review_view::CodeReviewAction;
|
||||
use crate::code_review::code_review_view::{
|
||||
render_file_navigation_button, CodeReviewView, CONTENT_LEFT_MARGIN, CONTENT_RIGHT_MARGIN,
|
||||
render_file_navigation_button, CodeReviewCommentDebugState, CodeReviewView,
|
||||
CodeReviewViewEvent, ReviewActionTargetProvider, CONTENT_LEFT_MARGIN, CONTENT_RIGHT_MARGIN,
|
||||
};
|
||||
use crate::code_review::code_review_view::{CodeReviewCommentDebugState, CodeReviewViewEvent};
|
||||
use crate::code_review::diff_state::DiffStateModel;
|
||||
use crate::code_review::telemetry_event::CodeReviewContextDestination;
|
||||
use crate::pane_group::pane::view::header::{components::HEADER_EDGE_PADDING, PANE_HEADER_HEIGHT};
|
||||
use crate::pane_group::WorkingDirectoriesEvent;
|
||||
use crate::pane_group::{Event as PaneGroupEvent, PaneGroup, WorkingDirectoriesModel};
|
||||
use crate::drive::panel::{MAX_SIDEBAR_WIDTH_RATIO, MIN_SIDEBAR_WIDTH};
|
||||
use crate::pane_group::pane::view::header::components::HEADER_EDGE_PADDING;
|
||||
use crate::pane_group::pane::view::header::PANE_HEADER_HEIGHT;
|
||||
use crate::pane_group::{
|
||||
Event as PaneGroupEvent, PaneGroup, WorkingDirectoriesEvent, WorkingDirectoriesModel,
|
||||
};
|
||||
use crate::settings::{AISettings, AISettingsChangedEvent};
|
||||
use crate::terminal::cli_agent_sessions::CLIAgentSessionsModel;
|
||||
use crate::terminal::input::MenuPositioning;
|
||||
use crate::terminal::resizable_data::{ModalType, ResizableData};
|
||||
use crate::terminal::view::TerminalView;
|
||||
use crate::terminal::CLIAgent;
|
||||
use crate::ui_components::{buttons::icon_button_with_color, icons};
|
||||
use crate::ui_components::buttons::icon_button_with_color;
|
||||
use crate::ui_components::icons;
|
||||
use crate::util::bindings::{keybinding_name_to_display_string, CustomAction};
|
||||
#[cfg(feature = "local_fs")]
|
||||
use crate::util::openable_file_type::FileTarget;
|
||||
use crate::util::path::{display_name_with_host, display_path_with_host};
|
||||
use crate::view_components::action_button::{ActionButton, PaneHeaderTheme};
|
||||
#[cfg(feature = "local_fs")]
|
||||
use crate::view_components::action_button::{NakedTheme, TooltipAlignment};
|
||||
use crate::view_components::{Dropdown, DropdownItem};
|
||||
use crate::workspace::view::TOGGLE_RIGHT_PANEL_BINDING_NAME;
|
||||
use crate::workspace::WorkspaceAction;
|
||||
use crate::{
|
||||
appearance::Appearance,
|
||||
drive::panel::{MAX_SIDEBAR_WIDTH_RATIO, MIN_SIDEBAR_WIDTH},
|
||||
terminal::resizable_data::{ModalType, ResizableData},
|
||||
};
|
||||
use crate::{code_review::diff_state::DiffStateModel, terminal::view::TerminalView};
|
||||
use dunce::canonicalize;
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxy_core::ui::Icon;
|
||||
use galaxy_util::path::LineAndColumnArg;
|
||||
use galaxyui::elements::{ChildAnchor, Empty, PositionedElementAnchor};
|
||||
use galaxyui::keymap::EditableBinding;
|
||||
use galaxyui::EntityId;
|
||||
use galaxyui::{
|
||||
elements::{
|
||||
resizable_state_handle, Container, DragBarSide, Element, MainAxisSize, MouseStateHandle,
|
||||
Resizable, ResizableStateHandle,
|
||||
},
|
||||
AppContext, Entity, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext,
|
||||
ViewHandle, WeakViewHandle,
|
||||
};
|
||||
use galaxyui::{
|
||||
elements::{
|
||||
ChildView, Clipped, ConstrainedBox, CrossAxisAlignment, Flex, MainAxisAlignment,
|
||||
ParentElement, Shrinkable, Text,
|
||||
},
|
||||
fonts::{Properties, Weight},
|
||||
platform::Cursor,
|
||||
ui_components::components::UiComponent,
|
||||
};
|
||||
use itertools::Itertools;
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
/// Describes which agent destination is available for sending review comments.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -107,7 +106,7 @@ impl ReviewTerminalUnavailableReason {
|
||||
#[derive(Debug)]
|
||||
struct ReviewTerminalStatus {
|
||||
active_session_path: Option<PathBuf>,
|
||||
current_repo_path: Option<PathBuf>,
|
||||
current_repo_path: Option<LocalOrRemotePath>,
|
||||
active_cli_agent: Option<String>,
|
||||
is_executing: bool,
|
||||
is_input_box_visible: bool,
|
||||
@@ -119,13 +118,71 @@ impl ReviewTerminalStatus {
|
||||
}
|
||||
}
|
||||
|
||||
/// `ReviewActionTargetProvider` backed by the right panel's active pane group,
|
||||
/// so code review actions resolve their target terminal at action time instead
|
||||
/// of using a handle captured when the review view was created.
|
||||
struct RightPanelReviewActionTargetProvider {
|
||||
right_panel: WeakViewHandle<RightPanelView>,
|
||||
}
|
||||
|
||||
impl ReviewActionTargetProvider for RightPanelReviewActionTargetProvider {
|
||||
fn attach_terminal(
|
||||
&self,
|
||||
repo_path: &LocalOrRemotePath,
|
||||
app: &AppContext,
|
||||
) -> Option<ViewHandle<TerminalView>> {
|
||||
let right_panel = self.right_panel.upgrade(app)?;
|
||||
right_panel.read(app, |panel, app| {
|
||||
let pane_group = panel.active_pane_group.as_ref()?;
|
||||
let ai_enabled = AISettings::as_ref(app).is_any_ai_enabled(app);
|
||||
panel
|
||||
.find_review_terminal(pane_group, repo_path, ai_enabled, app)
|
||||
.or_else(|| {
|
||||
// No terminal is available (e.g. all candidates are
|
||||
// executing). Fall back to the focused terminal when it is
|
||||
// inside the repo, so per-action handling for busy
|
||||
// terminals still targets the focused conversation.
|
||||
let focused = pane_group
|
||||
.read(app, |pane_group, app| pane_group.focused_session_view(app))?;
|
||||
let status = RightPanelView::review_terminal_status(
|
||||
&focused,
|
||||
Some(repo_path),
|
||||
ai_enabled,
|
||||
app,
|
||||
);
|
||||
let in_repo = !status.unavailable_reasons.iter().any(|reason| {
|
||||
matches!(
|
||||
reason,
|
||||
ReviewTerminalUnavailableReason::NoSelectedRepo
|
||||
| ReviewTerminalUnavailableReason::SessionPathUnavailable
|
||||
| ReviewTerminalUnavailableReason::SessionOutsideSelectedRepo
|
||||
)
|
||||
});
|
||||
in_repo.then_some(focused)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn focused_terminal(&self, app: &AppContext) -> Option<ViewHandle<TerminalView>> {
|
||||
let right_panel = self.right_panel.upgrade(app)?;
|
||||
right_panel.read(app, |panel, app| {
|
||||
let pane_group = panel.active_pane_group.as_ref()?;
|
||||
pane_group.read(app, |pane_group, app| {
|
||||
pane_group
|
||||
.focused_session_view(app)
|
||||
.or_else(|| pane_group.active_session_view(app))
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct CodeReviewState {
|
||||
dropdown: ViewHandle<Dropdown<RightPanelAction>>,
|
||||
available_repos: Vec<PathBuf>,
|
||||
available_repos: Vec<LocalOrRemotePath>,
|
||||
/// The repository path of the focused terminal
|
||||
focused_repo_path: Option<PathBuf>,
|
||||
focused_repo_path: Option<LocalOrRemotePath>,
|
||||
/// The repository path of the repository selected in the dropdown
|
||||
selected_repo_path: Option<PathBuf>,
|
||||
selected_repo_path: Option<LocalOrRemotePath>,
|
||||
/// Avoid showing the jump-to-repo button if the focused repo has not changed
|
||||
did_focused_repo_change: bool,
|
||||
}
|
||||
@@ -136,16 +193,27 @@ struct CodeReviewSessionEnv {
|
||||
is_wsl: bool,
|
||||
}
|
||||
|
||||
/// Resolve the repo-switcher dropdown's text color from the current theme.
|
||||
/// Kept as a free function so the construction site and the
|
||||
/// `AppearanceEvent::ThemeChanged` subscription compute the exact same color.
|
||||
fn repo_dropdown_font_color(appearance: &Appearance) -> ColorU {
|
||||
appearance
|
||||
.theme()
|
||||
.sub_text_color(appearance.theme().background())
|
||||
.into_solid()
|
||||
}
|
||||
|
||||
impl CodeReviewState {
|
||||
pub fn new(ctx: &mut ViewContext<RightPanelView>) -> Self {
|
||||
CodeReviewState {
|
||||
dropdown: ctx.add_typed_action_view(|ctx| {
|
||||
let appearance = Appearance::as_ref(ctx);
|
||||
let font_color = appearance
|
||||
.theme()
|
||||
.sub_text_color(appearance.theme().background())
|
||||
.into_solid();
|
||||
let ui_font_size = appearance.ui_font_size();
|
||||
let (font_color, ui_font_size) = {
|
||||
let appearance = Appearance::as_ref(ctx);
|
||||
(
|
||||
repo_dropdown_font_color(appearance),
|
||||
appearance.ui_font_size(),
|
||||
)
|
||||
};
|
||||
let mut dropdown = Dropdown::new(ctx);
|
||||
dropdown.set_menu_position(
|
||||
PositionedElementAnchor::BottomRight,
|
||||
@@ -158,6 +226,19 @@ impl CodeReviewState {
|
||||
dropdown.set_vertical_margin(0., ctx);
|
||||
dropdown.set_top_bar_height(galaxy_core::ui::icons::ICON_DIMENSIONS, ctx);
|
||||
dropdown.set_padding(HEADER_BUTTON_PADDING, ctx);
|
||||
|
||||
// The font color above is derived from the active theme and
|
||||
// cached inside the dropdown. Without this subscription, the
|
||||
// cached value goes stale across light/dark switches and the
|
||||
// header label becomes unreadable on the new background
|
||||
// (e.g. white-on-white in light mode after starting in dark).
|
||||
ctx.subscribe_to_model(&Appearance::handle(ctx), |dropdown, _, event, ctx| {
|
||||
if matches!(event, AppearanceEvent::ThemeChanged) {
|
||||
let font_color = repo_dropdown_font_color(Appearance::as_ref(ctx));
|
||||
dropdown.set_font_color(font_color, ctx);
|
||||
}
|
||||
});
|
||||
|
||||
dropdown
|
||||
}),
|
||||
available_repos: vec![],
|
||||
@@ -170,13 +251,17 @@ impl CodeReviewState {
|
||||
#[cfg(not(feature = "local_fs"))]
|
||||
fn set_available_repos(
|
||||
&mut self,
|
||||
_repos: Vec<PathBuf>,
|
||||
_repos: Vec<LocalOrRemotePath>,
|
||||
_ctx: &mut ViewContext<RightPanelView>,
|
||||
) {
|
||||
}
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
fn set_available_repos(&mut self, repos: Vec<PathBuf>, ctx: &mut ViewContext<RightPanelView>) {
|
||||
fn set_available_repos(
|
||||
&mut self,
|
||||
repos: Vec<LocalOrRemotePath>,
|
||||
ctx: &mut ViewContext<RightPanelView>,
|
||||
) {
|
||||
let should_clear = self
|
||||
.selected_repo_path
|
||||
.as_ref()
|
||||
@@ -200,19 +285,23 @@ impl CodeReviewState {
|
||||
#[cfg(not(feature = "local_fs"))]
|
||||
pub fn set_selected_repo(
|
||||
&mut self,
|
||||
_repo_path: PathBuf,
|
||||
_repo_path: LocalOrRemotePath,
|
||||
_ctx: &mut ViewContext<RightPanelView>,
|
||||
) {
|
||||
}
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub fn set_selected_repo(&mut self, repo_path: PathBuf, ctx: &mut ViewContext<RightPanelView>) {
|
||||
pub fn set_selected_repo(
|
||||
&mut self,
|
||||
repo_path: LocalOrRemotePath,
|
||||
ctx: &mut ViewContext<RightPanelView>,
|
||||
) {
|
||||
self.set_selected_repo_internal(repo_path, true, ctx);
|
||||
}
|
||||
|
||||
pub fn set_focused_repo(
|
||||
&mut self,
|
||||
repo_path: Option<PathBuf>,
|
||||
repo_path: Option<LocalOrRemotePath>,
|
||||
ctx: &mut ViewContext<RightPanelView>,
|
||||
) {
|
||||
self.did_focused_repo_change = true;
|
||||
@@ -226,10 +315,13 @@ impl CodeReviewState {
|
||||
#[cfg(feature = "local_fs")]
|
||||
fn set_selected_repo_internal(
|
||||
&mut self,
|
||||
repo_path: PathBuf,
|
||||
repo_path: LocalOrRemotePath,
|
||||
update_dropdown: bool,
|
||||
ctx: &mut ViewContext<RightPanelView>,
|
||||
) {
|
||||
if repo_path.is_remote() && !FeatureFlag::RemoteCodeReview.is_enabled() {
|
||||
return;
|
||||
}
|
||||
if self.selected_repo_path.as_ref() == Some(&repo_path) {
|
||||
return;
|
||||
}
|
||||
@@ -246,11 +338,13 @@ impl CodeReviewState {
|
||||
}
|
||||
|
||||
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
|
||||
fn get_repo_display_name(&self, repo_path: &Path) -> Option<String> {
|
||||
repo_path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.map(|name| name.to_string())
|
||||
fn get_repo_display_name(
|
||||
&self,
|
||||
repo_path: &LocalOrRemotePath,
|
||||
ctx: &AppContext,
|
||||
) -> Option<String> {
|
||||
let name = display_name_with_host(repo_path, ctx);
|
||||
(!name.is_empty()).then_some(name)
|
||||
}
|
||||
|
||||
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
|
||||
@@ -262,7 +356,7 @@ impl CodeReviewState {
|
||||
.iter()
|
||||
.map(|repo_path| {
|
||||
let display_name = self
|
||||
.get_repo_display_name(repo_path)
|
||||
.get_repo_display_name(repo_path, ctx)
|
||||
.unwrap_or_else(|| "Unknown".to_string());
|
||||
DropdownItem::new(
|
||||
display_name,
|
||||
@@ -277,7 +371,7 @@ impl CodeReviewState {
|
||||
let selected_display_name = self
|
||||
.selected_repo_path
|
||||
.as_ref()
|
||||
.and_then(|selected| self.get_repo_display_name(selected));
|
||||
.and_then(|selected| self.get_repo_display_name(selected, ctx));
|
||||
|
||||
(items, selected_display_name)
|
||||
};
|
||||
@@ -294,12 +388,12 @@ impl CodeReviewState {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
#[cfg_attr(not(feature = "local_fs"), allow(dead_code))]
|
||||
pub enum RightPanelAction {
|
||||
ToggleFileSidebar,
|
||||
SelectRepo {
|
||||
repo_path: PathBuf,
|
||||
repo_path: LocalOrRemotePath,
|
||||
from_dropdown: bool,
|
||||
},
|
||||
OpenRepository,
|
||||
@@ -317,7 +411,7 @@ pub enum RightPanelEvent {
|
||||
line_col: Option<LineAndColumnArg>,
|
||||
},
|
||||
OpenFileInNewTab {
|
||||
path: PathBuf,
|
||||
path: LocalOrRemotePath,
|
||||
line_and_column: Option<LineAndColumnArg>,
|
||||
},
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
@@ -464,14 +558,18 @@ impl RightPanelView {
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
pub fn selected_repo_path(&self) -> Option<&PathBuf> {
|
||||
pub fn selected_repo_path(&self) -> Option<&LocalOrRemotePath> {
|
||||
self.code_review_state
|
||||
.as_ref()
|
||||
.and_then(|s| s.selected_repo_path.as_ref())
|
||||
}
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub fn update_selected_repo(&mut self, repo_path: PathBuf, ctx: &mut ViewContext<Self>) {
|
||||
pub fn update_selected_repo(
|
||||
&mut self,
|
||||
repo_path: LocalOrRemotePath,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
self.handle_action(
|
||||
&RightPanelAction::SelectRepo {
|
||||
repo_path,
|
||||
@@ -503,7 +601,7 @@ impl RightPanelView {
|
||||
.and_then(|s| s.selected_repo_path.clone());
|
||||
|
||||
if let Some(state) = self.code_review_state.as_mut() {
|
||||
state.set_available_repos(repositories.to_owned(), ctx);
|
||||
state.set_available_repos(repositories.clone(), ctx);
|
||||
}
|
||||
|
||||
let new_selected = self
|
||||
@@ -570,12 +668,22 @@ impl RightPanelView {
|
||||
self.active_pane_group = Some(pane_group);
|
||||
|
||||
if let Some(state) = &mut self.code_review_state {
|
||||
let active_repositories = working_directories_model.read(ctx, |model, _| {
|
||||
model
|
||||
.most_recent_repositories_for_pane_group(pane_group_id)
|
||||
.map(|repos| repos.collect())
|
||||
.unwrap_or_default()
|
||||
});
|
||||
let (active_repositories, saved_selection) =
|
||||
working_directories_model.read(ctx, |model, _| {
|
||||
let repos: Vec<LocalOrRemotePath> = model
|
||||
.most_recent_repositories_for_pane_group(pane_group_id)
|
||||
.map(|repos| repos.collect())
|
||||
.unwrap_or_default();
|
||||
let saved = model.get_selected_review_repo(pane_group_id).cloned();
|
||||
(repos, saved)
|
||||
});
|
||||
|
||||
// Replace the carried-over selection from a different pane group
|
||||
// with whatever was saved for this pane group (if anything). This
|
||||
// ensures `set_available_repos` either keeps the saved selection
|
||||
// (when it's still in the repo list) or falls back to auto-selecting
|
||||
// the first repo, instead of preserving the previous tab's repo.
|
||||
state.selected_repo_path = saved_selection;
|
||||
state.set_available_repos(active_repositories, ctx);
|
||||
}
|
||||
|
||||
@@ -598,9 +706,8 @@ impl RightPanelView {
|
||||
/// Will only update repo_path if one is not already set
|
||||
pub fn open_code_review(
|
||||
&mut self,
|
||||
repo_path: Option<PathBuf>,
|
||||
repo_path: Option<LocalOrRemotePath>,
|
||||
diff_state_model: ModelHandle<DiffStateModel>,
|
||||
terminal_view: WeakViewHandle<TerminalView>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
let Some(repo_dropdown_state) = &mut self.code_review_state else {
|
||||
@@ -610,6 +717,9 @@ impl RightPanelView {
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if repo_path.is_remote() && !FeatureFlag::RemoteCodeReview.is_enabled() {
|
||||
return;
|
||||
}
|
||||
let pane_group_id = active_pane_group.id();
|
||||
|
||||
if repo_dropdown_state.selected_repo_path.is_none() {
|
||||
@@ -622,19 +732,14 @@ impl RightPanelView {
|
||||
.get_code_review_view(pane_group_id, repo_path);
|
||||
if let Some(view) = existing_view {
|
||||
view.update(ctx, |view, ctx| {
|
||||
view.set_terminal_view(terminal_view);
|
||||
view.on_open(Some(repo_path.clone()), ctx);
|
||||
view.on_open(ctx);
|
||||
});
|
||||
self.recompute_terminal_availability(ctx);
|
||||
} else if let Some(view) = self.create_code_review_view(
|
||||
repo_path,
|
||||
diff_state_model.clone(),
|
||||
pane_group_id,
|
||||
terminal_view.clone(),
|
||||
ctx,
|
||||
) {
|
||||
} else if let Some(view) =
|
||||
self.create_code_review_view(repo_path, diff_state_model.clone(), pane_group_id, ctx)
|
||||
{
|
||||
view.update(ctx, |view, ctx| {
|
||||
view.on_open(Some(repo_path.clone()), ctx);
|
||||
view.on_open(ctx);
|
||||
});
|
||||
self.recompute_terminal_availability(ctx);
|
||||
};
|
||||
@@ -646,7 +751,7 @@ impl RightPanelView {
|
||||
fn close_code_review_view(
|
||||
&self,
|
||||
pane_group_id: EntityId,
|
||||
repo_path: &Path,
|
||||
repo_path: &LocalOrRemotePath,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
if let Some(code_review_view) = self
|
||||
@@ -771,27 +876,34 @@ impl RightPanelView {
|
||||
.finish();
|
||||
};
|
||||
|
||||
let selected_repo_path = state
|
||||
.selected_repo_path
|
||||
.as_ref()
|
||||
.filter(|repo_path| state.available_repos.contains(repo_path));
|
||||
let selected_repo_path = state.selected_repo_path.as_ref().filter(|repo_path| {
|
||||
if repo_path.is_remote() {
|
||||
FeatureFlag::RemoteCodeReview.is_enabled()
|
||||
} else {
|
||||
state.available_repos.contains(repo_path)
|
||||
}
|
||||
});
|
||||
|
||||
let Some(selected_repo_path) = selected_repo_path else {
|
||||
let simple_header = self.render_simple_header(close_button);
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
let no_repo_body = {
|
||||
let button = Some(ChildView::new(&self.open_repository_button).finish());
|
||||
let open_repo_button =
|
||||
|| Some(ChildView::new(&self.open_repository_button).finish());
|
||||
if let Some(env) = &self.code_review_session_env {
|
||||
if env.is_remote {
|
||||
CodeReviewView::render_remote_state(appearance, button)
|
||||
// No "Open repository" CTA when the session is remote — the
|
||||
// button navigates to a local folder, which is not meaningful
|
||||
// in a remote session.
|
||||
CodeReviewView::render_remote_state(appearance, None)
|
||||
} else if env.is_wsl {
|
||||
CodeReviewView::render_wsl_state(appearance, button)
|
||||
CodeReviewView::render_wsl_state(appearance, open_repo_button())
|
||||
} else {
|
||||
CodeReviewView::render_not_repo_state(appearance, button)
|
||||
CodeReviewView::render_not_repo_state(appearance, open_repo_button())
|
||||
}
|
||||
} else {
|
||||
CodeReviewView::render_not_repo_state(appearance, button)
|
||||
CodeReviewView::render_not_repo_state(appearance, open_repo_button())
|
||||
}
|
||||
};
|
||||
|
||||
@@ -856,14 +968,11 @@ impl RightPanelView {
|
||||
let repo_path = crv.repo_path();
|
||||
let branch_name = crv
|
||||
.diff_state_model()
|
||||
.read(app, |model, _| model.get_current_branch_name());
|
||||
.read(app, |model, ctx| model.get_current_branch_name(ctx));
|
||||
let diff_stats = crv.loaded_diff_stats();
|
||||
|
||||
let repo_path_element = repo_path.map(|repo_path| {
|
||||
let display_path = dirs::home_dir()
|
||||
.and_then(|home| repo_path.strip_prefix(&home).ok())
|
||||
.map(|relative| format!("~/{}", relative.display()))
|
||||
.unwrap_or_else(|| repo_path.display().to_string());
|
||||
let display_path = display_path_with_host(repo_path, true, app);
|
||||
Container::new(
|
||||
Text::new_inline(
|
||||
format!("{display_path}:"),
|
||||
@@ -1097,18 +1206,22 @@ impl RightPanelView {
|
||||
|
||||
fn create_code_review_view(
|
||||
&self,
|
||||
repo_path: &Path,
|
||||
repo_path: &LocalOrRemotePath,
|
||||
diff_state_model: ModelHandle<DiffStateModel>,
|
||||
pane_group_id: EntityId,
|
||||
terminal_view: WeakViewHandle<TerminalView>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Option<ViewHandle<CodeReviewView>> {
|
||||
// Early check: if pane group has no active repositories, don't create a view
|
||||
let has_active_repos = self
|
||||
.working_directories_model
|
||||
.as_ref(ctx)
|
||||
.most_recent_repositories_for_pane_group(pane_group_id)
|
||||
.is_some_and(|repos| repos.count() > 0);
|
||||
// Early check: if pane group has no active repositories, don't create a view.
|
||||
// Remote repos require the RemoteCodeReview feature flag; local repos go
|
||||
// through the active-repos check.
|
||||
let has_active_repos = if repo_path.is_remote() {
|
||||
FeatureFlag::RemoteCodeReview.is_enabled()
|
||||
} else {
|
||||
self.working_directories_model
|
||||
.as_ref(ctx)
|
||||
.most_recent_repositories_for_pane_group(pane_group_id)
|
||||
.is_some_and(|mut repos| repos.any(|r| &r == repo_path))
|
||||
};
|
||||
|
||||
if !has_active_repos {
|
||||
return None;
|
||||
@@ -1120,12 +1233,16 @@ impl RightPanelView {
|
||||
.update(ctx, |working_directories, ctx| {
|
||||
working_directories.get_or_create_code_review_comments(repo_path, ctx)
|
||||
});
|
||||
let action_target_provider: Box<dyn ReviewActionTargetProvider> =
|
||||
Box::new(RightPanelReviewActionTargetProvider {
|
||||
right_panel: ctx.handle(),
|
||||
});
|
||||
let code_review_view = ctx.add_typed_action_view(|ctx| {
|
||||
CodeReviewView::new(
|
||||
Some(repo_path.to_path_buf()),
|
||||
Some(repo_path.clone()),
|
||||
diff_state_model_clone,
|
||||
code_review_comment_batch,
|
||||
Some(terminal_view),
|
||||
Some(action_target_provider),
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
@@ -1134,7 +1251,7 @@ impl RightPanelView {
|
||||
self.working_directories_model.update(ctx, |model, _ctx| {
|
||||
model.store_code_review_view(
|
||||
pane_group_id,
|
||||
repo_path.to_path_buf(),
|
||||
repo_path.clone(),
|
||||
code_review_view.clone(),
|
||||
);
|
||||
});
|
||||
@@ -1198,7 +1315,7 @@ impl RightPanelView {
|
||||
&mut self,
|
||||
code_review_view: &ViewHandle<CodeReviewView>,
|
||||
comments: AgentReviewCommentBatch,
|
||||
repo_path: &Path,
|
||||
repo_path: &LocalOrRemotePath,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
let Some(pane_group) = &self.active_pane_group else {
|
||||
@@ -1226,7 +1343,7 @@ impl RightPanelView {
|
||||
.filter_map(|c| {
|
||||
c.target
|
||||
.absolute_file_path()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.map(LocalOrRemotePath::display_path)
|
||||
})
|
||||
.collect::<std::collections::HashSet<_>>()
|
||||
.len();
|
||||
@@ -1274,9 +1391,14 @@ impl RightPanelView {
|
||||
.unwrap_or_else(|| "<none>".to_string())
|
||||
}
|
||||
|
||||
fn format_optional_location(path: Option<&LocalOrRemotePath>) -> String {
|
||||
path.map(LocalOrRemotePath::display_path)
|
||||
.unwrap_or_else(|| "<none>".to_string())
|
||||
}
|
||||
|
||||
fn review_terminal_status(
|
||||
tv: &ViewHandle<TerminalView>,
|
||||
repo_path: Option<&Path>,
|
||||
repo_path: Option<&LocalOrRemotePath>,
|
||||
ai_enabled: bool,
|
||||
ctx: &AppContext,
|
||||
) -> ReviewTerminalStatus {
|
||||
@@ -1290,16 +1412,25 @@ impl RightPanelView {
|
||||
let mut unavailable_reasons = Vec::new();
|
||||
|
||||
match repo_path {
|
||||
Some(repo_path) => match active_session_path.as_ref() {
|
||||
// Canonicalize the CWD, note that repo_path has already been canonicalized.
|
||||
Some(cwd)
|
||||
if canonicalize(cwd)
|
||||
.as_deref()
|
||||
.unwrap_or(cwd)
|
||||
.starts_with(repo_path) => {}
|
||||
Some(_) => unavailable_reasons
|
||||
Some(repo_path) => match (repo_path, t.current_repo_path()) {
|
||||
(LocalOrRemotePath::Local(repo_path), _) => {
|
||||
match active_session_path.as_ref() {
|
||||
Some(cwd)
|
||||
if canonicalize(cwd)
|
||||
.as_deref()
|
||||
.unwrap_or(cwd)
|
||||
.starts_with(repo_path) => {}
|
||||
Some(_) => unavailable_reasons
|
||||
.push(ReviewTerminalUnavailableReason::SessionOutsideSelectedRepo),
|
||||
None => unavailable_reasons
|
||||
.push(ReviewTerminalUnavailableReason::SessionPathUnavailable),
|
||||
}
|
||||
}
|
||||
(repo_path @ LocalOrRemotePath::Remote(_), Some(current_repo_path))
|
||||
if repo_path.strip_repo_prefix(current_repo_path).is_some() => {}
|
||||
(LocalOrRemotePath::Remote(_), Some(_)) => unavailable_reasons
|
||||
.push(ReviewTerminalUnavailableReason::SessionOutsideSelectedRepo),
|
||||
None => unavailable_reasons
|
||||
(LocalOrRemotePath::Remote(_), None) => unavailable_reasons
|
||||
.push(ReviewTerminalUnavailableReason::SessionPathUnavailable),
|
||||
},
|
||||
None => unavailable_reasons.push(ReviewTerminalUnavailableReason::NoSelectedRepo),
|
||||
@@ -1331,7 +1462,7 @@ impl RightPanelView {
|
||||
fn log_code_review_debug_state(debug_state: &CodeReviewCommentDebugState) {
|
||||
log::info!(
|
||||
"Active code review view: repo_path={}, has_active_comment_model={}, review_destination={:?}, total_comments={}, sendable_comments={}, is_collapsed={}, is_outdated_section_collapsed={:?}, ai_available={}, ai_enabled={}, send_button_tooltip={}",
|
||||
Self::format_optional_path(debug_state.repo_path.as_deref()),
|
||||
Self::format_optional_location(debug_state.repo_path.as_ref()),
|
||||
debug_state.has_active_comment_model,
|
||||
debug_state.comment_list.review_destination,
|
||||
debug_state.comment_list.total_comments,
|
||||
@@ -1356,7 +1487,7 @@ impl RightPanelView {
|
||||
let Some(pane_group) = &self.active_pane_group else {
|
||||
log::info!(
|
||||
"Review comment send status for active tab: no active pane group, selected_repo_path={}, ai_enabled={}",
|
||||
Self::format_optional_path(selected_repo_path.as_deref()),
|
||||
Self::format_optional_location(selected_repo_path.as_ref()),
|
||||
ai_enabled,
|
||||
);
|
||||
if let Some(debug_state) = &code_review_debug_state {
|
||||
@@ -1381,7 +1512,7 @@ impl RightPanelView {
|
||||
|
||||
log::info!(
|
||||
"Review comment send status for active tab: pane_group_id={pane_group_id}, selected_repo_path={}, ai_enabled={}, focused_pane_id={focused_pane_id}, preferred_terminal_id={preferred_terminal_id:?}, chosen_terminal_id={chosen_terminal_id:?}, visible_pane_count={}",
|
||||
Self::format_optional_path(selected_repo_path.as_deref()),
|
||||
Self::format_optional_location(selected_repo_path.as_ref()),
|
||||
ai_enabled,
|
||||
visible_pane_ids.len(),
|
||||
);
|
||||
@@ -1418,7 +1549,7 @@ impl RightPanelView {
|
||||
let terminal_id = terminal_view.id();
|
||||
let terminal_status = Self::review_terminal_status(
|
||||
&terminal_view,
|
||||
selected_repo_path.as_deref(),
|
||||
selected_repo_path.as_ref(),
|
||||
ai_enabled,
|
||||
ctx,
|
||||
);
|
||||
@@ -1439,7 +1570,7 @@ impl RightPanelView {
|
||||
chosen_terminal_id == Some(terminal_id),
|
||||
terminal_status.is_available(),
|
||||
Self::format_optional_path(terminal_status.active_session_path.as_deref()),
|
||||
Self::format_optional_path(terminal_status.current_repo_path.as_deref()),
|
||||
Self::format_optional_location(terminal_status.current_repo_path.as_ref()),
|
||||
terminal_status
|
||||
.active_cli_agent
|
||||
.as_deref()
|
||||
@@ -1460,7 +1591,7 @@ impl RightPanelView {
|
||||
/// considered available (non-CLI Warp terminals require AI to be on).
|
||||
fn is_terminal_available_for_review(
|
||||
tv: &ViewHandle<TerminalView>,
|
||||
repo_path: &Path,
|
||||
repo_path: &LocalOrRemotePath,
|
||||
ai_enabled: bool,
|
||||
ctx: &AppContext,
|
||||
) -> bool {
|
||||
@@ -1474,7 +1605,7 @@ impl RightPanelView {
|
||||
terminal_views: &[ViewHandle<TerminalView>],
|
||||
focused_terminal: Option<&ViewHandle<TerminalView>>,
|
||||
preferred_terminal_id: Option<EntityId>,
|
||||
repo_path: &Path,
|
||||
repo_path: &LocalOrRemotePath,
|
||||
ai_enabled: bool,
|
||||
ctx: &AppContext,
|
||||
) -> Option<ViewHandle<TerminalView>> {
|
||||
@@ -1508,11 +1639,11 @@ impl RightPanelView {
|
||||
fn find_review_terminal(
|
||||
&self,
|
||||
pane_group: &ViewHandle<PaneGroup>,
|
||||
repo_path: &Path,
|
||||
repo_path: &LocalOrRemotePath,
|
||||
ai_enabled: bool,
|
||||
ctx: &AppContext,
|
||||
) -> Option<ViewHandle<TerminalView>> {
|
||||
let terminal_views = pane_group.read(ctx, |pg, ctx| pg.terminal_views(ctx));
|
||||
let terminal_views = pane_group.read(ctx, |pg, ctx| pg.visible_terminal_views(ctx));
|
||||
let focused_terminal = pane_group.read(ctx, |pg, ctx| pg.focused_session_view(ctx));
|
||||
let pane_group_id = pane_group.id();
|
||||
let preferred_terminal_id = self
|
||||
@@ -1570,7 +1701,14 @@ impl RightPanelView {
|
||||
});
|
||||
}
|
||||
|
||||
fn ensure_code_review_view_exists(&mut self, repo_path: &Path, ctx: &mut ViewContext<Self>) {
|
||||
fn ensure_code_review_view_exists(
|
||||
&mut self,
|
||||
repo_path: &LocalOrRemotePath,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
if repo_path.is_remote() && !FeatureFlag::RemoteCodeReview.is_enabled() {
|
||||
return;
|
||||
}
|
||||
let Some(pane_group) = &self.active_pane_group else {
|
||||
return;
|
||||
};
|
||||
@@ -1588,46 +1726,59 @@ impl RightPanelView {
|
||||
if is_panel_open {
|
||||
// on_open is idempotent (guards on is_open), so this is safe for
|
||||
// already-open views and correctly re-opens cached-but-closed ones.
|
||||
let repo_path = repo_path.to_path_buf();
|
||||
view.update(ctx, |view, ctx| {
|
||||
view.on_open(Some(repo_path), ctx);
|
||||
view.on_open(ctx);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Prefer the pane group's active session so the diff request rides
|
||||
// the connection actually showing the review; the manager falls
|
||||
// back to any connected session for the host when unavailable.
|
||||
let preferred_session = pane_group
|
||||
.read(ctx, |pg, ctx| pg.active_session_view(ctx))
|
||||
.and_then(|tv| tv.as_ref(ctx).active_block_session_id());
|
||||
let diff_state_model = self.working_directories_model.update(ctx, |model, ctx| {
|
||||
model.get_or_create_diff_state_model(repo_path.to_path_buf(), ctx)
|
||||
model.get_or_create_diff_state_model(repo_path.clone(), preferred_session, ctx)
|
||||
});
|
||||
|
||||
let Some(diff_state_model) = diff_state_model else {
|
||||
return;
|
||||
};
|
||||
let working_directories_model = self.working_directories_model.as_ref(ctx);
|
||||
let Some(terminal_view_id) =
|
||||
working_directories_model.get_terminal_id_for_root_path(pane_group_id, repo_path)
|
||||
else {
|
||||
return;
|
||||
let is_known_repo = self
|
||||
.working_directories_model
|
||||
.as_ref(ctx)
|
||||
.most_recent_repositories_for_pane_group(pane_group_id)
|
||||
.is_some_and(|mut repos| repos.any(|r| &r == repo_path));
|
||||
|
||||
// Only create a view when a terminal exists for the repo. The view
|
||||
// resolves its target terminal lazily via `ReviewActionTargetProvider`,
|
||||
// so this is purely a creation gate.
|
||||
let has_review_terminal = if is_known_repo {
|
||||
let Some(terminal_view_id) = self
|
||||
.working_directories_model
|
||||
.as_ref(ctx)
|
||||
.get_terminal_id_for_root_path(pane_group_id, repo_path)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
ctx.view_with_id::<TerminalView>(ctx.window_id(), terminal_view_id)
|
||||
.is_some()
|
||||
} else {
|
||||
// For repos not yet tracked (e.g. remote repos from direct open),
|
||||
// fall back to the active session.
|
||||
pane_group
|
||||
.read(ctx, |pane_group, ctx| pane_group.active_session_view(ctx))
|
||||
.is_some()
|
||||
};
|
||||
|
||||
if working_directories_model
|
||||
.most_recent_repositories_for_pane_group(pane_group_id)
|
||||
.is_some_and(|mut repos| repos.contains(repo_path))
|
||||
{
|
||||
if let Some(terminal_view) =
|
||||
ctx.view_with_id::<TerminalView>(ctx.window_id(), terminal_view_id)
|
||||
if has_review_terminal {
|
||||
if let Some(view) =
|
||||
self.create_code_review_view(repo_path, diff_state_model, pane_group_id, ctx)
|
||||
{
|
||||
if let Some(view) = self.create_code_review_view(
|
||||
repo_path,
|
||||
diff_state_model,
|
||||
pane_group_id,
|
||||
terminal_view.downgrade(),
|
||||
ctx,
|
||||
) {
|
||||
if is_panel_open {
|
||||
let repo_path = repo_path.to_path_buf();
|
||||
view.update(ctx, |view, ctx| {
|
||||
view.on_open(Some(repo_path), ctx);
|
||||
});
|
||||
}
|
||||
if is_panel_open {
|
||||
view.update(ctx, |view, ctx| {
|
||||
view.on_open(ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1685,6 +1836,20 @@ impl TypedActionView for RightPanelView {
|
||||
ctx,
|
||||
);
|
||||
self.ensure_code_review_view_exists(repo_path, ctx);
|
||||
|
||||
// Persist the user's manual selection so it can be restored when
|
||||
// they leave this pane group's session and come back. We only
|
||||
// persist explicit `SelectRepo` actions (i.e. dropdown picks or
|
||||
// contextual opens) so that the auto-selected default doesn't
|
||||
// overwrite an earlier manual choice for a different pane group.
|
||||
if let Some(pane_group) = &self.active_pane_group {
|
||||
let pane_group_id = pane_group.id();
|
||||
let repo_path = repo_path.clone();
|
||||
self.working_directories_model.update(ctx, |model, _| {
|
||||
model.set_selected_review_repo(pane_group_id, repo_path);
|
||||
});
|
||||
}
|
||||
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
//! Logic to determine the working directory for new terminal sessions.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use warpui::{AppContext, SingletonEntity, ViewContext, WindowId};
|
||||
|
||||
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 galaxyui::SingletonEntity;
|
||||
use galaxyui::{AppContext, ViewContext, WindowId};
|
||||
use std::path::PathBuf;
|
||||
|
||||
impl Workspace {
|
||||
/// Helper function to compute the initial directory for a new session
|
||||
|
||||
@@ -0,0 +1,717 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use itertools::{Either, Itertools};
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use warpui::{EntityId, UpdateView, ViewContext};
|
||||
|
||||
use super::{group_member_indices, Workspace};
|
||||
use crate::menu::{MenuItem, MenuItemFields};
|
||||
use crate::tab::{TabData, MOVE_TO_GROUP_LABEL};
|
||||
use crate::workspace::action::{TabContextMenuAnchor, WorkspaceAction};
|
||||
use crate::workspace::tab_group::{TabGroup, TabGroupId};
|
||||
use crate::workspace::util::PaneViewLocator;
|
||||
|
||||
// TODO(johnturcoo) move tab grouping helpers here from workspace/view.rs.
|
||||
impl Workspace {
|
||||
/// Clears the multi-selection on every tab.
|
||||
pub(super) fn clear_tab_multi_selection(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
for tab in &mut self.tabs {
|
||||
tab.in_multi_selection = false;
|
||||
}
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
/// Adds the inclusive range between `anchor_index` and `clicked_index` to
|
||||
/// the multi-selection, expanding any collapsed groups the range crosses.
|
||||
/// Existing multi-selection outside the range is preserved (additive
|
||||
/// semantics), so cmd-click selections survive a subsequent shift-click.
|
||||
fn set_tab_range_selection(
|
||||
&mut self,
|
||||
anchor_index: usize,
|
||||
clicked_index: usize,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
// Determine the bounds for our range selection.
|
||||
let lo_index = anchor_index.min(clicked_index);
|
||||
let hi_index = anchor_index.max(clicked_index);
|
||||
|
||||
// Identify groups in the selection range.
|
||||
let crossed_group_ids: HashSet<TabGroupId> = self
|
||||
.tabs
|
||||
.get(lo_index..=hi_index)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|tab| tab.group_id)
|
||||
.collect();
|
||||
|
||||
// Expand any groups within the selected range, so user can see what they are selecting.
|
||||
for group_id in &crossed_group_ids {
|
||||
self.expand_tab_group(*group_id, ctx);
|
||||
}
|
||||
|
||||
// Add tabs in the selected range to the multi-selection.
|
||||
self.tabs
|
||||
.iter_mut()
|
||||
.enumerate()
|
||||
.filter(|(index, _)| (lo_index..=hi_index).contains(index))
|
||||
.for_each(|(_, tab)| tab.in_multi_selection = true);
|
||||
|
||||
ctx.dispatch_global_action("workspace:save_app", ());
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
/// Shift-click on a vertical tab row: selects every tab between the
|
||||
/// active tab and `locator` (inclusive).
|
||||
pub(super) fn shift_select_tab_range(
|
||||
&mut self,
|
||||
locator: PaneViewLocator,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
if !FeatureFlag::GroupedTabs.is_enabled() {
|
||||
return;
|
||||
}
|
||||
// Identify index of the tab that was shift-clicked.
|
||||
if let Some(clicked_index) = self
|
||||
.tabs
|
||||
.iter()
|
||||
.position(|tab| tab.pane_group.id() == locator.pane_group_id)
|
||||
{
|
||||
self.set_tab_range_selection(self.active_tab_index, clicked_index, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Cmd-click on a tab: toggles the multi-selection flag
|
||||
/// for a single tab.
|
||||
pub(super) fn toggle_tab_multi_selection(
|
||||
&mut self,
|
||||
locator: PaneViewLocator,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
if !FeatureFlag::GroupedTabs.is_enabled() {
|
||||
return;
|
||||
}
|
||||
if let Some(tab) = self
|
||||
.tabs
|
||||
.iter_mut()
|
||||
.find(|tab| tab.pane_group.id() == locator.pane_group_id)
|
||||
{
|
||||
// Toggle multi selection flag for this tab.
|
||||
tab.in_multi_selection = !tab.in_multi_selection;
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns all tabs that are part of the multi tab selection.
|
||||
/// The active tab index is always included if any other tab is marked
|
||||
/// as selected. This is to handle the edge case where we only mark other
|
||||
/// tabs as selected via command click.
|
||||
pub(super) fn selected_tab_indices(&self) -> Vec<usize> {
|
||||
let any_flagged = self.tabs.iter().any(|tab| tab.in_multi_selection);
|
||||
// If no tab is part of the multi selection, return empty list.
|
||||
if !any_flagged {
|
||||
return Vec::new();
|
||||
}
|
||||
// Otherwise, the active tab must always be part of the multi tab selection.
|
||||
self.tabs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(index, tab)| tab.in_multi_selection || *index == self.active_tab_index)
|
||||
.map(|(index, _)| index)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Drives right-click menu dispatch: when a selected tab is right-clicked
|
||||
/// and the selection covers multiple tabs, show the multi-tab menu;
|
||||
/// otherwise fall through to the normal single-pane menu.
|
||||
pub(super) fn is_tab_in_multi_tab_selection(&self, tab_index: usize) -> bool {
|
||||
if !FeatureFlag::GroupedTabs.is_enabled() {
|
||||
return false;
|
||||
}
|
||||
let indices = self.selected_tab_indices();
|
||||
indices.len() > 1 && indices.contains(&tab_index)
|
||||
}
|
||||
|
||||
/// Gates the "Remove from group" menu item. All selected tabs
|
||||
/// must be in the same group in order to display this option.
|
||||
pub(super) fn selection_shared_group(&self) -> Option<TabGroupId> {
|
||||
let indices = self.selected_tab_indices();
|
||||
let mut group_ids = indices
|
||||
.iter()
|
||||
.filter_map(|index| self.tabs.get(*index))
|
||||
.map(|tab| tab.group_id);
|
||||
let first = group_ids.next()??;
|
||||
group_ids.all(|gid| gid == Some(first)).then_some(first)
|
||||
}
|
||||
|
||||
/// Re-seats `active_tab_index` so the previously-active pane group stays
|
||||
/// visually active across a tab reorder. Pass the pane group id captured
|
||||
/// before the reorder; no-op if it can't be found.
|
||||
pub(super) fn restore_active_tab_index(&mut self, pane_group_id: Option<EntityId>) {
|
||||
if let Some(active_id) = pane_group_id {
|
||||
if let Some(new_index) = self
|
||||
.tabs
|
||||
.iter()
|
||||
.position(|tab| tab.pane_group.id() == active_id)
|
||||
{
|
||||
self.active_tab_index = new_index;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Context-aware "create group" entry point used by the
|
||||
/// `workspace:new_tab_group_from_active_or_selected_tabs` keybinding. When
|
||||
/// the multi-selection covers 2+ tabs, groups the selection; otherwise
|
||||
/// groups just the active tab. `selected_tab_indices` already folds the
|
||||
/// active tab into the selection, so a lone flagged active tab (or no
|
||||
/// selection at all) takes the single-tab path.
|
||||
pub(super) fn new_tab_group_from_active_or_selected_tabs(
|
||||
&mut self,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
if !FeatureFlag::GroupedTabs.is_enabled() {
|
||||
return;
|
||||
}
|
||||
if self.selected_tab_indices().len() >= 2 {
|
||||
self.new_tab_group_from_selected_tabs(ctx);
|
||||
} else {
|
||||
self.new_tab_group_from_tab(self.active_tab_index, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Context-aware "remove from group" entry point used by the
|
||||
/// `workspace:remove_active_or_selected_tabs_from_group` keybinding. With a
|
||||
/// 2+ multi-selection, removes the whole selection from its group;
|
||||
/// otherwise removes just the active tab.
|
||||
pub(super) fn remove_active_or_selected_tabs_from_group(
|
||||
&mut self,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
if !FeatureFlag::GroupedTabs.is_enabled() {
|
||||
return;
|
||||
}
|
||||
if self.selected_tab_indices().len() >= 2 {
|
||||
self.remove_selected_tabs_from_group(ctx);
|
||||
} else {
|
||||
self.remove_tab_from_group(self.active_tab_index, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
/// "Create group from tabs" menu action. Group membership requires
|
||||
/// tabs to be contiguous in the bar, so we gather the selected tabs into
|
||||
/// a single block anchored at the earliest selected tab's position before
|
||||
/// binding them to the new group. When that earliest tab was itself in a
|
||||
/// group, the block is placed just past that group's last remaining
|
||||
/// member so the existing group stays contiguous instead of being split.
|
||||
pub(super) fn new_tab_group_from_selected_tabs(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
if !FeatureFlag::GroupedTabs.is_enabled() {
|
||||
return;
|
||||
}
|
||||
let selected_indices = self.selected_tab_indices();
|
||||
|
||||
// Should be unreachable: the multi-tab menu only opens when 2+ tabs
|
||||
// are selected.
|
||||
if selected_indices.len() < 2 {
|
||||
log::warn!(
|
||||
"new_tab_group_from_selected_tabs called with {} selected tab(s); expected at least 2",
|
||||
selected_indices.len()
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Remember the groups the selected tabs are leaving so we can prune
|
||||
// any that become empty after the move.
|
||||
let previous_group_ids: HashSet<TabGroupId> = selected_indices
|
||||
.iter()
|
||||
.filter_map(|index| self.tabs[*index].group_id)
|
||||
.collect();
|
||||
|
||||
let group = TabGroup::new();
|
||||
let group_id = group.id;
|
||||
self.tab_groups.insert(group_id, group);
|
||||
|
||||
// Store the active tab (pane group).
|
||||
let active_pane_group_id = self
|
||||
.tabs
|
||||
.get(self.active_tab_index)
|
||||
.map(|tab| tab.pane_group.id());
|
||||
|
||||
// Anchor the group block at the earliest selected tab. `selected_indices`
|
||||
// is ascending, so its first entry is the earliest tab in the list, and
|
||||
// we remember the group it currently belongs to (if any).
|
||||
let anchor_index = selected_indices[0];
|
||||
let anchor_previous_group_id = self.tabs[anchor_index].group_id;
|
||||
|
||||
// Assign membership and clear flags for every selected tab. The new
|
||||
// group is unpinned, so any selected tab in set as unpinned.
|
||||
for &index in &selected_indices {
|
||||
let tab = &mut self.tabs[index];
|
||||
tab.group_id = Some(group_id);
|
||||
tab.pinned = false;
|
||||
tab.in_multi_selection = false;
|
||||
}
|
||||
|
||||
// Split tabs into the new group's members and all other tabs.
|
||||
let (selected_tabs, mut other_tabs): (Vec<_>, Vec<_>) = self
|
||||
.tabs
|
||||
.drain(..)
|
||||
.partition(|tab| tab.group_id == Some(group_id));
|
||||
|
||||
// Compute where to splice the new group block into `other_tabs`.
|
||||
//
|
||||
// Simple case — anchor tab was NOT in a group:
|
||||
// Every tab before `anchor_index` in the original list was unselected
|
||||
// (because `anchor_index` is the smallest selected index), so those
|
||||
// tabs are still at the front of `other_tabs` in their original order.
|
||||
// Inserting at `anchor_index` in `other_tabs` places the block exactly
|
||||
// where the anchor tab used to be.
|
||||
//
|
||||
// Edge case — anchor tab WAS in an existing group G:
|
||||
// Other surviving members of G are still in `other_tabs`. Inserting at
|
||||
// `anchor_index` could land in the middle of G's run and split it.
|
||||
// Example: tabs = [A(G), B(G), C(G), D] and we select B and D.
|
||||
// other_tabs = [A(G), C(G), D]. anchor_index = 1, which points at C —
|
||||
// inserting there would produce [A(G), B(new), D(new), C(G)], breaking
|
||||
// G's contiguity. Instead we search other_tabs from the right for the
|
||||
// last surviving G member (C, at index 1) and insert after it (index 2),
|
||||
// giving [A(G), C(G), B(new), D(new)].
|
||||
let insert_at = anchor_previous_group_id
|
||||
.and_then(|prev_group_id| {
|
||||
other_tabs
|
||||
.iter()
|
||||
.rposition(|tab| tab.group_id == Some(prev_group_id))
|
||||
.map(|last| last + 1)
|
||||
})
|
||||
.unwrap_or(anchor_index);
|
||||
|
||||
// Our insertion index for this group should be below any pinned items.
|
||||
let insert_at = self.clamp_to_unpinned_region(&other_tabs, insert_at);
|
||||
|
||||
other_tabs.splice(insert_at..insert_at, selected_tabs);
|
||||
self.tabs = other_tabs;
|
||||
|
||||
self.restore_active_tab_index(active_pane_group_id);
|
||||
|
||||
// Prune any groups that are now empty.
|
||||
for previous_group_id in previous_group_ids {
|
||||
self.prune_empty_tab_group(previous_group_id, ctx);
|
||||
}
|
||||
|
||||
ctx.dispatch_global_action("workspace:save_app", ());
|
||||
ctx.notify();
|
||||
|
||||
ctx.dispatch_typed_action_deferred(WorkspaceAction::RenameTabGroup(group_id));
|
||||
}
|
||||
|
||||
/// "Move to group" menu action. The destination group's first-member
|
||||
/// position is preserved so the group doesn't visually jump while the
|
||||
/// selected tabs are folded in.
|
||||
pub(super) fn move_selected_tabs_to_group(
|
||||
&mut self,
|
||||
group_id: TabGroupId,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
if !FeatureFlag::GroupedTabs.is_enabled() || !self.tab_groups.contains_key(&group_id) {
|
||||
return;
|
||||
}
|
||||
let selected_indices = self.selected_tab_indices();
|
||||
|
||||
// Should be unreachable: the multi-tab menu only opens when 2+ tabs
|
||||
// are selected.
|
||||
if selected_indices.len() < 2 {
|
||||
log::warn!(
|
||||
"move_selected_tabs_to_group called with {} selected tab(s); expected at least 2",
|
||||
selected_indices.len()
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Store all groups that tabs previously belonged to, excluding the
|
||||
// group that we are moving tabs to. In order to prune these groups later.
|
||||
let previous_group_ids: HashSet<TabGroupId> = selected_indices
|
||||
.iter()
|
||||
.filter_map(|index| self.tabs[*index].group_id)
|
||||
.filter(|gid| *gid != group_id)
|
||||
.collect();
|
||||
|
||||
// Anchor the block at the existing first member so the group doesn't jump.
|
||||
let first_existing_member = self
|
||||
.tabs
|
||||
.iter()
|
||||
.position(|tab| tab.group_id == Some(group_id));
|
||||
|
||||
// Store the active tab (pane group).
|
||||
let active_pane_group_id = self
|
||||
.tabs
|
||||
.get(self.active_tab_index)
|
||||
.map(|tab| tab.pane_group.id());
|
||||
|
||||
// Assign membership and clear flags for every selected tab. Entering
|
||||
// the group removes any per-tab pinned flag — the destination group's
|
||||
// own `pinned` flag now governs the member's position.
|
||||
for &index in &selected_indices {
|
||||
let tab = &mut self.tabs[index];
|
||||
tab.group_id = Some(group_id);
|
||||
tab.pinned = false;
|
||||
tab.in_multi_selection = false;
|
||||
}
|
||||
|
||||
// Anchor the group block at its original first-member position, shifted
|
||||
// left by the count of newly-added members from before that position.
|
||||
let insert_at = first_existing_member.map_or(0, |first| {
|
||||
first - selected_indices.iter().filter(|&&i| i < first).count()
|
||||
});
|
||||
// Split tabs into the destination group's members (existing + newly
|
||||
// added) and the rest.
|
||||
let (members, mut rest): (Vec<_>, Vec<_>) = self
|
||||
.tabs
|
||||
.drain(..)
|
||||
.partition(|tab| tab.group_id == Some(group_id));
|
||||
// Drop the group block into rest at the anchored position.
|
||||
rest.splice(insert_at..insert_at, members);
|
||||
self.tabs = rest;
|
||||
|
||||
self.restore_active_tab_index(active_pane_group_id);
|
||||
|
||||
self.expand_tab_group(group_id, ctx);
|
||||
|
||||
// Prune any groups that are now empty.
|
||||
for previous_group_id in previous_group_ids {
|
||||
self.prune_empty_tab_group(previous_group_id, ctx);
|
||||
}
|
||||
|
||||
ctx.dispatch_global_action("workspace:save_app", ());
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
/// "Remove from group" menu action. Removed tabs land just below the
|
||||
/// group's remaining members so the user can still see where they came
|
||||
/// from; if the group ends up empty it's pruned and the removed block
|
||||
/// anchors at the original position instead.
|
||||
pub(super) fn remove_selected_tabs_from_group(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
if !FeatureFlag::GroupedTabs.is_enabled() {
|
||||
return;
|
||||
}
|
||||
let Some(group_id) = self.selection_shared_group() else {
|
||||
// Only a single-group selection has an unambiguous group to leave.
|
||||
self.clear_tab_multi_selection(ctx);
|
||||
return;
|
||||
};
|
||||
|
||||
// Capture the group's first index before clearing membership so we can
|
||||
// anchor the removed block if the group ends up empty.
|
||||
let group_first_index = self
|
||||
.tabs
|
||||
.iter()
|
||||
.position(|tab| tab.group_id == Some(group_id))
|
||||
.unwrap_or(0);
|
||||
// Store the active tab (pane group).
|
||||
let active_pane_group_id = self
|
||||
.tabs
|
||||
.get(self.active_tab_index)
|
||||
.map(|tab| tab.pane_group.id());
|
||||
let selected_indices = self.selected_tab_indices();
|
||||
let selected_set: HashSet<usize> = selected_indices.iter().copied().collect();
|
||||
|
||||
// Clear the group that all selected tabs belonged to.
|
||||
for &index in &selected_indices {
|
||||
self.tabs[index].group_id = None;
|
||||
}
|
||||
|
||||
// Non-selected tabs originally before the group's first member; if the
|
||||
// group ends up empty we fall back to inserting at this position.
|
||||
let kept_before_group = group_first_index
|
||||
- selected_indices
|
||||
.iter()
|
||||
.filter(|&&i| i < group_first_index)
|
||||
.count();
|
||||
|
||||
// Split tabs by index into the removed (selected) block and the rest.
|
||||
let (removed, mut rest): (Vec<_>, Vec<_>) =
|
||||
self.tabs
|
||||
.drain(..)
|
||||
.enumerate()
|
||||
.partition_map(|(index, tab)| {
|
||||
if selected_set.contains(&index) {
|
||||
Either::Left(tab)
|
||||
} else {
|
||||
Either::Right(tab)
|
||||
}
|
||||
});
|
||||
// Anchor the removed block just after the group's remaining members;
|
||||
// if none remain, fall back to the pre-computed prefix position.
|
||||
let natural_insert_at = match rest.iter().rposition(|tab| tab.group_id == Some(group_id)) {
|
||||
Some(last) => last + 1,
|
||||
None => kept_before_group,
|
||||
};
|
||||
// The removed tabs are now unpinned (they left a possibly-pinned
|
||||
// group); they must land past every effectively pinned tab in
|
||||
// not just past the source group's remaining members.
|
||||
let insert_at = self.clamp_to_unpinned_region(&rest, natural_insert_at);
|
||||
rest.splice(insert_at..insert_at, removed);
|
||||
self.tabs = rest;
|
||||
|
||||
self.clear_tab_multi_selection(ctx);
|
||||
self.restore_active_tab_index(active_pane_group_id);
|
||||
self.prune_empty_tab_group(group_id, ctx);
|
||||
|
||||
ctx.dispatch_global_action("workspace:save_app", ());
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
/// Items shown in the multi-tab right-click menu. Composition depends on
|
||||
/// the selection: "Create group from tabs" is always there; "Remove from
|
||||
/// group" only when the selection has an unambiguous group; "Move to
|
||||
/// group" only when there's a destination group worth offering.
|
||||
fn tab_selection_menu_items(&self) -> Vec<MenuItem<WorkspaceAction>> {
|
||||
let shared_group = self.selection_shared_group();
|
||||
let mut menu_items = vec![MenuItemFields::new("Create group from tabs")
|
||||
.with_on_select_action(WorkspaceAction::NewTabGroupFromSelectedTabs)
|
||||
.into_item()];
|
||||
|
||||
// Only single-group selections have an unambiguous group to leave.
|
||||
if shared_group.is_some() {
|
||||
menu_items.push(
|
||||
MenuItemFields::new("Remove from group")
|
||||
.with_on_select_action(WorkspaceAction::RemoveSelectedTabsFromGroup)
|
||||
.into_item(),
|
||||
);
|
||||
}
|
||||
|
||||
// Offer "Move to group" only when another group is available.
|
||||
let has_destination_group = self
|
||||
.tab_groups
|
||||
.keys()
|
||||
.any(|group_id| Some(*group_id) != shared_group);
|
||||
if has_destination_group {
|
||||
menu_items.push(MenuItemFields::new_submenu(MOVE_TO_GROUP_LABEL).into_item());
|
||||
}
|
||||
menu_items
|
||||
}
|
||||
|
||||
/// Opens (or closes) the multi-tab right-click menu. Reuses the shared
|
||||
/// `tab_right_click_menu` view — the menu rendering pipeline doesn't need
|
||||
/// to know which item set is loaded, only which `show_*` flag is set.
|
||||
pub fn toggle_tab_selection_right_click_menu(
|
||||
&mut self,
|
||||
tab_index: usize,
|
||||
anchor: TabContextMenuAnchor,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
if self.show_tab_selection_right_click_menu.is_some() {
|
||||
self.show_tab_selection_right_click_menu = None;
|
||||
self.hide_move_to_group_sidecar(ctx);
|
||||
ctx.notify();
|
||||
return;
|
||||
}
|
||||
|
||||
let menu_items = self.tab_selection_menu_items();
|
||||
ctx.update_view(&self.tab_right_click_menu, |context_menu, view_ctx| {
|
||||
context_menu.set_items(menu_items, view_ctx);
|
||||
});
|
||||
self.show_tab_right_click_menu = None;
|
||||
self.show_tab_group_right_click_menu = None;
|
||||
self.hide_move_to_group_sidecar(ctx);
|
||||
self.show_tab_selection_right_click_menu = Some((tab_index, anchor));
|
||||
ctx.focus(&self.tab_right_click_menu);
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
/// True when `tab` is positioned in the pinned region of the tab list —
|
||||
/// either because its own `pinned` flag is set (ungrouped pinned tab) or
|
||||
/// because it belongs to a pinned group.
|
||||
pub(super) fn is_tab_effectively_pinned(&self, tab: &TabData) -> bool {
|
||||
// Safety net, ensures no behavioral changes if feature flag
|
||||
// is off and some tabs have a pinned state saved.
|
||||
if !FeatureFlag::PinnedTabs.is_enabled() {
|
||||
return false;
|
||||
}
|
||||
tab.pinned
|
||||
|| tab
|
||||
.group_id
|
||||
.is_some_and(|gid| self.tab_groups.get(&gid).is_some_and(|g| g.pinned))
|
||||
}
|
||||
|
||||
/// Index where the unpinned region begins within `tabs`: the count of
|
||||
/// leading tabs that belong to the pinned region.
|
||||
pub(super) fn pinned_boundary_index(&self, tabs: &[TabData]) -> usize {
|
||||
tabs.iter()
|
||||
.take_while(|tab| self.is_tab_effectively_pinned(tab))
|
||||
.count()
|
||||
}
|
||||
|
||||
/// Pushes `idx` past the leading effectively-pinned tabs in `tabs` if it
|
||||
/// falls inside that prefix.
|
||||
pub(super) fn clamp_to_unpinned_region(&self, tabs: &[TabData], idx: usize) -> usize {
|
||||
idx.max(self.pinned_boundary_index(tabs))
|
||||
}
|
||||
|
||||
/// Returns the slot just past the last member of `group_id`, suitable as
|
||||
/// an insert/move target that keeps the group contiguous. `None` when the
|
||||
/// group has no members.
|
||||
pub(super) fn index_after_group(&self, group_id: TabGroupId) -> Option<usize> {
|
||||
group_member_indices(&self.tabs, group_id)
|
||||
.last()
|
||||
.map(|last| last + 1)
|
||||
}
|
||||
|
||||
/// Pins the tab. Grouped tabs are extracted from their group first
|
||||
/// regardless of whether that group itself is pinned — tab pinning and
|
||||
/// group pinning are independent concepts.
|
||||
pub(super) fn pin_tab(&mut self, tab_index: usize, ctx: &mut ViewContext<Self>) {
|
||||
if !FeatureFlag::PinnedTabs.is_enabled() {
|
||||
return;
|
||||
}
|
||||
let Some(tab) = self.tabs.get(tab_index) else {
|
||||
log::debug!("pin_tab: tab_index {tab_index} out of bounds");
|
||||
return;
|
||||
};
|
||||
if tab.pinned {
|
||||
log::debug!("pin_tab: tab {tab_index} is already pinned");
|
||||
return;
|
||||
}
|
||||
let previous_group_id = tab.group_id;
|
||||
|
||||
// Identify where this newly pinned tab should land (after the last pinned item).
|
||||
let target = self.pinned_boundary_index(&self.tabs);
|
||||
|
||||
self.tabs[tab_index].group_id = None;
|
||||
self.tabs[tab_index].pinned = true;
|
||||
self.move_tab_to_index(tab_index, target, ctx);
|
||||
|
||||
if let Some(prev) = previous_group_id {
|
||||
self.prune_empty_tab_group(prev, ctx);
|
||||
}
|
||||
|
||||
ctx.dispatch_global_action("workspace:save_app", ());
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
/// Unpins a pinned tab and moves it to the start of the unpinned region.
|
||||
pub(super) fn unpin_tab(&mut self, tab_index: usize, ctx: &mut ViewContext<Self>) {
|
||||
if !FeatureFlag::PinnedTabs.is_enabled() {
|
||||
return;
|
||||
}
|
||||
let Some(tab) = self.tabs.get(tab_index) else {
|
||||
log::debug!("unpin_tab: tab_index {tab_index} out of bounds");
|
||||
return;
|
||||
};
|
||||
if !tab.pinned {
|
||||
log::debug!("unpin_tab: tab {tab_index} is not pinned");
|
||||
return;
|
||||
}
|
||||
|
||||
// This tab should land right after all pinned items.
|
||||
let target = self.pinned_boundary_index(&self.tabs);
|
||||
|
||||
self.tabs[tab_index].pinned = false;
|
||||
self.move_tab_to_index(tab_index, target, ctx);
|
||||
|
||||
ctx.dispatch_global_action("workspace:save_app", ());
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
/// Pins the entire tab group: flips the group's `pinned` flag and moves
|
||||
/// its contiguous block of members to the end of the pinned region. We
|
||||
/// don't touch individual member `tab.pinned` flags because the block
|
||||
/// always travels as a unit, and we want to support pinning a tab even if
|
||||
/// it already belongs to a (pinned) group.
|
||||
pub(super) fn pin_tab_group(&mut self, group_id: TabGroupId, ctx: &mut ViewContext<Self>) {
|
||||
if !FeatureFlag::PinnedTabs.is_enabled() {
|
||||
return;
|
||||
}
|
||||
let Some(group) = self.tab_groups.get(&group_id) else {
|
||||
log::debug!("pin_tab_group: unknown group {group_id:?}");
|
||||
return;
|
||||
};
|
||||
if group.pinned {
|
||||
log::debug!("pin_tab_group: group {group_id:?} is already pinned");
|
||||
return;
|
||||
}
|
||||
|
||||
let target = self.pinned_boundary_index(&self.tabs);
|
||||
if let Some(group) = self.tab_groups.get_mut(&group_id) {
|
||||
group.pinned = true;
|
||||
}
|
||||
self.move_group_block(group_id, target, ctx);
|
||||
|
||||
ctx.dispatch_global_action("workspace:save_app", ());
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
/// Unpins the entire tab group: clears the group's `pinned` flag and
|
||||
/// moves the group's block to the start of the unpinned region.
|
||||
pub(super) fn unpin_tab_group(&mut self, group_id: TabGroupId, ctx: &mut ViewContext<Self>) {
|
||||
if !FeatureFlag::PinnedTabs.is_enabled() {
|
||||
return;
|
||||
}
|
||||
let Some(group) = self.tab_groups.get(&group_id) else {
|
||||
log::debug!("unpin_tab_group: unknown group {group_id:?}");
|
||||
return;
|
||||
};
|
||||
if !group.pinned {
|
||||
log::debug!("unpin_tab_group: group {group_id:?} is not pinned");
|
||||
return;
|
||||
}
|
||||
|
||||
let target = self.pinned_boundary_index(&self.tabs);
|
||||
if let Some(group) = self.tab_groups.get_mut(&group_id) {
|
||||
group.pinned = false;
|
||||
}
|
||||
self.move_group_block(group_id, target, ctx);
|
||||
|
||||
ctx.dispatch_global_action("workspace:save_app", ());
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
/// Builds the "Move to group" submenu. One builder serves both parent
|
||||
/// menus: `Some(tab_index)` for the single-tab pane menu, `None` for the
|
||||
/// multi-tab selection menu. Destination groups exclude the source's own
|
||||
/// group (no useful move) and follow panel order so the submenu visually
|
||||
/// matches what the user sees in the tabs sidebar.
|
||||
pub(super) fn build_move_to_group_sidecar_items(
|
||||
&self,
|
||||
tab_index: Option<usize>,
|
||||
) -> Vec<MenuItem<WorkspaceAction>> {
|
||||
// Exclude the source's current group (if any) — there's nowhere to
|
||||
// move it to. For a mixed selection (no shared group) every
|
||||
// destination stays available.
|
||||
let excluded_group = match tab_index {
|
||||
Some(idx) => self.tabs.get(idx).and_then(|tab| tab.group_id),
|
||||
None => self.selection_shared_group(),
|
||||
};
|
||||
|
||||
let mut groups_with_first_index: Vec<(TabGroupId, usize)> = self
|
||||
.tab_groups
|
||||
.keys()
|
||||
.copied()
|
||||
.filter(|gid| Some(*gid) != excluded_group)
|
||||
.filter_map(|gid| {
|
||||
group_member_indices(&self.tabs, gid)
|
||||
.next()
|
||||
.map(|idx| (gid, idx))
|
||||
})
|
||||
.collect();
|
||||
groups_with_first_index.sort_by_key(|(_, idx)| *idx);
|
||||
|
||||
groups_with_first_index
|
||||
.into_iter()
|
||||
.map(|(group_id, _)| {
|
||||
let label = self
|
||||
.tab_groups
|
||||
.get(&group_id)
|
||||
.and_then(|g| g.name.clone())
|
||||
.unwrap_or_else(|| "Untitled group".to_string());
|
||||
let action = match tab_index {
|
||||
Some(tab_index) => WorkspaceAction::MoveTabToGroup {
|
||||
tab_index,
|
||||
group_id,
|
||||
},
|
||||
None => WorkspaceAction::MoveSelectedTabsToGroup { group_id },
|
||||
};
|
||||
MenuItemFields::new(label)
|
||||
.with_on_select_action(action)
|
||||
.into_item()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
+1771
-439
File diff suppressed because it is too large
Load Diff
@@ -1,29 +1,40 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use pathfinder_geometry::rect::RectF;
|
||||
use pathfinder_geometry::vector::Vector2F;
|
||||
use galaxyui::elements::PositionedElementOffsetBounds;
|
||||
use galaxyui::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, non_terminal_search_text_fragments,
|
||||
pane_ids_for_display_granularity, pane_search_text_fragments, preferred_agent_tab_titles,
|
||||
push_normalized_unique_summary_label, search_fragments_contain_query,
|
||||
select_summary_pane_kind_icons, should_keep_detail_sidecar_visible_for_mouse_position,
|
||||
sort_summary_primary_labels_status_first, 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,
|
||||
VerticalTabsSummaryPrimaryLabel,
|
||||
};
|
||||
use crate::ai::agent::conversation::ConversationStatus;
|
||||
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 galaxyui::elements::PositionedElementOffsetBounds;
|
||||
use galaxyui::EntityId;
|
||||
use pathfinder_geometry::rect::RectF;
|
||||
use pathfinder_geometry::vector::Vector2F;
|
||||
use std::path::PathBuf;
|
||||
|
||||
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 label(text: &str) -> VerticalTabsSummaryPrimaryLabel {
|
||||
VerticalTabsSummaryPrimaryLabel {
|
||||
text: text.to_string(),
|
||||
status: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn pane_id() -> PaneId {
|
||||
TerminalPaneId::dummy_terminal_pane_id().into()
|
||||
@@ -87,6 +98,7 @@ fn summary_pane_kind_icons_distinguish_agent_terminals_from_plain_terminals() {
|
||||
EntityId::from_usize(20),
|
||||
SummaryPaneKind::CLIAgent {
|
||||
agent: CLIAgent::Claude,
|
||||
is_ambient: false,
|
||||
},
|
||||
),
|
||||
(
|
||||
@@ -98,6 +110,41 @@ fn summary_pane_kind_icons_distinguish_agent_terminals_from_plain_terminals() {
|
||||
primary: SummaryPaneKind::Terminal,
|
||||
secondary: SummaryPaneKind::CLIAgent {
|
||||
agent: CLIAgent::Claude,
|
||||
is_ambient: false,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_pane_kind_icons_distinguish_ambient_claude_from_local_claude() {
|
||||
// A local Claude session and a cloud-mode Claude session should count as distinct kinds
|
||||
// so they render with different icons (claude.svg vs claude_cloud.svg).
|
||||
assert_eq!(
|
||||
select_summary_pane_kind_icons([
|
||||
(
|
||||
EntityId::from_usize(10),
|
||||
SummaryPaneKind::CLIAgent {
|
||||
agent: CLIAgent::Claude,
|
||||
is_ambient: false,
|
||||
},
|
||||
),
|
||||
(
|
||||
EntityId::from_usize(20),
|
||||
SummaryPaneKind::CLIAgent {
|
||||
agent: CLIAgent::Claude,
|
||||
is_ambient: true,
|
||||
},
|
||||
),
|
||||
]),
|
||||
Some(SummaryPaneKindIcons::Pair {
|
||||
primary: SummaryPaneKind::CLIAgent {
|
||||
agent: CLIAgent::Claude,
|
||||
is_ambient: false,
|
||||
},
|
||||
secondary: SummaryPaneKind::CLIAgent {
|
||||
agent: CLIAgent::Claude,
|
||||
is_ambient: true,
|
||||
},
|
||||
})
|
||||
);
|
||||
@@ -869,6 +916,7 @@ fn coalesce_summary_branch_entries_groups_by_repo_and_branch() {
|
||||
branch_name: "main".to_string(),
|
||||
diff_stats: None,
|
||||
pull_request_label: None,
|
||||
pull_request_url: None,
|
||||
},
|
||||
VerticalTabsSummaryBranchEntry {
|
||||
repo_path: repo_a.clone(),
|
||||
@@ -879,6 +927,7 @@ fn coalesce_summary_branch_entries_groups_by_repo_and_branch() {
|
||||
lines_removed: 3,
|
||||
}),
|
||||
pull_request_label: Some("#123".to_string()),
|
||||
pull_request_url: Some("https://github.com/acme/repo-a/pull/123".to_string()),
|
||||
},
|
||||
VerticalTabsSummaryBranchEntry {
|
||||
repo_path: repo_b.clone(),
|
||||
@@ -889,6 +938,7 @@ fn coalesce_summary_branch_entries_groups_by_repo_and_branch() {
|
||||
lines_removed: 6,
|
||||
}),
|
||||
pull_request_label: Some("#456".to_string()),
|
||||
pull_request_url: Some("https://github.com/acme/repo-b/pull/456".to_string()),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -904,6 +954,7 @@ fn coalesce_summary_branch_entries_groups_by_repo_and_branch() {
|
||||
lines_removed: 3,
|
||||
}),
|
||||
pull_request_label: Some("#123".to_string()),
|
||||
pull_request_url: Some("https://github.com/acme/repo-a/pull/123".to_string()),
|
||||
},
|
||||
VerticalTabsSummaryBranchEntry {
|
||||
repo_path: repo_b,
|
||||
@@ -914,37 +965,143 @@ fn coalesce_summary_branch_entries_groups_by_repo_and_branch() {
|
||||
lines_removed: 6,
|
||||
}),
|
||||
pull_request_label: Some("#456".to_string()),
|
||||
pull_request_url: Some("https://github.com/acme/repo-b/pull/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(),
|
||||
];
|
||||
fn summary_overflow_count_caps_visible_region() {
|
||||
assert_eq!(summary_overflow_count(5, 3), 2);
|
||||
assert_eq!(summary_overflow_count(3, 3), 0);
|
||||
assert_eq!(summary_overflow_count(2, 3), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn primary_labels_dedupe_preserves_first_seen_status() {
|
||||
let mut values = Vec::new();
|
||||
let mut seen = std::collections::HashMap::new();
|
||||
push_normalized_unique_summary_label(&mut values, &mut seen, " cargo test ", None);
|
||||
push_normalized_unique_summary_label(
|
||||
&mut values,
|
||||
&mut seen,
|
||||
"cargo test",
|
||||
Some(ConversationStatus::InProgress),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
format_summary_primary_labels(&labels, 4),
|
||||
Some("Claude • Oz • cargo • code review + 1 more".to_string())
|
||||
values,
|
||||
vec![VerticalTabsSummaryPrimaryLabel {
|
||||
text: "cargo test".to_string(),
|
||||
status: None,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn primary_labels_preserve_status_through_aggregation() {
|
||||
let mut values = Vec::new();
|
||||
let mut seen = std::collections::HashMap::new();
|
||||
push_normalized_unique_summary_label(
|
||||
&mut values,
|
||||
&mut seen,
|
||||
"Plan a refactor",
|
||||
Some(ConversationStatus::InProgress),
|
||||
);
|
||||
push_normalized_unique_summary_label(
|
||||
&mut values,
|
||||
&mut seen,
|
||||
"Investigate failure",
|
||||
Some(ConversationStatus::Success),
|
||||
);
|
||||
push_normalized_unique_summary_label(&mut values, &mut seen, "cargo build", None);
|
||||
|
||||
assert_eq!(
|
||||
values,
|
||||
vec![
|
||||
VerticalTabsSummaryPrimaryLabel {
|
||||
text: "Plan a refactor".to_string(),
|
||||
status: Some(ConversationStatus::InProgress),
|
||||
},
|
||||
VerticalTabsSummaryPrimaryLabel {
|
||||
text: "Investigate failure".to_string(),
|
||||
status: Some(ConversationStatus::Success),
|
||||
},
|
||||
VerticalTabsSummaryPrimaryLabel {
|
||||
text: "cargo build".to_string(),
|
||||
status: None,
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sort_summary_primary_labels_moves_status_first_and_preserves_order() {
|
||||
let mut values = vec![
|
||||
VerticalTabsSummaryPrimaryLabel {
|
||||
text: "plain terminal".to_string(),
|
||||
status: None,
|
||||
},
|
||||
VerticalTabsSummaryPrimaryLabel {
|
||||
text: "first conversation".to_string(),
|
||||
status: Some(ConversationStatus::InProgress),
|
||||
},
|
||||
VerticalTabsSummaryPrimaryLabel {
|
||||
text: "code pane".to_string(),
|
||||
status: None,
|
||||
},
|
||||
VerticalTabsSummaryPrimaryLabel {
|
||||
text: "second conversation".to_string(),
|
||||
status: Some(ConversationStatus::Success),
|
||||
},
|
||||
VerticalTabsSummaryPrimaryLabel {
|
||||
text: "last terminal".to_string(),
|
||||
status: None,
|
||||
},
|
||||
];
|
||||
|
||||
sort_summary_primary_labels_status_first(&mut values);
|
||||
|
||||
assert_eq!(
|
||||
values,
|
||||
vec![
|
||||
VerticalTabsSummaryPrimaryLabel {
|
||||
text: "first conversation".to_string(),
|
||||
status: Some(ConversationStatus::InProgress),
|
||||
},
|
||||
VerticalTabsSummaryPrimaryLabel {
|
||||
text: "second conversation".to_string(),
|
||||
status: Some(ConversationStatus::Success),
|
||||
},
|
||||
VerticalTabsSummaryPrimaryLabel {
|
||||
text: "plain terminal".to_string(),
|
||||
status: None,
|
||||
},
|
||||
VerticalTabsSummaryPrimaryLabel {
|
||||
text: "code pane".to_string(),
|
||||
status: None,
|
||||
},
|
||||
VerticalTabsSummaryPrimaryLabel {
|
||||
text: "last terminal".to_string(),
|
||||
status: None,
|
||||
},
|
||||
]
|
||||
);
|
||||
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(),
|
||||
VerticalTabsSummaryPrimaryLabel {
|
||||
text: "Claude".to_string(),
|
||||
status: Some(ConversationStatus::InProgress),
|
||||
},
|
||||
label("Oz"),
|
||||
label("cargo"),
|
||||
label("code review"),
|
||||
label("hidden work"),
|
||||
],
|
||||
working_directories: vec!["~/warp-internal".to_string(), "~/warp-server".to_string()],
|
||||
branch_entries: vec![
|
||||
@@ -957,31 +1114,37 @@ fn summary_search_fragments_include_hidden_overflow_values() {
|
||||
lines_removed: 3,
|
||||
}),
|
||||
pull_request_label: Some("#123".to_string()),
|
||||
pull_request_url: Some("https://github.com/acme/repo-a/pull/123".to_string()),
|
||||
},
|
||||
VerticalTabsSummaryBranchEntry {
|
||||
repo_path: PathBuf::from("/tmp/repo-b"),
|
||||
branch_name: "feature/hidden".to_string(),
|
||||
diff_stats: None,
|
||||
pull_request_label: None,
|
||||
pull_request_url: None,
|
||||
},
|
||||
VerticalTabsSummaryBranchEntry {
|
||||
repo_path: PathBuf::from("/tmp/repo-c"),
|
||||
branch_name: "cleanup".to_string(),
|
||||
diff_stats: None,
|
||||
pull_request_label: None,
|
||||
pull_request_url: 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()),
|
||||
pull_request_url: Some("https://github.com/acme/repo-d/pull/789".to_string()),
|
||||
},
|
||||
],
|
||||
has_unread_activity: false,
|
||||
};
|
||||
|
||||
let fragments = summary_search_text_fragments(&summary, Some("Custom tab"));
|
||||
|
||||
assert!(search_fragments_contain_query(&fragments, "custom tab"));
|
||||
assert!(search_fragments_contain_query(&fragments, "claude"));
|
||||
assert!(search_fragments_contain_query(&fragments, "hidden work"));
|
||||
assert!(search_fragments_contain_query(&fragments, "hidden-branch"));
|
||||
assert!(search_fragments_contain_query(&fragments, "#789"));
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
//! WASM-only view functions for the Workspace.
|
||||
|
||||
use galaxy_core::channel::ChannelState;
|
||||
use galaxyui::elements::{ChildView, Element};
|
||||
use galaxyui::{AppContext, SingletonEntity, ViewContext, ViewHandle};
|
||||
|
||||
use galaxy_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::uri::browser_url_handler::parse_current_url;
|
||||
use crate::view_components::action_button::{
|
||||
ActionButton, ButtonSize, NakedTheme, PrimaryTheme, SecondaryTheme,
|
||||
};
|
||||
@@ -114,24 +111,28 @@ impl Workspace {
|
||||
/// 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
|
||||
/// - Restored ambient cloud tasks
|
||||
/// - Shared sessions with 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);
|
||||
|
||||
if terminal_view_ref
|
||||
.ambient_agent_task_id_for_details_panel(ctx)
|
||||
.is_some()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
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
|
||||
// For shared sessions, show if there's 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())
|
||||
@@ -194,6 +195,19 @@ impl Workspace {
|
||||
ctx.notify();
|
||||
return;
|
||||
}
|
||||
|
||||
// Task not yet available - check if the fetch failed and show error state
|
||||
if let Some(error_message) = conversations_model_handle
|
||||
.as_ref(ctx)
|
||||
.task_fetch_error(&task_id)
|
||||
.cloned()
|
||||
{
|
||||
let details =
|
||||
ConversationDetailsData::from_task_id(task_id, Some(error_message));
|
||||
panel.set_conversation_details(details, ctx);
|
||||
ctx.notify();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, populate from conversation
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user