Initial public release of Warp.

Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
David Stern
2026-04-28 08:43:33 -05:00
commit 0dbd3d567a
4982 changed files with 1431549 additions and 0 deletions
+175
View File
@@ -0,0 +1,175 @@
use std::{collections::HashMap, ffi::OsString};
use warpui::{EntityId, SingletonEntity, ViewContext, ViewHandle};
use crate::ai::agent::conversation::{AIConversationId, ConversationStatus};
use crate::ai::blocklist::agent_view::AgentViewEntryOrigin;
use crate::ai::blocklist::BlocklistAIHistoryModel;
use crate::ai::llms::LLMPreferences;
use crate::pane_group::{PaneGroup, PaneId};
use crate::terminal::TerminalView;
use crate::AIExecutionProfilesModel;
pub(crate) struct HiddenChildAgentConversation {
pub terminal_view: ViewHandle<TerminalView>,
pub terminal_view_id: EntityId,
pub conversation_id: AIConversationId,
}
fn propagate_parent_agent_settings(
group: &PaneGroup,
parent_pane_id: PaneId,
child_terminal_view_id: EntityId,
ctx: &mut ViewContext<PaneGroup>,
) {
let Some(parent_terminal_view) = group.terminal_view_from_pane_id(parent_pane_id, ctx) else {
log::warn!(
"Could not find parent terminal view for pane {parent_pane_id:?}; child will use default AI profile"
);
return;
};
let parent_view_id = parent_terminal_view.id();
let parent_profile_id = *AIExecutionProfilesModel::as_ref(ctx)
.active_profile(Some(parent_view_id), ctx)
.id();
AIExecutionProfilesModel::handle(ctx).update(ctx, |profiles, ctx| {
profiles.set_active_profile(child_terminal_view_id, parent_profile_id, ctx);
});
let parent_base_model_id = LLMPreferences::as_ref(ctx)
.get_active_base_model(ctx, Some(parent_view_id))
.id
.clone();
LLMPreferences::handle(ctx).update(ctx, |llm_prefs, ctx| {
llm_prefs.update_preferred_agent_mode_llm(
&parent_base_model_id,
child_terminal_view_id,
ctx,
);
});
}
fn start_new_child_conversation(
terminal_view_id: EntityId,
name: String,
parent_conversation_id: AIConversationId,
ctx: &mut ViewContext<PaneGroup>,
) -> AIConversationId {
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, ctx| {
history_model.start_new_child_conversation(
terminal_view_id,
name,
parent_conversation_id,
ctx,
)
})
}
pub(crate) fn create_hidden_child_agent_conversation(
group: &mut PaneGroup,
parent_pane_id: PaneId,
name: String,
parent_conversation_id: AIConversationId,
env_vars: HashMap<OsString, OsString>,
ctx: &mut ViewContext<PaneGroup>,
) -> Option<HiddenChildAgentConversation> {
let new_pane_id =
group.insert_terminal_pane_hidden_for_child_agent(parent_pane_id, env_vars, ctx);
let Some(new_terminal_view) = group.terminal_view_from_pane_id(new_pane_id, ctx) else {
log::error!("Failed to get terminal view for new StartAgent pane");
group.discard_pane(new_pane_id.into(), ctx);
return None;
};
let terminal_view_id = new_terminal_view.id();
propagate_parent_agent_settings(group, parent_pane_id, terminal_view_id, ctx);
let conversation_id =
start_new_child_conversation(terminal_view_id, name, parent_conversation_id, ctx);
group
.child_agent_panes
.insert(conversation_id, new_pane_id.into());
Some(HiddenChildAgentConversation {
terminal_view: new_terminal_view,
terminal_view_id,
conversation_id,
})
}
fn create_error_child_agent_conversation_context(
group: &mut PaneGroup,
parent_pane_id: PaneId,
name: String,
parent_conversation_id: AIConversationId,
ctx: &mut ViewContext<PaneGroup>,
) -> Option<(Option<ViewHandle<TerminalView>>, EntityId, AIConversationId)> {
if let Some(HiddenChildAgentConversation {
terminal_view,
terminal_view_id,
conversation_id,
..
}) = create_hidden_child_agent_conversation(
group,
parent_pane_id,
name.clone(),
parent_conversation_id,
HashMap::new(),
ctx,
) {
return Some((Some(terminal_view), terminal_view_id, conversation_id));
}
let parent_terminal_view = group.terminal_view_from_pane_id(parent_pane_id, ctx)?;
let parent_terminal_view_id = parent_terminal_view.id();
let conversation_id =
start_new_child_conversation(parent_terminal_view_id, name, parent_conversation_id, ctx);
Some((None, parent_terminal_view_id, conversation_id))
}
pub(crate) fn create_error_child_agent_conversation(
group: &mut PaneGroup,
parent_pane_id: PaneId,
name: String,
parent_conversation_id: AIConversationId,
error_message: String,
ctx: &mut ViewContext<PaneGroup>,
) {
let Some((terminal_view, terminal_view_id, conversation_id)) =
create_error_child_agent_conversation_context(
group,
parent_pane_id,
name,
parent_conversation_id,
ctx,
)
else {
log::error!(
"Failed to surface local child harness error for parent conversation {parent_conversation_id:?}: {error_message}"
);
return;
};
if let Some(terminal_view) = terminal_view {
terminal_view.update(ctx, |terminal_view, ctx| {
terminal_view.enter_agent_view(
None,
Some(conversation_id),
AgentViewEntryOrigin::ChildAgent,
ctx,
);
});
}
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, ctx| {
history_model.update_conversation_status_with_error_message(
terminal_view_id,
conversation_id,
ConversationStatus::Error,
Some(error_message),
ctx,
);
});
}
+237
View File
@@ -0,0 +1,237 @@
use super::pane::{PaneId, TerminalPaneId};
use super::{PaneState, SplitPaneState};
use warpui::{AppContext, Entity, ModelContext, ModelHandle};
/// Centralized focus state for a pane group.
/// This model tracks which pane is focused, which session is active,
/// and which panes are visible. Individual panes subscribe to this model
/// to automatically update their split pane state.
pub struct PaneGroupFocusState {
focused_pane_id: PaneId,
active_session_id: Option<TerminalPaneId>,
in_split_pane: bool,
is_focused_pane_maximized: bool,
}
#[derive(Debug, Clone)]
pub enum PaneGroupFocusEvent {
FocusChanged {
old_focused: PaneId,
new_focused: PaneId,
},
ActiveSessionChanged {
old_active: Option<TerminalPaneId>,
new_active: Option<TerminalPaneId>,
},
InSplitPaneChanged,
FocusedPaneMaximizedChanged,
}
impl Entity for PaneGroupFocusState {
type Event = PaneGroupFocusEvent;
}
impl PaneGroupFocusState {
pub fn new(
focused_pane_id: PaneId,
active_session_id: Option<TerminalPaneId>,
in_split_pane: bool,
) -> Self {
Self {
focused_pane_id,
active_session_id,
in_split_pane,
is_focused_pane_maximized: false,
}
}
/// Returns the currently focused pane ID.
pub fn focused_pane_id(&self) -> PaneId {
self.focused_pane_id
}
/// Returns the active terminal session ID, if any.
pub fn active_session_id(&self) -> Option<TerminalPaneId> {
self.active_session_id
}
/// Returns true if the given pane is the focused pane.
pub fn is_pane_focused(&self, pane_id: PaneId) -> bool {
self.focused_pane_id == pane_id
}
/// Returns true if there is more than one visible pane (i.e., panes are split).
pub fn is_in_split_pane(&self) -> bool {
self.in_split_pane
}
/// Returns true if the focused pane is maximized.
pub fn is_focused_pane_maximized(&self) -> bool {
self.is_focused_pane_maximized
}
/// Computes the split pane state for a given pane based on current focus state.
pub fn split_pane_state_for(&self, pane_id: PaneId) -> SplitPaneState {
// If there's only one visible pane, it's not in a split
if !self.in_split_pane {
return SplitPaneState::NotInSplitPane;
}
let is_focused = self.focused_pane_id == pane_id;
if is_focused && self.is_focused_pane_maximized {
SplitPaneState::InSplitPane(PaneState::Maximized)
} else if is_focused {
SplitPaneState::InSplitPane(PaneState::Focused)
} else {
SplitPaneState::InSplitPane(PaneState::Unfocused)
}
}
/// Sets the focused pane and emits a FocusChanged event.
pub(super) fn set_focused_pane(&mut self, pane_id: PaneId, ctx: &mut ModelContext<Self>) {
let old_focused = self.focused_pane_id;
if old_focused != pane_id {
self.focused_pane_id = pane_id;
// When focus changes, clear maximize state
self.is_focused_pane_maximized = false;
ctx.emit(PaneGroupFocusEvent::FocusChanged {
old_focused,
new_focused: pane_id,
});
}
}
/// Sets the active terminal session and emits an ActiveSessionChanged event.
pub(super) fn set_active_session(
&mut self,
session_id: Option<TerminalPaneId>,
ctx: &mut ModelContext<Self>,
) {
let old_active = self.active_session_id;
if old_active != session_id {
self.active_session_id = session_id;
ctx.emit(PaneGroupFocusEvent::ActiveSessionChanged {
old_active,
new_active: session_id,
});
}
}
/// Sets whether or not the pane group has multiple split panes.
pub(super) fn set_in_split_pane(&mut self, in_split_pane: bool, ctx: &mut ModelContext<Self>) {
if self.in_split_pane != in_split_pane {
self.in_split_pane = in_split_pane;
ctx.emit(PaneGroupFocusEvent::InSplitPaneChanged);
}
}
/// Sets whether the focused pane is maximized.
pub(super) fn set_focused_pane_maximized(
&mut self,
maximized: bool,
ctx: &mut ModelContext<Self>,
) {
if self.is_focused_pane_maximized != maximized {
self.is_focused_pane_maximized = maximized;
ctx.emit(PaneGroupFocusEvent::FocusedPaneMaximizedChanged);
}
}
/// Toggles whether the focused pane is maximized.
pub(super) fn toggle_focused_pane_maximized(&mut self, ctx: &mut ModelContext<Self>) {
self.is_focused_pane_maximized = !self.is_focused_pane_maximized;
ctx.emit(PaneGroupFocusEvent::FocusedPaneMaximizedChanged);
}
/// Test-only method to set the in_split_pane state.
#[cfg(test)]
pub fn set_in_split_pane_for_test(
&mut self,
in_split_pane: bool,
ctx: &mut ModelContext<Self>,
) {
self.set_in_split_pane(in_split_pane, ctx);
}
}
#[derive(Clone)]
pub struct PaneFocusHandle {
focus_state: ModelHandle<PaneGroupFocusState>,
pane_id: PaneId,
}
impl PaneFocusHandle {
pub fn new(pane_id: PaneId, focus_state: ModelHandle<PaneGroupFocusState>) -> Self {
Self {
focus_state,
pane_id,
}
}
/// The current split-pane state of this pane.
pub fn split_pane_state(&self, app: &AppContext) -> SplitPaneState {
self.focus_state
.as_ref(app)
.split_pane_state_for(self.pane_id)
}
/// True if this pane is currently maximized.
pub fn is_maximized(&self, app: &AppContext) -> bool {
self.split_pane_state(app).is_maximized()
}
/// True if this pane is part of a split.
pub fn is_in_split_pane(&self, app: &AppContext) -> bool {
self.split_pane_state(app).is_in_split_pane()
}
/// True if this pane is focused.
pub fn is_focused(&self, app: &AppContext) -> bool {
self.split_pane_state(app).is_focused()
}
/// True if this pane is the active terminal session.
pub fn is_active_session(&self, app: &AppContext) -> bool {
self.pane_id
.as_terminal_pane_id()
.is_some_and(|terminal_id| {
self.focus_state.as_ref(app).active_session_id() == Some(terminal_id)
})
}
/// Returns a reference to the underlying focus state model handle.
/// This can be used to subscribe to focus state changes.
pub fn focus_state_handle(&self) -> &ModelHandle<PaneGroupFocusState> {
&self.focus_state
}
/// Returns the pane ID associated with this focus handle.
pub fn pane_id(&self) -> PaneId {
self.pane_id
}
/// Whether or not a focus-change event affects the pane associated with this handle.
///
/// The implementation prioritizes correctness over efficiency:
/// * Changes in focus affect this pane if it gains or loses focus.
/// * Changes in the active session affect this pane if it was or became active.
/// * Changes to maximization and whether or not there are split panes *always* affect this pane.
pub fn is_affected(&self, event: &PaneGroupFocusEvent) -> bool {
match event {
PaneGroupFocusEvent::FocusChanged {
old_focused,
new_focused,
} => old_focused == &self.pane_id || new_focused == &self.pane_id,
PaneGroupFocusEvent::ActiveSessionChanged {
old_active,
new_active,
} => match self.pane_id.as_terminal_pane_id() {
Some(id) => Some(id) == *old_active || Some(id) == *new_active,
None => false,
},
PaneGroupFocusEvent::InSplitPaneChanged => true,
PaneGroupFocusEvent::FocusedPaneMaximizedChanged => true,
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+179
View File
@@ -0,0 +1,179 @@
use warpui::{AppContext, ModelHandle, SingletonEntity, ViewContext, ViewHandle};
use crate::{
ai::ai_document_view::{AIDocumentEvent, AIDocumentView},
ai::document::ai_document_model::AIDocumentModel,
app_state::{AIDocumentPaneSnapshot, LeafContents},
};
use super::{
view::PaneView, DetachType, PaneConfiguration, PaneContent, PaneGroup, PaneId, ShareableLink,
ShareableLinkError,
};
pub struct AIDocumentPane {
view: ViewHandle<PaneView<AIDocumentView>>,
pane_configuration: ModelHandle<PaneConfiguration>,
}
impl AIDocumentPane {
pub fn new(document_view: ViewHandle<AIDocumentView>, ctx: &mut AppContext) -> Self {
let pane_configuration = document_view.as_ref(ctx).pane_configuration().to_owned();
let view = ctx.add_typed_action_view(document_view.window_id(ctx), |ctx| {
let pane_id = PaneId::from_ai_document_pane_ctx(ctx);
PaneView::new(pane_id, document_view, (), pane_configuration.clone(), ctx)
});
Self {
view,
pane_configuration,
}
}
pub fn document_view(&self, ctx: &AppContext) -> ViewHandle<AIDocumentView> {
self.view.as_ref(ctx).child(ctx)
}
}
impl PaneContent for AIDocumentPane {
fn id(&self) -> PaneId {
PaneId::from_ai_document_pane_view(&self.view)
}
fn snapshot(&self, app: &AppContext) -> LeafContents {
let document_view = self.document_view(app).as_ref(app);
let document_id = *document_view.document_id();
let ai_document_model = AIDocumentModel::as_ref(app);
let content = ai_document_model.get_document_content(&document_id, app);
let title = ai_document_model
.get_current_document(&document_id)
.map(|doc| doc.title.clone());
if content.is_none() {
log::warn!(
"AI document snapshot: no content for {document_id} (document not in model)"
);
}
LeafContents::AIDocument(AIDocumentPaneSnapshot::Local {
document_id: document_id.to_string(),
version: document_view.document_version().0 as i32,
content,
title,
})
}
fn attach(
&self,
_group: &PaneGroup,
focus_handle: crate::pane_group::focus_state::PaneFocusHandle,
ctx: &mut ViewContext<PaneGroup>,
) {
let pane_id = self.id();
self.view
.update(ctx, |view, ctx| view.set_focus_handle(focus_handle, ctx));
// Bind the editor model to this window now that we know it
let window_id = ctx.window_id();
let doc_view = self.document_view(ctx);
doc_view.update(ctx, move |view, ctx| {
view.bind_window(window_id, ctx);
});
// Update visibility state when pane is attached/opened
let document_id = *doc_view.as_ref(ctx).document_id();
let pane_group_id = ctx.view_id();
AIDocumentModel::handle(ctx).update(ctx, |model, ctx| {
model.set_document_visible(&document_id, pane_group_id, true, ctx);
});
ctx.subscribe_to_view(
&self.document_view(ctx),
move |group, _, event, ctx| match event {
AIDocumentEvent::Pane(pane_event) => {
group.handle_pane_event(pane_id, pane_event, ctx);
}
AIDocumentEvent::CloseRequested => {
group.close_pane_with_confirmation(pane_id, ctx);
}
AIDocumentEvent::ViewInWarpDrive(id) => {
ctx.emit(crate::pane_group::Event::ViewInWarpDrive(*id));
}
#[cfg(feature = "local_fs")]
AIDocumentEvent::OpenCodeInWarp {
source,
layout,
line_col,
} => {
ctx.emit(crate::pane_group::Event::OpenCodeInWarp {
source: source.clone(),
layout: *layout,
line_col: *line_col,
});
}
#[cfg(feature = "local_fs")]
AIDocumentEvent::OpenFileWithTarget {
path,
target,
line_col,
} => {
ctx.emit(crate::pane_group::Event::OpenFileWithTarget {
path: path.clone(),
target: target.clone(),
line_col: *line_col,
});
}
AIDocumentEvent::AttachPlanAsContext(ai_document_id) => {
ctx.emit(crate::pane_group::Event::AttachPlanAsContext {
ai_document_id: *ai_document_id,
});
}
},
);
ctx.subscribe_to_view(&self.view, move |group, _, event, ctx| {
group.handle_pane_view_event(pane_id, event, ctx);
});
}
fn detach(
&self,
_group: &PaneGroup,
_detach_type: DetachType,
ctx: &mut ViewContext<PaneGroup>,
) {
let document_view = self.document_view(ctx);
ctx.unsubscribe_to_view(&document_view);
ctx.unsubscribe_to_view(&self.view);
// Clear visibility for this pane group on close, hide, or move.
// On move, attach() in the destination pane group will re-add the new ID.
let document_id = *document_view.as_ref(ctx).document_id();
let pane_group_id = ctx.view_id();
AIDocumentModel::handle(ctx).update(ctx, |model, ctx| {
model.set_document_visible(&document_id, pane_group_id, false, ctx);
});
}
fn has_application_focus(&self, ctx: &mut ViewContext<PaneGroup>) -> bool {
self.view.is_self_or_child_focused(ctx)
}
fn focus(&self, ctx: &mut ViewContext<PaneGroup>) {
self.document_view(ctx)
.update(ctx, |view, ctx| view.focus(ctx));
}
fn shareable_link(
&self,
_ctx: &mut ViewContext<PaneGroup>,
) -> Result<ShareableLink, ShareableLinkError> {
Ok(ShareableLink::Base)
}
fn pane_configuration(&self) -> ModelHandle<PaneConfiguration> {
self.pane_configuration.clone()
}
fn is_pane_being_dragged(&self, ctx: &AppContext) -> bool {
self.view.as_ref(ctx).is_being_dragged()
}
}
+122
View File
@@ -0,0 +1,122 @@
use warpui::{AppContext, ModelHandle, SingletonEntity, View, ViewContext, ViewHandle};
use crate::{
ai::facts::{AIFactManager, AIFactView, AIFactViewEvent},
app_state::{AIFactPaneSnapshot, LeafContents},
};
use super::{
view::PaneView, DetachType, PaneConfiguration, PaneContent, PaneGroup, PaneId, ShareableLink,
ShareableLinkError,
};
pub struct AIFactPane {
view: ViewHandle<PaneView<AIFactView>>,
pane_configuration: ModelHandle<PaneConfiguration>,
}
impl AIFactPane {
pub fn from_view(ai_fact_view: ViewHandle<AIFactView>, ctx: &mut AppContext) -> Self {
let pane_configuration = ai_fact_view.as_ref(ctx).pane_configuration();
let view = ctx.add_typed_action_view(ai_fact_view.window_id(ctx), |ctx| {
let pane_id = PaneId::from_ai_fact_pane_ctx(ctx);
PaneView::new(pane_id, ai_fact_view, (), pane_configuration.clone(), ctx)
});
Self {
view,
pane_configuration,
}
}
pub fn new<V: View>(ctx: &mut ViewContext<V>) -> Self {
let window_id = ctx.window_id();
let view =
AIFactManager::handle(ctx).read(ctx, |manager, _ctx| manager.ai_fact_view(window_id));
Self::from_view(view, ctx)
}
pub fn ai_fact_view(&self, ctx: &AppContext) -> ViewHandle<AIFactView> {
self.view.as_ref(ctx).child(ctx)
}
}
impl PaneContent for AIFactPane {
fn id(&self) -> PaneId {
PaneId::from_ai_fact_pane_view(&self.view)
}
fn attach(
&self,
_group: &PaneGroup,
focus_handle: crate::pane_group::focus_state::PaneFocusHandle,
ctx: &mut ViewContext<PaneGroup>,
) {
self.view
.update(ctx, |view, ctx| view.set_focus_handle(focus_handle, ctx));
let pane_id = self.id();
let pane_group_id = ctx.view_id();
let window_id = ctx.window_id();
ctx.subscribe_to_view(&self.ai_fact_view(ctx), move |pane_group, _, event, ctx| {
if let AIFactViewEvent::Pane(pane_event) = event {
pane_group.handle_pane_event(pane_id, pane_event, ctx)
}
});
ctx.subscribe_to_view(&self.view, move |group, _, event, ctx| {
group.handle_pane_view_event(pane_id, event, ctx);
});
AIFactManager::handle(ctx).update(ctx, |manager, ctx| {
manager.register_pane(self, pane_group_id, window_id, ctx);
});
}
fn detach(
&self,
_group: &PaneGroup,
_detach_type: DetachType,
ctx: &mut ViewContext<PaneGroup>,
) {
// Always unsubscribe from views
let ai_fact_view = self.ai_fact_view(ctx);
ctx.unsubscribe_to_view(&ai_fact_view);
ctx.unsubscribe_to_view(&self.view);
// Always deregister from AIFactManager - it will be re-registered on attach if restored
let window_id = ctx.window_id();
AIFactManager::handle(ctx).update(ctx, |manager, ctx| {
manager.deregister_pane(&window_id, ctx);
});
}
fn snapshot(&self, _app: &AppContext) -> LeafContents {
LeafContents::AIFact(AIFactPaneSnapshot::Personal)
}
fn has_application_focus(&self, ctx: &mut ViewContext<PaneGroup>) -> bool {
self.view.is_self_or_child_focused(ctx)
}
fn focus(&self, ctx: &mut ViewContext<PaneGroup>) {
self.ai_fact_view(ctx)
.update(ctx, |view, ctx| view.focus(ctx));
}
fn shareable_link(
&self,
_ctx: &mut ViewContext<PaneGroup>,
) -> Result<ShareableLink, ShareableLinkError> {
Ok(ShareableLink::Base)
}
fn pane_configuration(&self) -> ModelHandle<PaneConfiguration> {
self.pane_configuration.clone()
}
fn is_pane_being_dragged(&self, ctx: &AppContext) -> bool {
self.view.as_ref(ctx).is_being_dragged()
}
}
+166
View File
@@ -0,0 +1,166 @@
use warpui::{AppContext, ModelHandle, SingletonEntity, ViewContext, ViewHandle};
use crate::{
ai::blocklist::inline_action::code_diff_view::{CodeDiffView, CodeDiffViewEvent},
app_state::{CodePaneSnapShot, CodePaneTabSnapshot, LeafContents},
code::editor_management::{CodeManager, CodeSource},
pane_group::PaneGroup,
};
use super::{
code_diff_pane_model::CodeDiffPaneModel, DetachType, PaneConfiguration, PaneContent, PaneEvent,
PaneId, PaneView, ShareableLink, ShareableLinkError,
};
pub struct CodeDiffPane {
view: ViewHandle<PaneView<CodeDiffView>>,
pane_configuration: ModelHandle<PaneConfiguration>,
model: ModelHandle<CodeDiffPaneModel>,
}
impl CodeDiffPane {
pub fn from_view(diff_view: ViewHandle<CodeDiffView>, ctx: &mut AppContext) -> Self {
let window_id = diff_view.window_id(ctx);
let pane_configuration = ctx.add_model(|_ctx| {
let mut config = PaneConfiguration::new("");
// This title must be set with .set_title and not just ::new() to ensure that the tab renders immediately.
config.set_title("Requested Edit", _ctx);
config
});
let diff_view_clone = diff_view.clone();
let view = ctx.add_typed_action_view(window_id, |ctx| {
let pane_id = PaneId::from_code_diff_pane_ctx(ctx);
PaneView::new(pane_id, diff_view, (), pane_configuration.clone(), ctx)
});
let model = ctx.add_model(|ctx| CodeDiffPaneModel::new(diff_view_clone, ctx));
Self {
view,
pane_configuration,
model,
}
}
pub fn diff_view(&self, ctx: &AppContext) -> ViewHandle<CodeDiffView> {
self.view.as_ref(ctx).child(ctx)
}
}
impl PaneContent for CodeDiffPane {
fn id(&self) -> PaneId {
PaneId::from_code_diff_pane_view(&self.view)
}
fn attach(
&self,
_group: &PaneGroup,
focus_handle: crate::pane_group::focus_state::PaneFocusHandle,
ctx: &mut ViewContext<PaneGroup>,
) {
self.view
.update(ctx, |view, ctx| view.set_focus_handle(focus_handle, ctx));
// Skip if the diff view is already in full-pane mode to avoid an
// unnecessary re-render.
let diff_view = self.diff_view(ctx);
let is_already_full_pane = diff_view.as_ref(ctx).display_mode().is_full_pane();
if !is_already_full_pane {
diff_view.update(ctx, |view, ctx| {
view.set_embedded_display_mode(false, ctx);
});
}
let pane_id = self.id();
ctx.subscribe_to_model(&self.model, move |pane_group, _, event, ctx| match event {
CodeDiffViewEvent::Pane(pane_event) => {
pane_group.handle_pane_event(pane_id, pane_event, ctx)
}
CodeDiffViewEvent::EditorFocused => {
pane_group.handle_pane_event(pane_id, &PaneEvent::FocusSelf, ctx)
}
_ => (),
});
ctx.subscribe_to_view(&self.view, move |group, _, event, ctx| {
group.handle_pane_view_event(pane_id, event, ctx);
});
let action_id = self.diff_view(ctx).as_ref(ctx).action_id().clone();
let pane_group_id = ctx.view_id();
let window_id = ctx.window_id();
CodeManager::handle(ctx).update(ctx, |manager, _ctx| {
manager.register_pane(
pane_group_id,
window_id,
pane_id,
CodeSource::AIAction { id: action_id },
);
});
}
fn detach(
&self,
_group: &PaneGroup,
detach_type: DetachType,
ctx: &mut ViewContext<PaneGroup>,
) {
let diff_view = self.diff_view(ctx);
// When a pane is moved to another window (tab transfer), the diff view
// should keep its full-pane display mode because it will be re-attached
// in the target window. Only reset to embedded mode on close/hide.
if !matches!(detach_type, DetachType::Moved) {
diff_view.update(ctx, |view, ctx| {
view.set_embedded_display_mode(true, ctx);
});
}
// Always unsubscribe from models and views
ctx.unsubscribe_to_model(&self.model);
ctx.unsubscribe_to_view(&self.view);
if matches!(detach_type, DetachType::Closed) {
// Only deregister from CodeManager when permanently closed
let action_id = self.diff_view(ctx).as_ref(ctx).action_id().clone();
CodeManager::handle(ctx).update(ctx, |manager, _ctx| {
manager.deregister_pane(&CodeSource::AIAction { id: action_id });
});
}
}
fn snapshot(&self, _app: &AppContext) -> LeafContents {
// Todo (kc) Implement snapshots.
LeafContents::Code(CodePaneSnapShot::Local {
tabs: vec![CodePaneTabSnapshot { path: None }],
active_tab_index: 0,
source: None,
})
}
fn focus(&self, ctx: &mut ViewContext<PaneGroup>) {
ctx.focus(&self.diff_view(ctx));
}
fn has_application_focus(&self, ctx: &mut ViewContext<PaneGroup>) -> bool {
self.view.is_self_or_child_focused(ctx)
}
fn shareable_link(
&self,
_ctx: &mut ViewContext<PaneGroup>,
) -> Result<ShareableLink, ShareableLinkError> {
Ok(ShareableLink::Base)
}
fn pane_configuration(&self) -> ModelHandle<PaneConfiguration> {
self.pane_configuration.clone()
}
fn is_pane_being_dragged(&self, ctx: &AppContext) -> bool {
self.view.as_ref(ctx).is_being_dragged()
}
}
@@ -0,0 +1,22 @@
use warpui::{Entity, ModelContext, ViewHandle};
use crate::ai::blocklist::inline_action::code_diff_view::{CodeDiffView, CodeDiffViewEvent};
/// Intermediate model between CodeDiffPane and CodeDiffView.
/// This model is needed because if the PaneGroup subscribes directly to the CodeDiffView,
/// then the PaneGroup could not unsubscribe during detach.
/// This is because unsubscribing does not work when handling an event from the subscribed view.
pub struct CodeDiffPaneModel {}
impl CodeDiffPaneModel {
pub fn new(view: ViewHandle<CodeDiffView>, ctx: &mut ModelContext<Self>) -> Self {
// Subscribe to the CodeDiffView events
ctx.subscribe_to_view(&view, |_model, event, ctx| ctx.emit(event.clone()));
Self {}
}
}
impl Entity for CodeDiffPaneModel {
type Event = CodeDiffViewEvent;
}
+250
View File
@@ -0,0 +1,250 @@
use warp_util::path::LineAndColumnArg;
use warpui::{AppContext, ModelHandle, SingletonEntity, View, ViewContext, ViewHandle};
use crate::{
app_state::{CodePaneSnapShot, CodePaneTabSnapshot, LeafContents},
code::{
editor_management::{CodeEditorStatus, CodeManager, CodeSource},
view::{CodeView, CodeViewEvent},
},
pane_group::PaneGroup,
};
use super::{
DetachType, PaneConfiguration, PaneContent, PaneId, PaneView, ShareableLink, ShareableLinkError,
};
pub struct CodePane {
view: ViewHandle<PaneView<CodeView>>,
pane_configuration: ModelHandle<PaneConfiguration>,
}
impl CodePane {
pub fn from_view(file_view: ViewHandle<CodeView>, ctx: &mut AppContext) -> Self {
let pane_configuration = file_view.as_ref(ctx).pane_configuration();
let view = ctx.add_typed_action_view(file_view.window_id(ctx), |ctx| {
let pane_id = PaneId::from_code_pane_ctx(ctx);
PaneView::new(pane_id, file_view, (), pane_configuration.clone(), ctx)
});
Self {
view,
pane_configuration,
}
}
/// For now, make the pane-opening behavior consistent with markdown viewer.
pub fn new<V: View>(
source: CodeSource,
line_col: Option<LineAndColumnArg>,
ctx: &mut ViewContext<V>,
) -> Self {
let view = ctx.add_typed_action_view(move |ctx| CodeView::new(source, line_col, ctx));
Self::from_view(view, ctx)
}
#[cfg(feature = "local_fs")]
pub fn new_preview<V: View>(source: CodeSource, ctx: &mut ViewContext<V>) -> Self {
let view = ctx.add_typed_action_view(move |ctx| CodeView::new_preview(source, ctx));
Self::from_view(view, ctx)
}
pub fn file_view(&self, ctx: &AppContext) -> ViewHandle<CodeView> {
self.view.as_ref(ctx).child(ctx)
}
pub fn editor_status(&self, app: &AppContext) -> CodeEditorStatus {
CodeEditorStatus::editor_status(&self.file_view(app), app)
}
}
impl PaneContent for CodePane {
fn id(&self) -> PaneId {
PaneId::from_code_pane_view(&self.view)
}
fn pre_attach(&self, group: &PaneGroup, ctx: &mut ViewContext<PaneGroup>) -> bool {
let source = self.file_view(ctx).as_ref(ctx).source().clone();
let Some(path) = source.path() else {
return true;
};
let pane_group_id = ctx.view_id();
let existing_locator = CodeManager::handle(ctx).read(ctx, |manager, _ctx| {
manager.get_locator_for_path_in_tab(pane_group_id, path.as_path())
});
// If the file is already open in the same tab, don't restore it, just focus it (and jump).
if let Some(existing_locator) = existing_locator {
if let Some(code_pane) = group.code_pane_by_id(existing_locator.pane_id) {
let line_col = match &source {
CodeSource::Link { range_start, .. } => *range_start,
_ => None,
};
code_pane.file_view(ctx).update(ctx, |code_view, ctx| {
code_view.open_or_focus_existing(Some(path.clone()), line_col, ctx);
});
}
ctx.emit(crate::pane_group::Event::FocusPaneInWorkspace {
locator: existing_locator,
});
return false;
}
#[cfg(feature = "local_fs")]
self.file_view(ctx).update(ctx, |code_view, ctx| {
if let Some(path) = source.path() {
let line_col = match &source {
CodeSource::Link { range_start, .. } => *range_start,
_ => None,
};
code_view.open_or_focus_existing(Some(path), line_col, ctx);
}
});
true
}
fn attach(
&self,
group: &PaneGroup,
focus_handle: crate::pane_group::focus_state::PaneFocusHandle,
ctx: &mut ViewContext<PaneGroup>,
) {
let pane_id = self.id();
let _code_model = group.active_file_model().clone();
self.view
.update(ctx, |view, ctx| view.set_focus_handle(focus_handle, ctx));
ctx.subscribe_to_view(
&self.file_view(ctx),
move |pane_group, _, event, ctx| match event {
CodeViewEvent::Pane(pane_event) => {
pane_group.handle_pane_event(pane_id, pane_event, ctx)
}
CodeViewEvent::TabChanged { file_path, .. } => {
if let Some(path) = file_path {
pane_group.active_file_model().update(ctx, |model, ctx| {
model.active_file_changed(path.clone(), ctx);
});
}
}
CodeViewEvent::FileOpened { file_path, .. } => {
pane_group.active_file_model().update(ctx, |model, ctx| {
model.active_file_changed(file_path.clone(), ctx);
});
// Track the opened file in the OpenedFilesModel
#[cfg(feature = "local_fs")]
{
use crate::code::opened_files::OpenedFilesModel;
use repo_metadata::repositories::DetectedRepositories;
if let Some(repo_path) =
DetectedRepositories::as_ref(ctx).get_root_for_path(file_path)
{
OpenedFilesModel::handle(ctx).update(ctx, |opened_files, ctx| {
opened_files.file_opened(repo_path, file_path.clone(), ctx);
});
}
}
}
CodeViewEvent::RunTabConfigSkill { path } => {
ctx.emit(crate::pane_group::Event::RunTabConfigSkill { path: path.clone() });
}
#[cfg(not(target_family = "wasm"))]
CodeViewEvent::OpenLspLogs { log_path } => {
ctx.emit(crate::pane_group::Event::OpenLspLogs {
log_path: log_path.clone(),
});
}
#[cfg(target_family = "wasm")]
CodeViewEvent::OpenLspLogs { .. } => {}
},
);
ctx.subscribe_to_view(&self.view, move |group, _, event, ctx| {
group.handle_pane_view_event(pane_id, event, ctx);
});
let source = self.file_view(ctx).as_ref(ctx).source().clone();
let pane_group_id = ctx.view_id();
let window_id = ctx.window_id();
CodeManager::handle(ctx).update(ctx, |manager, _ctx| {
manager.register_pane(pane_group_id, window_id, pane_id, source);
});
}
fn detach(
&self,
_group: &PaneGroup,
detach_type: DetachType,
ctx: &mut ViewContext<PaneGroup>,
) {
// Always unsubscribe from views
let file_view = self.file_view(ctx);
ctx.unsubscribe_to_view(&file_view);
ctx.unsubscribe_to_view(&self.view);
// Deregister from CodeManager for both HiddenForClose and Closed cases
// This ensures files can be opened elsewhere even during the undo grace period
if matches!(detach_type, DetachType::HiddenForClose | DetachType::Closed) {
let source = self.file_view(ctx).as_ref(ctx).source().clone();
CodeManager::handle(ctx).update(ctx, |manager, _ctx| {
manager.deregister_pane(&source);
});
}
// Only cleanup tabs when the pane is actually being destroyed (not during undo grace period)
// This preserves the tab state so it can be properly restored via undo-close
#[cfg(feature = "local_fs")]
if matches!(detach_type, DetachType::Closed) {
file_view.update(ctx, |code_view, ctx| {
code_view.cleanup_all_tabs(ctx);
});
}
}
fn snapshot(&self, app: &AppContext) -> LeafContents {
let code_view_ref = self.file_view(app).as_ref(app);
let tabs: Vec<CodePaneTabSnapshot> = (0..code_view_ref.tab_count())
.filter_map(|i| code_view_ref.tab_at(i))
.map(|tab| CodePaneTabSnapshot { path: tab.path() })
.collect();
let active_tab_index = code_view_ref.active_tab_index();
let source = code_view_ref.source().clone();
LeafContents::Code(CodePaneSnapShot::Local {
tabs,
active_tab_index,
source: Some(source),
})
}
fn focus(&self, ctx: &mut ViewContext<PaneGroup>) {
self.file_view(ctx).update(ctx, |view, ctx| view.focus(ctx));
}
fn has_application_focus(&self, ctx: &mut ViewContext<PaneGroup>) -> bool {
self.view.is_self_or_child_focused(ctx)
}
fn shareable_link(
&self,
_ctx: &mut ViewContext<PaneGroup>,
) -> Result<ShareableLink, ShareableLinkError> {
Ok(ShareableLink::Base)
}
fn pane_configuration(&self) -> ModelHandle<PaneConfiguration> {
self.pane_configuration.clone()
}
fn is_pane_being_dragged(&self, ctx: &AppContext) -> bool {
self.view.as_ref(ctx).is_being_dragged()
}
}
@@ -0,0 +1,198 @@
use anyhow::Context;
use warpui::{AppContext, ModelHandle, SingletonEntity, ViewContext, ViewHandle};
use crate::{
app_state::{EnvVarCollectionPaneSnapshot, LeafContents},
drive::items::WarpDriveItemId,
env_vars::{
manager::{EnvVarCollectionManager, EnvVarCollectionSource},
view::env_var_collection::{EnvVarCollectionEvent, EnvVarCollectionView},
EnvVarCollectionType,
},
pane_group::focus_state::PaneFocusHandle,
server::ids::SyncId,
workspaces::user_workspaces::UserWorkspaces,
};
use super::{
view::PaneView, DetachType, PaneConfiguration, PaneContent, PaneGroup, PaneId, ShareableLink,
ShareableLinkError,
};
pub struct EnvVarCollectionPane {
view: ViewHandle<PaneView<EnvVarCollectionView>>,
pane_configuration: ModelHandle<PaneConfiguration>,
}
impl EnvVarCollectionPane {
pub fn new(
env_var_collection_view: ViewHandle<EnvVarCollectionView>,
ctx: &mut AppContext,
) -> Self {
let pane_configuration = env_var_collection_view
.as_ref(ctx)
.pane_configuration()
.to_owned();
let view = ctx.add_typed_action_view(env_var_collection_view.window_id(ctx), |ctx| {
let pane_id = PaneId::from_env_var_collection_pane_ctx(ctx);
PaneView::new(
pane_id,
env_var_collection_view,
(),
pane_configuration.clone(),
ctx,
)
});
Self {
view,
pane_configuration,
}
}
pub fn restore(
env_var_collection_id: Option<SyncId>,
ctx: &mut ViewContext<PaneGroup>,
) -> anyhow::Result<Self> {
let window_id = ctx.window_id();
let source = match env_var_collection_id {
Some(id) => EnvVarCollectionSource::Existing(id),
None => EnvVarCollectionSource::New {
title: None,
owner: UserWorkspaces::as_ref(ctx)
.personal_drive(ctx)
.context("personal drive unavailable")?,
initial_folder_id: None,
},
};
Ok(
EnvVarCollectionManager::handle(ctx).update(ctx, |manager, ctx| {
manager.create_pane(&source, window_id, ctx)
}),
)
}
pub fn env_var_collection_view(&self, ctx: &AppContext) -> ViewHandle<EnvVarCollectionView> {
self.view.as_ref(ctx).child(ctx)
}
}
impl PaneContent for EnvVarCollectionPane {
fn id(&self) -> PaneId {
PaneId::from_env_var_collection_view(&self.view)
}
fn snapshot(&self, app: &AppContext) -> LeafContents {
let env_var_collection_id = self
.env_var_collection_view(app)
.as_ref(app)
.env_var_collection_id(app);
LeafContents::EnvVarCollection(EnvVarCollectionPaneSnapshot::CloudEnvVarCollection {
env_var_collection_id,
})
}
fn attach(
&self,
_group: &PaneGroup,
focus_handle: PaneFocusHandle,
ctx: &mut ViewContext<PaneGroup>,
) {
self.view
.update(ctx, |view, ctx| view.set_focus_handle(focus_handle, ctx));
let pane_id = self.id();
ctx.subscribe_to_view(
&self.env_var_collection_view(ctx),
move |group, _, event, ctx| {
handle_env_var_collection_event(group, pane_id, event, ctx);
},
);
ctx.subscribe_to_view(&self.view, move |group, _, event, ctx| {
group.handle_pane_view_event(pane_id, event, ctx);
});
let pane_group_id = ctx.view_id();
let window_id = ctx.window_id();
EnvVarCollectionManager::handle(ctx).update(ctx, |manager, ctx| {
manager.register_pane(self, pane_group_id, window_id, ctx);
});
}
fn detach(
&self,
_group: &PaneGroup,
_detach_type: DetachType,
ctx: &mut ViewContext<PaneGroup>,
) {
// Always unsubscribe from views
let env_var_collection_view = self.env_var_collection_view(ctx);
ctx.unsubscribe_to_view(&env_var_collection_view);
ctx.unsubscribe_to_view(&self.view);
// Always deregister from EnvVarCollectionManager - it will be re-registered on attach if restored
EnvVarCollectionManager::handle(ctx)
.update(ctx, |manager, ctx| manager.deregister_pane(self, ctx));
}
fn has_application_focus(&self, ctx: &mut ViewContext<PaneGroup>) -> bool {
self.view.is_self_or_child_focused(ctx)
}
fn focus(&self, ctx: &mut ViewContext<PaneGroup>) {
self.env_var_collection_view(ctx)
.update(ctx, |view, ctx| view.focus(ctx));
}
fn shareable_link(
&self,
_ctx: &mut ViewContext<PaneGroup>,
) -> Result<ShareableLink, ShareableLinkError> {
// TODO: sega
Ok(ShareableLink::Base)
}
fn pane_configuration(&self) -> ModelHandle<PaneConfiguration> {
self.pane_configuration.clone()
}
fn is_pane_being_dragged(&self, ctx: &AppContext) -> bool {
self.view.as_ref(ctx).is_being_dragged()
}
}
fn handle_env_var_collection_event(
group: &mut PaneGroup,
pane_id: PaneId,
event: &EnvVarCollectionEvent,
ctx: &mut ViewContext<PaneGroup>,
) {
match event {
EnvVarCollectionEvent::Pane(pane_event) => {
group.handle_pane_event(pane_id, pane_event, ctx)
}
EnvVarCollectionEvent::ViewInWarpDrive(id) => view_in_warp_drive(*id, ctx),
EnvVarCollectionEvent::Invoke(env_var_collection) => {
invoke_env_var_collection(env_var_collection.clone(), ctx)
}
EnvVarCollectionEvent::UpdatedEnvVarCollection(_) => {
log::warn!("EVC updates not yet handled by EVC pane")
}
}
}
fn invoke_env_var_collection(
env_var_collection: EnvVarCollectionType,
ctx: &mut ViewContext<PaneGroup>,
) {
ctx.emit(crate::pane_group::Event::InvokeEnvVarCollection {
env_var_collection: env_var_collection.into(),
in_subshell: false,
})
}
fn view_in_warp_drive(id: WarpDriveItemId, ctx: &mut ViewContext<PaneGroup>) {
ctx.emit(crate::pane_group::Event::ViewInWarpDrive(id))
}
@@ -0,0 +1,158 @@
use warpui::{AppContext, ModelHandle, ViewContext, ViewHandle};
use crate::{
app_state::{EnvironmentManagementPaneSnapshot, LeafContents},
pane_group::focus_state::PaneFocusHandle,
settings_view::{
environments_page::{EnvironmentsPage, EnvironmentsPageView},
settings_page::{PaneEventWrapper, SettingsPageEvent},
update_environment_form::GithubAuthRedirectTarget,
},
};
use super::{
view::PaneView, DetachType, PaneConfiguration, PaneContent, PaneEvent, PaneGroup, PaneId,
ShareableLink, ShareableLinkError,
};
pub struct EnvironmentManagementPane {
view: ViewHandle<PaneView<EnvironmentsPageView>>,
pane_configuration: ModelHandle<PaneConfiguration>,
}
impl EnvironmentManagementPane {
pub fn new(ctx: &mut ViewContext<PaneGroup>) -> Self {
// Create the EnvironmentsPageView
let environments_page_view = ctx.add_typed_action_view(|ctx| {
let mut view = EnvironmentsPageView::new(ctx);
view.set_github_auth_redirect_target(GithubAuthRedirectTarget::FocusCloudMode, ctx);
view
});
Self::from_view(environments_page_view, ctx)
}
pub fn from_view(
environments_page_view: ViewHandle<EnvironmentsPageView>,
ctx: &mut AppContext,
) -> Self {
let pane_configuration = environments_page_view.as_ref(ctx).pane_configuration();
let window_id = environments_page_view.window_id(ctx);
let view = ctx.add_typed_action_view(window_id, |ctx| {
let pane_id = PaneId::from_environment_management_pane_ctx(ctx);
PaneView::new(
pane_id,
environments_page_view,
(),
pane_configuration.clone(),
ctx,
)
});
Self {
view,
pane_configuration,
}
}
pub fn environments_page_view(&self, ctx: &AppContext) -> ViewHandle<EnvironmentsPageView> {
self.view.as_ref(ctx).child(ctx)
}
/// Returns the current mode of the environment management pane.
pub fn current_mode(&self, ctx: &AppContext) -> EnvironmentsPage {
self.environments_page_view(ctx)
.as_ref(ctx)
.current_page()
.clone()
}
}
impl PaneContent for EnvironmentManagementPane {
fn id(&self) -> PaneId {
PaneId::from_environment_management_pane_view(&self.view)
}
fn attach(
&self,
_group: &PaneGroup,
focus_handle: PaneFocusHandle,
ctx: &mut ViewContext<PaneGroup>,
) {
self.view
.update(ctx, |view, ctx| view.set_focus_handle(focus_handle, ctx));
let pane_id = self.id();
ctx.subscribe_to_view(
&self.environments_page_view(ctx),
move |pane_group, _, event, ctx| match event {
SettingsPageEvent::Pane(pane_event_wrapper) => {
let pane_event = match pane_event_wrapper {
PaneEventWrapper::Close => PaneEvent::Close,
};
pane_group.handle_pane_event(pane_id, &pane_event, ctx);
}
SettingsPageEvent::EnvironmentSetupModeSelectorToggled { is_open } => {
pane_group.pane_with_open_environment_setup_mode_selector =
is_open.then_some(pane_id);
ctx.notify();
}
SettingsPageEvent::AgentAssistedEnvironmentModalToggled { is_open } => {
pane_group.pane_with_open_agent_assisted_environment_modal =
is_open.then_some(pane_id);
ctx.notify();
}
SettingsPageEvent::FocusModal => {
// Not applicable when hosted in a pane.
}
},
);
ctx.subscribe_to_view(&self.view, move |group, _, event, ctx| {
group.handle_pane_view_event(pane_id, event, ctx);
});
}
fn detach(
&self,
_group: &PaneGroup,
_detach_type: DetachType,
ctx: &mut ViewContext<PaneGroup>,
) {
let environments_page_view = self.environments_page_view(ctx);
ctx.unsubscribe_to_view(&environments_page_view);
ctx.unsubscribe_to_view(&self.view);
}
fn snapshot(&self, ctx: &AppContext) -> LeafContents {
LeafContents::EnvironmentManagement(EnvironmentManagementPaneSnapshot {
mode: self.current_mode(ctx),
})
}
fn has_application_focus(&self, ctx: &mut ViewContext<PaneGroup>) -> bool {
self.view.is_self_or_child_focused(ctx)
}
fn focus(&self, ctx: &mut ViewContext<PaneGroup>) {
self.environments_page_view(ctx)
.update(ctx, |view, ctx| view.focus(ctx));
}
fn shareable_link(
&self,
_ctx: &mut ViewContext<PaneGroup>,
) -> Result<ShareableLink, ShareableLinkError> {
Ok(ShareableLink::Base)
}
fn pane_configuration(&self) -> ModelHandle<PaneConfiguration> {
self.pane_configuration.clone()
}
fn is_pane_being_dragged(&self, ctx: &AppContext) -> bool {
self.view.as_ref(ctx).is_being_dragged()
}
}
@@ -0,0 +1,138 @@
use super::{
view::PaneView, DetachType, PaneConfiguration, PaneContent, PaneGroup, PaneId, ShareableLink,
ShareableLinkError,
};
use crate::{
ai::execution_profiles::editor::{
ExecutionProfileEditorManager, ExecutionProfileEditorView, ExecutionProfileEditorViewEvent,
},
ai::execution_profiles::profiles::ClientProfileId,
app_state::LeafContents,
};
use warpui::{AppContext, ModelHandle, SingletonEntity, View, ViewContext, ViewHandle};
pub struct ExecutionProfileEditorPane {
view: ViewHandle<PaneView<ExecutionProfileEditorView>>,
pane_configuration: ModelHandle<PaneConfiguration>,
}
impl ExecutionProfileEditorPane {
pub fn from_view(
execution_profile_editor_view: ViewHandle<ExecutionProfileEditorView>,
ctx: &mut AppContext,
) -> Self {
let pane_configuration = execution_profile_editor_view
.as_ref(ctx)
.pane_configuration();
let view = ctx.add_typed_action_view(execution_profile_editor_view.window_id(ctx), |ctx| {
let pane_id = PaneId::from_execution_profile_editor_pane_ctx(ctx);
PaneView::new(
pane_id,
execution_profile_editor_view,
(),
pane_configuration.clone(),
ctx,
)
});
Self {
view,
pane_configuration,
}
}
pub fn new<V: View>(profile_id: ClientProfileId, ctx: &mut ViewContext<V>) -> Self {
let view =
ctx.add_typed_action_view(|ctx| ExecutionProfileEditorView::new(profile_id, ctx));
Self::from_view(view, ctx)
}
pub fn execution_profile_editor_view(
&self,
ctx: &AppContext,
) -> ViewHandle<ExecutionProfileEditorView> {
self.view.as_ref(ctx).child(ctx)
}
}
impl PaneContent for ExecutionProfileEditorPane {
fn id(&self) -> PaneId {
PaneId::from_execution_profile_editor_pane_view(&self.view)
}
fn attach(
&self,
_group: &PaneGroup,
focus_handle: crate::pane_group::focus_state::PaneFocusHandle,
ctx: &mut ViewContext<PaneGroup>,
) {
self.view
.update(ctx, |view, ctx| view.set_focus_handle(focus_handle, ctx));
let exec_view_handle = self.execution_profile_editor_view(ctx);
let pane_id = self.id();
let pane_group_id = ctx.view_id();
let window_id = ctx.window_id();
let profile_id = exec_view_handle.as_ref(ctx).profile_id();
ctx.subscribe_to_view(&exec_view_handle, move |pane_group, _, event, ctx| {
let ExecutionProfileEditorViewEvent::Pane(pane_event) = event;
pane_group.handle_pane_event(pane_id, pane_event, ctx)
});
ctx.subscribe_to_view(&self.view, move |group, _, event, ctx| {
group.handle_pane_view_event(pane_id, event, ctx);
});
ExecutionProfileEditorManager::handle(ctx).update(ctx, |manager, ctx| {
manager.register_pane(self, pane_group_id, window_id, profile_id, ctx);
});
}
fn detach(
&self,
_group: &PaneGroup,
_detach_type: DetachType,
ctx: &mut ViewContext<PaneGroup>,
) {
// Always unsubscribe from views
let execution_profile_editor_view = self.execution_profile_editor_view(ctx);
let profile_id = execution_profile_editor_view.as_ref(ctx).profile_id();
ctx.unsubscribe_to_view(&execution_profile_editor_view);
ctx.unsubscribe_to_view(&self.view);
// Always deregister from ExecutionProfileEditorManager - it will be re-registered on attach if restored
let window_id = ctx.window_id();
ExecutionProfileEditorManager::handle(ctx).update(ctx, |manager, _| {
manager.deregister_pane(&window_id, &profile_id);
});
}
fn snapshot(&self, _app: &AppContext) -> LeafContents {
LeafContents::ExecutionProfileEditor
}
fn has_application_focus(&self, ctx: &mut ViewContext<PaneGroup>) -> bool {
self.view.is_self_or_child_focused(ctx)
}
fn focus(&self, ctx: &mut ViewContext<PaneGroup>) {
self.execution_profile_editor_view(ctx)
.update(ctx, |view, ctx| view.focus(ctx));
}
fn shareable_link(
&self,
_ctx: &mut ViewContext<PaneGroup>,
) -> Result<ShareableLink, ShareableLinkError> {
Ok(ShareableLink::Base)
}
fn pane_configuration(&self) -> ModelHandle<PaneConfiguration> {
self.pane_configuration.clone()
}
fn is_pane_being_dragged(&self, ctx: &AppContext) -> bool {
self.view.as_ref(ctx).is_being_dragged()
}
}
+179
View File
@@ -0,0 +1,179 @@
use std::{path::PathBuf, sync::Arc};
use warpui::{AppContext, ModelHandle, SingletonEntity, View, ViewContext, ViewHandle};
#[cfg(feature = "local_fs")]
use crate::code::editor_management::CodeSource;
use crate::{
app_state::{LeafContents, NotebookPaneSnapshot},
notebooks::file::{FileNotebookEvent, FileNotebookView},
terminal::model::session::Session,
workflows::WorkflowSelectionSource,
workspace::ActiveSession,
};
use super::{
notebook_pane::subscribe_to_link_model, view::PaneView, DetachType, PaneConfiguration,
PaneContent, PaneGroup, PaneId, ShareableLink, ShareableLinkError,
};
pub struct FilePane {
view: ViewHandle<PaneView<FileNotebookView>>,
pane_configuration: ModelHandle<PaneConfiguration>,
}
impl FilePane {
fn from_view(file_view: ViewHandle<FileNotebookView>, ctx: &mut AppContext) -> Self {
let pane_configuration = file_view.as_ref(ctx).pane_configuration();
let view = ctx.add_typed_action_view(file_view.window_id(ctx), |ctx| {
let pane_id = PaneId::from_file_pane_ctx(ctx);
PaneView::new(pane_id, file_view, (), pane_configuration.clone(), ctx)
});
Self {
view,
pane_configuration,
}
}
/// Create a new file notebook pane for the given path and optional target session. If `path`
/// is `None` or the target session is remote, the pane is created but left empty. If `path` is
/// `Some`, but there's no target session, the pane is created using the next focused local
/// session.
pub fn new<V: View>(
path: Option<PathBuf>,
target_session: Option<Arc<Session>>,
#[cfg(feature = "local_fs")] code_source: Option<CodeSource>,
ctx: &mut ViewContext<V>,
) -> Self {
let view = ctx.add_typed_action_view(move |ctx| {
let mut view = FileNotebookView::new(ctx);
#[cfg(feature = "local_fs")]
view.set_code_source(code_source);
if let Some(path) = path {
if let Some(target_session) = target_session {
// If the target session is Some, but non-local, do not fall back - the path is
// remote, so we can't reliably use the fallback behavior.
if target_session.is_local() {
view.open_local(path, Some(target_session), ctx);
}
} else {
// If the active session is None or remote, the pane will wait for a local
// session to be activated.
let session = ActiveSession::as_ref(ctx)
.session(ctx.window_id())
.filter(|session| session.is_local());
view.open_local(path, session, ctx);
}
}
view
});
Self::from_view(view, ctx)
}
pub fn file_view(&self, ctx: &AppContext) -> ViewHandle<FileNotebookView> {
self.view.as_ref(ctx).child(ctx)
}
}
impl PaneContent for FilePane {
fn id(&self) -> PaneId {
PaneId::from_file_pane_view(&self.view)
}
fn attach(
&self,
_group: &PaneGroup,
focus_handle: crate::pane_group::focus_state::PaneFocusHandle,
ctx: &mut ViewContext<PaneGroup>,
) {
self.view
.update(ctx, |view, ctx| view.set_focus_handle(focus_handle, ctx));
let pane_id = self.id();
let file_view = self.file_view(ctx);
ctx.subscribe_to_view(
&self.file_view(ctx),
move |pane_group, _, event, ctx| match event {
FileNotebookEvent::RunWorkflow { workflow, source } => {
ctx.emit(crate::pane_group::Event::RunWorkflow {
workflow: workflow.clone(),
workflow_source: *source,
workflow_selection_source: WorkflowSelectionSource::Notebook,
argument_override: None,
});
}
FileNotebookEvent::TitleUpdated => {
ctx.emit(crate::pane_group::Event::PaneTitleUpdated)
}
FileNotebookEvent::FileLoaded => {
ctx.emit(crate::pane_group::Event::AppStateChanged)
}
#[cfg(feature = "local_fs")]
FileNotebookEvent::OpenFileWithTarget {
path,
target,
line_col,
} => {
ctx.emit(crate::pane_group::Event::OpenFileWithTarget {
path: path.clone(),
target: target.clone(),
line_col: *line_col,
});
}
FileNotebookEvent::Pane(pane_event) => {
pane_group.handle_pane_event(pane_id, pane_event, ctx)
}
},
);
subscribe_to_link_model(pane_id, &file_view.as_ref(ctx).links(), ctx);
ctx.subscribe_to_view(&self.view, move |group, _, event, ctx| {
group.handle_pane_view_event(pane_id, event, ctx);
});
}
fn detach(
&self,
_group: &PaneGroup,
_detach_type: DetachType,
ctx: &mut ViewContext<PaneGroup>,
) {
// Always unsubscribe from views and models
let file_view = self.file_view(ctx);
ctx.unsubscribe_to_view(&file_view);
ctx.unsubscribe_to_model(&file_view.as_ref(ctx).links());
ctx.unsubscribe_to_view(&self.view);
}
fn snapshot(&self, app: &AppContext) -> LeafContents {
let path = self.file_view(app).as_ref(app).local_path();
LeafContents::Notebook(NotebookPaneSnapshot::LocalFileNotebook { path })
}
fn has_application_focus(&self, ctx: &mut ViewContext<PaneGroup>) -> bool {
self.view.is_self_or_child_focused(ctx)
}
fn focus(&self, ctx: &mut ViewContext<PaneGroup>) {
self.file_view(ctx).update(ctx, |view, ctx| view.focus(ctx));
}
fn shareable_link(
&self,
_ctx: &mut ViewContext<PaneGroup>,
) -> Result<ShareableLink, ShareableLinkError> {
Ok(ShareableLink::Base)
}
fn pane_configuration(&self) -> ModelHandle<PaneConfiguration> {
self.pane_configuration.clone()
}
fn is_pane_being_dragged(&self, ctx: &AppContext) -> bool {
self.view.as_ref(ctx).is_being_dragged()
}
}
@@ -0,0 +1,99 @@
use warpui::{AppContext, ModelHandle, View, ViewContext, ViewHandle};
use crate::{
app_state::LeafContents,
pane_group::{
pane::{get_started_view::GetStartedView, ShareableLink, ShareableLinkError},
BackingView, PaneConfiguration, PaneContent, PaneGroup, PaneView,
},
};
use super::PaneId;
pub struct GetStartedPane {
view: ViewHandle<PaneView<GetStartedView>>,
pane_configuration: ModelHandle<PaneConfiguration>,
}
impl GetStartedPane {
pub fn new<V: View>(ctx: &mut ViewContext<V>) -> Self {
let get_started_view = ctx.add_typed_action_view(GetStartedView::new);
let pane_configuration = get_started_view.as_ref(ctx).pane_configuration();
let pane_view = ctx.add_typed_action_view(|ctx| {
let pane_id = PaneId::from_get_started_pane_ctx(ctx);
PaneView::new(
pane_id,
get_started_view,
(),
pane_configuration.clone(),
ctx,
)
});
Self {
view: pane_view,
pane_configuration,
}
}
}
impl PaneContent for GetStartedPane {
fn id(&self) -> PaneId {
PaneId::from_get_started_pane_view(&self.view)
}
fn attach(
&self,
_group: &PaneGroup,
focus_handle: crate::pane_group::focus_state::PaneFocusHandle,
ctx: &mut ViewContext<PaneGroup>,
) {
self.view
.update(ctx, |view, ctx| view.set_focus_handle(focus_handle, ctx));
let child = self.view.as_ref(ctx).child(ctx);
let pane_id = self.id();
ctx.subscribe_to_view(&child, move |pane_group, _, event, ctx| {
pane_group.handle_pane_event(pane_id, event, ctx);
});
}
fn detach(
&self,
_group: &PaneGroup,
_detach_type: super::DetachType,
ctx: &mut ViewContext<PaneGroup>,
) {
let child = self.view.as_ref(ctx).child(ctx);
ctx.unsubscribe_to_view(&child);
}
fn snapshot(&self, _ctx: &AppContext) -> LeafContents {
LeafContents::GetStarted
}
fn has_application_focus(&self, ctx: &mut ViewContext<PaneGroup>) -> bool {
self.view.is_self_or_child_focused(ctx)
}
fn focus(&self, ctx: &mut ViewContext<PaneGroup>) {
self.view
.as_ref(ctx)
.child(ctx)
.update(ctx, BackingView::focus_contents)
}
fn shareable_link(
&self,
_ctx: &mut ViewContext<PaneGroup>,
) -> Result<ShareableLink, ShareableLinkError> {
Ok(ShareableLink::Base)
}
fn pane_configuration(&self) -> ModelHandle<PaneConfiguration> {
self.pane_configuration.clone()
}
fn is_pane_being_dragged(&self, ctx: &AppContext) -> bool {
self.view.as_ref(ctx).is_being_dragged()
}
}
+395
View File
@@ -0,0 +1,395 @@
use pathfinder_geometry::vector::vec2f;
use warp_core::ui::{self, appearance::Appearance, color::blend::Blend as _};
use warpui::{
elements::{
Align, ChildView, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Flex, Icon,
MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement as _, Radius,
},
keymap::EditableBinding,
platform::Cursor,
ui_components::{
button::{ButtonVariant, TextAndIcon, TextAndIconAlignment},
components::{Coords, UiComponent, UiComponentStyles},
},
AppContext, Element, Entity, ModelHandle, SingletonEntity as _, TypedActionView, View,
ViewContext, ViewHandle,
};
use crate::{
coding_entrypoints::{
clone_repo_view::{CloneRepoEvent, CloneRepoView},
create_project_view::{CreateProjectEvent, CreateProjectView},
project_buttons::{ProjectButtons, ProjectButtonsEvent},
},
pane_group::{
focus_state::PaneFocusHandle, pane::view, BackingView, PaneConfiguration, PaneEvent,
},
send_telemetry_from_ctx,
terminal::TerminalView,
util::bindings::{keybinding_name_to_display_string, BindingGroup, CustomAction},
view_components::DismissibleToast,
workspace::ToastStack,
workspace::{Workspace, WorkspaceAction},
TelemetryEvent,
};
pub fn init(app: &mut AppContext) {
use warpui::keymap::macros::*;
app.register_editable_bindings([EditableBinding::new(
"workspace:new_tab",
"Terminal session",
GetStartedAction::TerminalSession,
)
.with_context_predicate(id!("GetStartedView"))
.with_group(BindingGroup::Terminal.as_str())
.with_custom_action(CustomAction::NewTab)]);
}
#[derive(Debug, Default)]
enum ActivePage {
#[default]
Main,
CreateProject,
CloneRepo,
}
pub struct GetStartedView {
pane_configuration: ModelHandle<PaneConfiguration>,
focus_handle: Option<PaneFocusHandle>,
project_buttons: ViewHandle<ProjectButtons>,
create_project_view: ViewHandle<CreateProjectView>,
clone_repo_view: ViewHandle<CloneRepoView>,
active_page: ActivePage,
terminal_session_button: MouseStateHandle,
}
impl GetStartedView {
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
let pane_configuration = ctx.add_model(|_ctx| PaneConfiguration::new("Get started"));
let project_buttons = ctx.add_typed_action_view(ProjectButtons::new);
ctx.subscribe_to_view(&project_buttons, Self::handle_project_buttons_event);
let create_project_view =
ctx.add_typed_action_view(|ctx| CreateProjectView::new(true, ctx));
ctx.subscribe_to_view(&create_project_view, Self::handle_create_project_event);
let clone_repo_view = ctx.add_typed_action_view(|ctx| CloneRepoView::new(true, ctx));
ctx.subscribe_to_view(&clone_repo_view, Self::handle_clone_repo_event);
Self {
pane_configuration,
focus_handle: None,
project_buttons,
create_project_view,
clone_repo_view,
active_page: Default::default(),
terminal_session_button: Default::default(),
}
}
fn handle_project_buttons_event(
&mut self,
_: ViewHandle<ProjectButtons>,
event: &ProjectButtonsEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
ProjectButtonsEvent::OpenRepository(path_result) => match path_result {
Ok(path) => {
send_telemetry_from_ctx!(
TelemetryEvent::OpenRepoFolderSubmitted { is_ftux: true },
ctx
);
ctx.dispatch_typed_action(&WorkspaceAction::OpenRepository {
path: Some(path.clone()),
});
self.close(ctx);
}
Err(err) => {
let window_id = ctx.window_id();
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
toast_stack.add_ephemeral_toast(
DismissibleToast::error(format!("{err}")),
window_id,
ctx,
);
});
}
},
ProjectButtonsEvent::CreateProject => {
self.active_page = ActivePage::CreateProject;
ctx.focus(&self.create_project_view);
ctx.notify();
}
ProjectButtonsEvent::CloneRepository => {
self.active_page = ActivePage::CloneRepo;
ctx.focus(&self.clone_repo_view);
ctx.notify();
}
}
}
pub fn pane_configuration(&self) -> ModelHandle<PaneConfiguration> {
self.pane_configuration.clone()
}
fn handle_create_project_event(
&mut self,
_: ViewHandle<CreateProjectView>,
event: &CreateProjectEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
CreateProjectEvent::SubmitPrompt(prompt) => {
self.start_create_new_project(prompt.clone(), ctx);
}
CreateProjectEvent::Cancel => {
self.active_page = Default::default();
ctx.notify();
}
}
}
fn handle_clone_repo_event(
&mut self,
_: ViewHandle<CloneRepoView>,
event: &CloneRepoEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
CloneRepoEvent::SubmitPrompt(url) => {
self.start_clone_repo(url.clone(), ctx);
}
CloneRepoEvent::Cancel => {
self.active_page = ActivePage::Main;
ctx.notify();
}
}
}
fn start_create_new_project(&mut self, prompt: String, ctx: &mut ViewContext<Self>) {
ctx.dispatch_typed_action(&WorkspaceAction::AddTerminalTab {
hide_homepage: true,
});
update_active_terminal(ctx, |terminal, ctx| {
terminal.create_new_project(prompt, ctx);
});
self.close(ctx);
}
fn start_clone_repo(&mut self, url: String, ctx: &mut ViewContext<Self>) {
ctx.dispatch_typed_action(&WorkspaceAction::AddTerminalTab {
hide_homepage: true,
});
update_active_terminal(ctx, |terminal, ctx| {
terminal.agent_clone_repository(url, ctx);
});
self.close(ctx);
}
fn render_main_content(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
match self.active_page {
ActivePage::Main => {}
ActivePage::CreateProject => {
return Align::new(
ConstrainedBox::new(ChildView::new(&self.create_project_view).finish())
.with_max_width(480.)
.finish(),
)
.finish();
}
ActivePage::CloneRepo => {
return Align::new(
ConstrainedBox::new(ChildView::new(&self.clone_repo_view).finish())
.with_max_width(480.)
.finish(),
)
.finish();
}
}
Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_children([
Container::new(
ConstrainedBox::new(
Icon::new("bundled/svg/warp-logo-neutral.svg", theme.foreground()).finish(),
)
.with_height(40.)
.with_width(40.)
.finish(),
)
.with_margin_bottom(12.)
.finish(),
appearance
.ui_builder()
.paragraph("Welcome to Warp")
.with_style(UiComponentStyles {
font_size: Some(20.),
..Default::default()
})
.build()
.finish(),
Container::new(
appearance
.ui_builder()
.paragraph("The Agentic Development Environment")
.with_style(UiComponentStyles {
font_size: Some(14.),
font_family_id: Some(appearance.monospace_font_family()),
font_color: Some(
theme.disabled_text_color(theme.background()).into_solid(),
),
..Default::default()
})
.build()
.finish(),
)
.with_margin_top(4.)
.with_margin_bottom(6.)
.finish(),
Container::new(
ConstrainedBox::new(ChildView::new(&self.project_buttons).finish())
.with_max_width(480.)
.with_max_height(70.)
.finish(),
)
.with_vertical_margin(16.)
.finish(),
appearance
.ui_builder()
.button(ButtonVariant::Text, self.terminal_session_button.clone())
.with_style(UiComponentStyles {
padding: Some(Coords::uniform(8.)),
..Default::default()
})
.with_hovered_styles(UiComponentStyles {
border_radius: Some(CornerRadius::with_all(Radius::Pixels(4.))),
background: Some(
theme.background().blend(&theme.surface_overlay_1()).into(),
),
..Default::default()
})
.with_text_and_icon_label(TextAndIcon::new(
TextAndIconAlignment::IconFirst,
format!(
" New session in {} {}",
dirs::home_dir()
.map(|dir| dir.display().to_string())
.unwrap_or("~".to_string()),
keybinding_name_to_display_string("workspace:new_tab", app)
.unwrap_or_default()
),
ui::Icon::Terminal.to_warpui_icon(theme.foreground()),
MainAxisSize::Min,
MainAxisAlignment::Center,
vec2f(16., 16.),
))
.build()
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(GetStartedAction::TerminalSession)
})
.with_cursor(Cursor::PointingHand)
.finish(),
])
.finish()
}
}
impl Entity for GetStartedView {
type Event = PaneEvent;
}
#[derive(Debug)]
pub enum GetStartedAction {
TerminalSession,
}
impl TypedActionView for GetStartedView {
type Action = GetStartedAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
GetStartedAction::TerminalSession => {
send_telemetry_from_ctx!(TelemetryEvent::GetStartedSkipToTerminal, ctx);
ctx.dispatch_typed_action(&WorkspaceAction::AddTerminalTab {
hide_homepage: true,
});
self.close(ctx);
}
}
}
}
impl View for GetStartedView {
fn ui_name() -> &'static str {
"GetStartedView"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
Align::new(self.render_main_content(app)).finish()
}
}
impl BackingView for GetStartedView {
type PaneHeaderOverflowMenuAction = ();
type CustomAction = ();
type AssociatedData = ();
fn handle_pane_header_overflow_menu_action(
&mut self,
_action: &Self::PaneHeaderOverflowMenuAction,
_ctx: &mut ViewContext<Self>,
) {
// TODO
}
fn close(&mut self, ctx: &mut ViewContext<Self>) {
ctx.emit(PaneEvent::Close);
}
fn focus_contents(&mut self, ctx: &mut ViewContext<Self>) {
match self.active_page {
ActivePage::CreateProject => ctx.focus(&self.create_project_view),
ActivePage::CloneRepo => ctx.focus(&self.clone_repo_view),
ActivePage::Main => ctx.focus(&self.project_buttons),
}
}
fn render_header_content(
&self,
_ctx: &view::HeaderRenderContext<'_>,
_app: &AppContext,
) -> view::HeaderContent {
view::HeaderContent::simple("Get started")
}
fn set_focus_handle(&mut self, focus_handle: PaneFocusHandle, _ctx: &mut ViewContext<Self>) {
self.focus_handle = Some(focus_handle);
}
}
fn update_active_terminal<F, S>(ctx: &mut ViewContext<GetStartedView>, func: F)
where
F: FnOnce(&mut TerminalView, &mut ViewContext<TerminalView>) -> S,
{
let window_id = ctx.window_id();
if let Some(workspaces) = ctx.views_of_type::<Workspace>(window_id) {
if let Some(workspace) = workspaces.into_iter().next() {
workspace.update(ctx, |workspace, ctx| {
let pane_group = workspace.active_tab_pane_group();
pane_group.update(ctx, |pane_group, ctx| {
if let Some(active_terminal) = pane_group.active_session_view(ctx) {
active_terminal.update(ctx, func);
}
});
});
}
}
}
@@ -0,0 +1,154 @@
use std::{collections::HashMap, ffi::OsString, path::PathBuf, sync::Arc};
use shell_words::quote as shell_quote;
use uuid::Uuid;
use warp_cli::agent::Harness;
use warp_managed_secrets::ManagedSecretValue;
use crate::ai::{
agent_sdk::{
driver::AgentDriverError, task_env_vars, validate_cli_installed, ClaudeHarness,
ThirdPartyHarness,
},
ambient_agents::{task::HarnessConfig, AgentConfigSnapshot, AmbientAgentTaskId},
};
use crate::server::server_api::ai::AIClient;
use crate::terminal::cli_agent_sessions::plugin_manager::plugin_manager_for;
use crate::terminal::shell::ShellType;
#[derive(Clone)]
pub(super) struct PreparedLocalHarnessLaunch {
pub command: String,
pub env_vars: HashMap<OsString, OsString>,
pub run_id: String,
pub task_id: AmbientAgentTaskId,
}
pub(super) fn normalize_local_child_harness(harness_type: &str) -> Option<Harness> {
Harness::parse_local_child_harness(harness_type)
}
pub(super) fn validate_local_harness_shell(shell_type: Option<ShellType>) -> Result<(), String> {
match shell_type {
Some(ShellType::Bash) | Some(ShellType::Zsh) | Some(ShellType::Fish) => Ok(()),
Some(ShellType::PowerShell) => Err(
"Local child harnesses currently require bash, zsh, or fish; PowerShell is not supported."
.to_string(),
),
None => Err(
"Local child harnesses currently require a detected bash, zsh, or fish session."
.to_string(),
),
}
}
pub(super) fn build_local_claude_child_command(prompt: &str) -> String {
let session_id = Uuid::new_v4();
let quoted_prompt = shell_quote(prompt);
// Local child harness panes are launched off-screen. We intentionally skip
// Claude's own permission prompts here so the child can start unattended
// instead of hanging on an approval UI the user cannot see in that hidden
// pane.
format!("claude --session-id {session_id} --dangerously-skip-permissions {quoted_prompt}")
}
pub(super) fn build_local_opencode_child_command(prompt: &str) -> String {
let quoted_prompt = shell_quote(prompt);
format!("opencode --prompt {quoted_prompt}")
}
fn local_child_task_config(harness: Harness) -> Option<AgentConfigSnapshot> {
match harness {
Harness::Oz | Harness::OpenCode | Harness::Gemini | Harness::Unknown => None,
Harness::Claude => Some(AgentConfigSnapshot {
harness: Some(HarnessConfig::from_harness_type(harness)),
..Default::default()
}),
}
}
pub(super) async fn prepare_local_harness_child_launch(
prompt: String,
harness_type: String,
parent_run_id: Option<String>,
shell_type: Option<ShellType>,
startup_directory: Option<PathBuf>,
ai_client: Arc<dyn AIClient>,
) -> Result<PreparedLocalHarnessLaunch, String> {
let Some(harness) = normalize_local_child_harness(&harness_type) else {
let harness_name = harness_type.trim();
return Err(if harness_name.is_empty() {
"Local child harness type is missing.".to_string()
} else {
format!("Unsupported local child harness '{harness_name}'.")
});
};
validate_local_harness_shell(shell_type)?;
let command = match harness {
Harness::Oz => unreachable!("normalize_local_child_harness filters out Oz"),
Harness::Unknown => unreachable!("normalize_local_child_harness filters out Unknown"),
Harness::Claude => {
let working_dir = startup_directory
.or_else(|| std::env::current_dir().ok())
.ok_or_else(|| {
"Could not resolve a working directory for the local Claude child.".to_string()
})?;
let claude_harness = ClaudeHarness;
claude_harness
.validate()
.map_err(|error: AgentDriverError| error.to_string())?;
// Local child harness panes inherit the user's existing local Claude
// auth/session state. We still prepare Claude's config files here,
// but there are no Warp-managed secrets to materialize into the
// hidden child pane.
let managed_secrets: HashMap<String, ManagedSecretValue> = HashMap::new();
claude_harness
.prepare_environment_config(&working_dir, None, &managed_secrets)
.map_err(|error: AgentDriverError| error.to_string())?;
if let Some(manager) = plugin_manager_for(claude_harness.cli_agent()) {
if let Err(error) = manager.install().await {
log::warn!("Claude plugin installation failed for child harness: {error}");
}
if let Err(error) = manager.install_platform_plugin().await {
log::warn!(
"Claude platform plugin installation failed for child harness: {error}"
);
}
}
build_local_claude_child_command(&prompt)
}
Harness::OpenCode => {
validate_cli_installed("opencode", Some("https://opencode.ai/docs"))
.map_err(|error: AgentDriverError| error.to_string())?;
build_local_opencode_child_command(&prompt)
}
Harness::Gemini => unreachable!("normalize_local_child_harness filters out Gemini"),
};
let task_id = ai_client
.create_agent_task(
prompt.clone(),
None,
parent_run_id.clone(),
local_child_task_config(harness),
)
.await
.map_err(|error| {
format!(
"Failed to create local {} child task: {error}",
harness.display_name()
)
})?;
Ok(PreparedLocalHarnessLaunch {
command,
env_vars: task_env_vars(Some(&task_id), parent_run_id.as_deref(), harness),
run_id: task_id.to_string(),
task_id,
})
}
#[cfg(test)]
#[path = "local_harness_launch_tests.rs"]
mod tests;
@@ -0,0 +1,83 @@
use warp_cli::agent::Harness;
use super::{
build_local_claude_child_command, build_local_opencode_child_command,
normalize_local_child_harness, validate_local_harness_shell,
};
use crate::terminal::shell::ShellType;
#[test]
fn normalize_local_child_harness_accepts_supported_aliases() {
assert_eq!(
normalize_local_child_harness("claude"),
Some(Harness::Claude)
);
assert_eq!(
normalize_local_child_harness("claude-code"),
Some(Harness::Claude)
);
assert_eq!(
normalize_local_child_harness("claude_code"),
Some(Harness::Claude)
);
assert_eq!(
normalize_local_child_harness("opencode"),
Some(Harness::OpenCode)
);
assert_eq!(
normalize_local_child_harness("open-code"),
Some(Harness::OpenCode)
);
assert_eq!(
normalize_local_child_harness("open_code"),
Some(Harness::OpenCode)
);
}
#[test]
fn normalize_local_child_harness_rejects_unsupported_values() {
assert_eq!(normalize_local_child_harness("oz"), None);
assert_eq!(normalize_local_child_harness("codex"), None);
assert_eq!(normalize_local_child_harness(""), None);
}
#[test]
fn validate_local_harness_shell_accepts_supported_shells() {
assert_eq!(validate_local_harness_shell(Some(ShellType::Bash)), Ok(()));
assert_eq!(validate_local_harness_shell(Some(ShellType::Zsh)), Ok(()));
assert_eq!(validate_local_harness_shell(Some(ShellType::Fish)), Ok(()));
}
#[test]
fn validate_local_harness_shell_rejects_unsupported_shells() {
assert_eq!(
validate_local_harness_shell(Some(ShellType::PowerShell)),
Err(
"Local child harnesses currently require bash, zsh, or fish; PowerShell is not supported."
.to_string()
)
);
assert_eq!(
validate_local_harness_shell(None),
Err(
"Local child harnesses currently require a detected bash, zsh, or fish session."
.to_string()
)
);
}
#[test]
fn build_local_claude_child_command_quotes_the_prompt() {
let command = build_local_claude_child_command("hello world");
assert!(command.starts_with("claude --session-id "));
assert!(command.ends_with(" --dangerously-skip-permissions 'hello world'"));
}
#[test]
fn build_local_opencode_child_command_quotes_the_prompt() {
assert_eq!(
build_local_opencode_child_command("hello world"),
"opencode --prompt 'hello world'"
);
}
File diff suppressed because it is too large Load Diff
+132
View File
@@ -0,0 +1,132 @@
use warpui::{AppContext, ModelHandle, SingletonEntity, View, ViewContext, ViewHandle};
use crate::app_state::LeafContents;
use crate::server::network_log_pane_manager::NetworkLogPaneManager;
use crate::server::network_log_view::{NetworkLogView, NetworkLogViewEvent};
use crate::workspace::PaneViewLocator;
use super::{
view::PaneView, DetachType, PaneConfiguration, PaneContent, PaneGroup, PaneId, ShareableLink,
ShareableLinkError,
};
pub struct NetworkLogPane {
view: ViewHandle<PaneView<NetworkLogView>>,
pane_configuration: ModelHandle<PaneConfiguration>,
}
impl NetworkLogPane {
pub fn from_view(network_log_view: ViewHandle<NetworkLogView>, ctx: &mut AppContext) -> Self {
let pane_configuration = network_log_view.as_ref(ctx).pane_configuration();
let view = ctx.add_typed_action_view(network_log_view.window_id(ctx), |ctx| {
let pane_id = PaneId::from_network_log_pane_ctx(ctx);
PaneView::new(
pane_id,
network_log_view,
(),
pane_configuration.clone(),
ctx,
)
});
Self {
view,
pane_configuration,
}
}
pub fn new<V: View>(ctx: &mut ViewContext<V>) -> Self {
let view = ctx.add_typed_action_view(NetworkLogView::new);
Self::from_view(view, ctx)
}
pub fn network_log_view(&self, ctx: &AppContext) -> ViewHandle<NetworkLogView> {
self.view.as_ref(ctx).child(ctx)
}
}
impl PaneContent for NetworkLogPane {
fn id(&self) -> PaneId {
PaneId::from_network_log_pane_view(&self.view)
}
fn attach(
&self,
_group: &PaneGroup,
focus_handle: crate::pane_group::focus_state::PaneFocusHandle,
ctx: &mut ViewContext<PaneGroup>,
) {
self.view
.update(ctx, |view, ctx| view.set_focus_handle(focus_handle, ctx));
let network_log_view = self.network_log_view(ctx);
let pane_id = self.id();
let pane_group_id = ctx.view_id();
let window_id = ctx.window_id();
ctx.subscribe_to_view(&network_log_view, move |pane_group, _, event, ctx| {
let NetworkLogViewEvent::Pane(pane_event) = event;
pane_group.handle_pane_event(pane_id, pane_event, ctx)
});
ctx.subscribe_to_view(&self.view, move |group, _, event, ctx| {
group.handle_pane_view_event(pane_id, event, ctx);
});
NetworkLogPaneManager::handle(ctx).update(ctx, |manager, _ctx| {
manager.register_pane(
window_id,
PaneViewLocator {
pane_group_id,
pane_id,
},
);
});
}
fn detach(
&self,
_group: &PaneGroup,
_detach_type: DetachType,
ctx: &mut ViewContext<PaneGroup>,
) {
// Always unsubscribe from views.
let network_log_view = self.network_log_view(ctx);
ctx.unsubscribe_to_view(&network_log_view);
ctx.unsubscribe_to_view(&self.view);
// Always deregister from the manager.
let window_id = ctx.window_id();
NetworkLogPaneManager::handle(ctx).update(ctx, |manager, _| {
manager.deregister_pane(&window_id);
});
}
fn snapshot(&self, _app: &AppContext) -> LeafContents {
LeafContents::NetworkLog
}
fn has_application_focus(&self, ctx: &mut ViewContext<PaneGroup>) -> bool {
self.view.is_self_or_child_focused(ctx)
}
fn focus(&self, ctx: &mut ViewContext<PaneGroup>) {
self.network_log_view(ctx)
.update(ctx, |view, ctx| view.focus(ctx));
}
fn shareable_link(
&self,
_ctx: &mut ViewContext<PaneGroup>,
) -> Result<ShareableLink, ShareableLinkError> {
Ok(ShareableLink::Base)
}
fn pane_configuration(&self) -> ModelHandle<PaneConfiguration> {
self.pane_configuration.clone()
}
fn is_pane_being_dragged(&self, ctx: &AppContext) -> bool {
self.view.as_ref(ctx).is_being_dragged()
}
}
+286
View File
@@ -0,0 +1,286 @@
use anyhow::Context;
use std::sync::Arc;
use url::Url;
use warpui::{AppContext, ModelHandle, SingletonEntity, ViewContext, ViewHandle};
use crate::{
app_state::{LeafContents, NotebookPaneSnapshot},
cloud_object::Space,
drive::{items::WarpDriveItemId, CloudObjectTypeAndId, OpenWarpDriveObjectSettings},
notebooks::{
link::{LinkEvent, NotebookLinks},
manager::{NotebookManager, NotebookSource},
notebook::{NotebookEvent, NotebookView},
},
server::ids::SyncId,
workflows::{WorkflowSelectionSource, WorkflowSource, WorkflowType},
workspaces::user_workspaces::UserWorkspaces,
};
use super::{
super::{DefaultSessionModeBehavior, Direction},
view::PaneView,
DetachType, PaneConfiguration, PaneContent, PaneGroup, PaneId, ShareableLink,
ShareableLinkError,
};
pub struct NotebookPane {
view: ViewHandle<PaneView<NotebookView>>,
pane_configuration: ModelHandle<PaneConfiguration>,
}
impl NotebookPane {
pub fn new(notebook_view: ViewHandle<NotebookView>, ctx: &mut AppContext) -> Self {
let pane_configuration = notebook_view.as_ref(ctx).pane_configuration().to_owned();
let view = ctx.add_typed_action_view(notebook_view.window_id(ctx), |ctx| {
let pane_id = PaneId::from_notebook_pane_ctx(ctx);
PaneView::new(pane_id, notebook_view, (), pane_configuration.clone(), ctx)
});
Self {
view,
pane_configuration,
}
}
/// Restore a notebook pane given its cloud notebook ID.
pub fn restore(
notebook_id: Option<SyncId>,
settings: &OpenWarpDriveObjectSettings,
ctx: &mut ViewContext<PaneGroup>,
) -> anyhow::Result<Self> {
let window_id = ctx.window_id();
let source = match notebook_id {
Some(id) => NotebookSource::Existing(id),
None => NotebookSource::New {
title: None,
owner: UserWorkspaces::as_ref(ctx)
.personal_drive(ctx)
.context("personal drive unavailable")?,
initial_folder_id: None,
},
};
Ok(NotebookManager::handle(ctx).update(ctx, |manager, ctx| {
manager.create_pane(&source, settings, window_id, ctx)
}))
}
pub fn notebook_view(&self, ctx: &AppContext) -> ViewHandle<NotebookView> {
self.view.as_ref(ctx).child(ctx)
}
}
impl PaneContent for NotebookPane {
fn id(&self) -> PaneId {
PaneId::from_notebook_pane_view(&self.view)
}
fn snapshot(&self, app: &AppContext) -> LeafContents {
let notebook_id = self.notebook_view(app).as_ref(app).notebook_id(app);
LeafContents::Notebook(NotebookPaneSnapshot::CloudNotebook {
notebook_id,
settings: OpenWarpDriveObjectSettings::default(),
})
}
fn attach(
&self,
_group: &PaneGroup,
focus_handle: crate::pane_group::focus_state::PaneFocusHandle,
ctx: &mut ViewContext<PaneGroup>,
) {
self.view
.update(ctx, |view, ctx| view.set_focus_handle(focus_handle, ctx));
let pane_id = self.id();
ctx.subscribe_to_view(&self.notebook_view(ctx), move |group, _, event, ctx| {
handle_notebook_event(group, pane_id, event, ctx);
});
subscribe_to_link_model(pane_id, &self.notebook_view(ctx).as_ref(ctx).links(), ctx);
ctx.subscribe_to_view(&self.view, move |group, _, event, ctx| {
group.handle_pane_view_event(pane_id, event, ctx);
});
let pane_group_id = ctx.view_id();
let window_id = ctx.window_id();
NotebookManager::handle(ctx).update(ctx, |manager, ctx| {
manager.register_pane(self, pane_group_id, window_id, ctx);
});
}
fn detach(
&self,
_group: &PaneGroup,
_detach_type: DetachType,
ctx: &mut ViewContext<PaneGroup>,
) {
// Always unsubscribe from views and models
let notebook_view = self.notebook_view(ctx);
ctx.unsubscribe_to_view(&notebook_view);
ctx.unsubscribe_to_model(&notebook_view.as_ref(ctx).links());
ctx.unsubscribe_to_view(&self.view);
// Always deregister from NotebookManager - it will be re-registered on attach if restored
NotebookManager::handle(ctx).update(ctx, |manager, ctx| manager.deregister_pane(self, ctx));
self.notebook_view(ctx)
.update(ctx, |view, ctx| view.on_detach(ctx));
}
fn has_application_focus(&self, ctx: &mut ViewContext<PaneGroup>) -> bool {
self.view.is_self_or_child_focused(ctx)
}
fn focus(&self, ctx: &mut ViewContext<PaneGroup>) {
self.notebook_view(ctx)
.update(ctx, |view, ctx| view.focus(ctx));
}
fn shareable_link(
&self,
ctx: &mut ViewContext<PaneGroup>,
) -> Result<ShareableLink, ShareableLinkError> {
self.notebook_view(ctx).read(ctx, |view, ctx| {
if let Some(link) = view.notebook_link(ctx) {
if let Ok(parsed_url) = Url::parse(link.as_str()) {
Ok(ShareableLink::Pane { url: parsed_url })
} else {
Err(ShareableLinkError::Unexpected(String::from(
"Failed to parse notebook url",
)))
}
} else {
Err(ShareableLinkError::Unexpected(String::from(
"Could not retrieve notebook url from view",
)))
}
})
}
fn pane_configuration(&self) -> ModelHandle<PaneConfiguration> {
self.pane_configuration.clone()
}
fn is_pane_being_dragged(&self, ctx: &AppContext) -> bool {
self.view.as_ref(ctx).is_being_dragged()
}
}
/// Subscribe to link events from a notebook view.
pub(super) fn subscribe_to_link_model(
pane_id: PaneId,
handle: &ModelHandle<NotebookLinks>,
ctx: &mut ViewContext<PaneGroup>,
) {
ctx.subscribe_to_model(handle, move |pane_group, _, event, ctx| match event {
LinkEvent::OpenFileNotebook { path, session } => {
// Opening local files is delegated to the parent workspace.
ctx.emit(crate::pane_group::Event::OpenFileInWarp {
path: path.clone(),
session: session.clone(),
})
}
LinkEvent::OpenWarpDriveLink {
open_warp_drive_args,
} => ctx.emit(crate::pane_group::Event::OpenWarpDriveLink {
open_warp_drive_args: open_warp_drive_args.clone(),
}),
LinkEvent::StartLocalSession { path } => {
pane_group.add_session_in_directory(
Direction::Right,
Some(pane_id),
None, /* chosen_shell */
Some(path.clone()),
None,
DefaultSessionModeBehavior::Apply,
ctx,
);
}
#[cfg(feature = "local_fs")]
LinkEvent::OpenFileWithTarget {
path,
target,
line_col,
} => {
// Emit event to workspace to handle opening in Warp
ctx.emit(crate::pane_group::Event::OpenFileWithTarget {
path: path.clone(),
target: target.clone(),
line_col: *line_col,
});
}
LinkEvent::RefreshLinks => (),
});
}
/// Applies a notebook event to the containing pane group.
fn handle_notebook_event(
group: &mut PaneGroup,
pane_id: PaneId,
event: &NotebookEvent,
ctx: &mut ViewContext<PaneGroup>,
) {
match event {
NotebookEvent::RunWorkflow { workflow, source } => {
run_notebook_workflow(workflow.clone(), *source, ctx)
}
NotebookEvent::EditWorkflow(id) => {
ctx.emit(crate::pane_group::Event::OpenCloudWorkflowForEdit(*id))
}
NotebookEvent::ViewInWarpDrive(id) => view_in_warp_drive(*id, ctx),
NotebookEvent::MoveToSpace {
cloud_object_type_and_id,
new_space,
} => move_to_space(*cloud_object_type_and_id, *new_space, ctx),
NotebookEvent::Pane(pane_event) => group.handle_pane_event(pane_id, pane_event, ctx),
NotebookEvent::OpenDriveObjectShareDialog {
cloud_object_type_and_id,
invitee_email,
source,
} => ctx.emit(crate::pane_group::Event::OpenDriveObjectShareDialog {
source: *source,
cloud_object_type_and_id: *cloud_object_type_and_id,
invitee_email: invitee_email.clone(),
}),
NotebookEvent::AttachPlanAsContext(ai_document_id) => {
ctx.emit(crate::pane_group::Event::AttachPlanAsContext {
ai_document_id: *ai_document_id,
})
}
}
}
/// Runs a workflow from a notebook contained in this pane group in the active session.
fn run_notebook_workflow(
workflow: Arc<WorkflowType>,
workflow_source: WorkflowSource,
ctx: &mut ViewContext<PaneGroup>,
) {
// If the notebook was visible, then this pane group is almost certainly the active tab at the
// workspace level. However, we dispatch to the workspace anyways for consistency (e.g. showing
// a message if the active session is busy).
ctx.emit(crate::pane_group::Event::RunWorkflow {
workflow,
workflow_source,
workflow_selection_source: WorkflowSelectionSource::Notebook,
argument_override: None,
});
}
fn view_in_warp_drive(id: WarpDriveItemId, ctx: &mut ViewContext<PaneGroup>) {
ctx.emit(crate::pane_group::Event::ViewInWarpDrive(id))
}
fn move_to_space(
cloud_object_type_and_id: CloudObjectTypeAndId,
space: Space,
ctx: &mut ViewContext<PaneGroup>,
) {
ctx.emit(crate::pane_group::Event::MoveToSpace {
cloud_object_type_and_id,
space,
});
}
+151
View File
@@ -0,0 +1,151 @@
use warpui::{AppContext, ModelHandle, SingletonEntity, View, ViewContext, ViewHandle, WindowId};
use crate::{
app_state::{LeafContents, SettingsPaneSnapshot},
settings_view::{
pane_manager::SettingsPaneManager, SettingsSection, SettingsView, SettingsViewEvent,
},
};
use super::{
view::PaneView, DetachType, PaneConfiguration, PaneContent, PaneGroup, PaneId, ShareableLink,
ShareableLinkError,
};
pub struct SettingsPane {
view: ViewHandle<PaneView<SettingsView>>,
pane_configuration: ModelHandle<PaneConfiguration>,
}
impl SettingsPane {
fn from_view(settings_view: ViewHandle<SettingsView>, ctx: &mut AppContext) -> Self {
let pane_configuration = settings_view.as_ref(ctx).pane_configuration();
let view = ctx.add_typed_action_view(settings_view.window_id(ctx), |ctx| {
let pane_id = PaneId::from_settings_pane_ctx(ctx);
PaneView::new(pane_id, settings_view, (), pane_configuration.clone(), ctx)
});
Self {
view,
pane_configuration,
}
}
pub fn new<V: View>(
page: SettingsSection,
search_query: Option<&str>,
window_id: WindowId,
ctx: &mut ViewContext<V>,
) -> Self {
let view = SettingsPaneManager::handle(ctx)
.read(ctx, |manager, _| manager.settings_view(window_id));
view.update(ctx, |view, ctx| {
view.set_and_refresh_current_page(page, ctx);
if let Some(search_query) = search_query {
view.set_search_query(search_query, ctx);
}
});
Self::from_view(view, ctx)
}
fn settings_view(&self, ctx: &AppContext) -> ViewHandle<SettingsView> {
self.view.as_ref(ctx).child(ctx)
}
}
impl PaneContent for SettingsPane {
fn id(&self) -> PaneId {
PaneId::from_settings_pane_view(&self.view)
}
fn attach(
&self,
_group: &PaneGroup,
focus_handle: crate::pane_group::focus_state::PaneFocusHandle,
ctx: &mut ViewContext<PaneGroup>,
) {
let pane_id = self.id();
self.view
.update(ctx, |view, ctx| view.set_focus_handle(focus_handle, ctx));
ctx.subscribe_to_view(
&self.settings_view(ctx),
move |pane_group, _, event, ctx| handle_settings_event(pane_group, pane_id, event, ctx),
);
ctx.subscribe_to_view(&self.view, move |group, _, event, ctx| {
group.handle_pane_view_event(pane_id, event, ctx);
});
let pane_group_id = ctx.view_id();
let window_id = ctx.window_id();
SettingsPaneManager::handle(ctx).update(ctx, |manager, ctx| {
manager.register_pane(self, pane_group_id, window_id, ctx);
});
}
fn detach(
&self,
_group: &PaneGroup,
_detach_type: DetachType,
ctx: &mut ViewContext<PaneGroup>,
) {
// Always unsubscribe from views
let settings_view = self.settings_view(ctx);
ctx.unsubscribe_to_view(&settings_view);
ctx.unsubscribe_to_view(&self.view);
// Always deregister from SettingsPaneManager - it will be re-registered on attach if restored.
// Only clear the locator if this is the currently registered settings pane for the window.
let window_id = ctx.window_id();
let pane_group_id = ctx.view_id();
let pane_id = self.id();
SettingsPaneManager::handle(ctx).update(ctx, |manager, ctx| {
manager.deregister_pane(&window_id, pane_group_id, pane_id, ctx);
});
}
fn snapshot(&self, app: &AppContext) -> LeafContents {
let view = self.settings_view(app);
let current_page = view.as_ref(app).current_settings_section();
LeafContents::Settings(SettingsPaneSnapshot::Local {
current_page,
search_query: None,
})
}
fn has_application_focus(&self, ctx: &mut ViewContext<PaneGroup>) -> bool {
self.view.is_self_or_child_focused(ctx)
}
fn focus(&self, ctx: &mut ViewContext<PaneGroup>) {
self.settings_view(ctx)
.update(ctx, |view, ctx| view.focus(ctx));
}
fn shareable_link(
&self,
_ctx: &mut ViewContext<PaneGroup>,
) -> Result<ShareableLink, ShareableLinkError> {
Ok(ShareableLink::Base)
}
fn pane_configuration(&self) -> ModelHandle<PaneConfiguration> {
self.pane_configuration.clone()
}
fn is_pane_being_dragged(&self, ctx: &AppContext) -> bool {
self.view.as_ref(ctx).is_being_dragged()
}
}
fn handle_settings_event(
group: &mut PaneGroup,
pane_id: PaneId,
event: &SettingsViewEvent,
ctx: &mut ViewContext<PaneGroup>,
) {
if let SettingsViewEvent::Pane(pane_event) = event {
group.handle_pane_event(pane_id, pane_event, ctx);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,213 @@
use crate::appearance::Appearance;
use crate::ui_components::buttons::{icon_button, icon_button_with_color};
use crate::ui_components::icons::Icon;
use super::super::header_content::HeaderRenderContext;
use super::{ActionPayload, PaneHeaderAction};
use warp_core::ui::icons::ICON_DIMENSIONS;
use warp_core::ui::theme::Fill;
use warpui::elements::{
Align, Clipped, ConstrainedBox, Container, CrossAxisAlignment, Flex, Hoverable,
MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, SavePosition, Shrinkable,
Text,
};
use warpui::text_layout::ClipConfig;
use warpui::ui_components::components::UiComponent;
use warpui::Element;
/// Horizontal padding applied inside each edge column of the three-column header.
pub const HEADER_EDGE_PADDING: f32 = 4.;
fn build_icon_button(
appearance: &Appearance,
icon: Icon,
mouse_state: MouseStateHandle,
icon_color: Option<Fill>,
) -> Hoverable {
if let Some(color) = icon_color {
icon_button_with_color(appearance, icon, false, mouse_state, color)
} else {
icon_button(appearance, icon, false, mouse_state)
}
.build()
}
fn apply_size_constraint(element: Box<dyn Element>, size: Option<f32>) -> Box<dyn Element> {
if let Some(size) = size {
ConstrainedBox::new(element)
.with_width(size)
.with_height(size)
.finish()
} else {
element
}
}
/// Renders the standard pane close button to dispatch the close action.
pub fn render_pane_close_button<A: ActionPayload, B: ActionPayload>(
appearance: &Appearance,
mouse_state: MouseStateHandle,
icon_color: Option<Fill>,
button_size: Option<f32>,
) -> Box<dyn Element> {
let button = build_icon_button(appearance, Icon::X, mouse_state, icon_color)
.on_click(|ctx, _, _| ctx.dispatch_typed_action(PaneHeaderAction::<A, B>::Close));
apply_size_constraint(button.finish(), button_size)
}
/// Renders the standard pane overflow menu button to dispatch the pane overflow menu action.
pub fn render_pane_overflow_button<A: ActionPayload, B: ActionPayload>(
appearance: &Appearance,
mouse_state: MouseStateHandle,
position_id: &str,
icon_color: Option<Fill>,
button_size: Option<f32>,
) -> Box<dyn Element> {
let button = build_icon_button(appearance, Icon::DotsVertical, mouse_state, icon_color)
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(PaneHeaderAction::<A, B>::OpenOverflowMenu)
});
let button = apply_size_constraint(button.finish(), button_size);
SavePosition::new(button, position_id).finish()
}
/// Renders a row containing the standard pane overflow and close buttons.
#[cfg_attr(target_family = "wasm", allow(dead_code))]
pub fn render_pane_header_buttons<A: ActionPayload, B: ActionPayload>(
header_ctx: &HeaderRenderContext<'_>,
appearance: &Appearance,
show_close_button: bool,
icon_color: Option<Fill>,
button_size: Option<f32>,
) -> Box<dyn Element> {
let mut row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_size(MainAxisSize::Min);
if header_ctx.has_overflow_items {
row.add_child(render_pane_overflow_button::<A, B>(
appearance,
header_ctx.overflow_button_mouse_state.clone(),
&header_ctx.overflow_button_position_id,
icon_color,
button_size,
));
}
if show_close_button {
row.add_child(render_pane_close_button::<A, B>(
appearance,
header_ctx.close_button_mouse_state.clone(),
icon_color,
button_size,
));
}
row.finish()
}
/// Renders a title text element with the standard pane header font, color, and clipping.
pub fn render_pane_header_title_text(
title: impl Into<std::borrow::Cow<'static, str>>,
appearance: &Appearance,
clip_config: ClipConfig,
) -> Box<dyn Element> {
let font_size = appearance.ui_font_size();
let font_color = appearance
.theme()
.sub_text_color(appearance.theme().background());
Text::new_inline(title, appearance.ui_font_family(), font_size)
.with_color(font_color.into())
.with_clip(clip_config)
.finish()
}
/// Estimates the minimum width needed for a header edge column containing
/// `icon_button_count` standard icon buttons, accounting for the edge
/// column's internal padding.
pub fn header_edge_min_width(icon_button_count: u32) -> f32 {
icon_button_count as f32 * ICON_DIMENSIONS + HEADER_EDGE_PADDING
}
/// Width constraints applied to both the left and right edge columns in
/// [`render_three_column_header`]. Giving both edges the same min/max
/// keeps the center title visually centered regardless of how much content
/// each side actually contains.
pub struct CenteredHeaderEdgeWidth {
/// Minimum width — typically the width of the always-visible buttons
/// so they are never clipped.
pub min: f32,
/// Maximum width — caps how far the edge columns can grow so they
/// don't eat into the title's space.
pub max: f32,
}
/// Renders a 3-column header layout: `[left] [title] [right]`.
///
/// The `left` and `right` edge columns share equal min/max width constraints
/// so the title stays centered. Each edge gets inner padding (left or right).
/// `extra_left_inset` adds additional left padding inside the left column
/// (e.g. to make room for a floating overlay button) without affecting centering.
pub fn render_three_column_header(
left: Box<dyn Element>,
title: Box<dyn Element>,
right: Box<dyn Element>,
edge_width: CenteredHeaderEdgeWidth,
extra_left_inset: f32,
is_pane_dragging: bool,
) -> Box<dyn Element> {
let main_axis_size = if is_pane_dragging {
MainAxisSize::Min
} else {
MainAxisSize::Max
};
let left_constrained = ConstrainedBox::new(
Container::new(left)
.with_padding_left(HEADER_EDGE_PADDING + extra_left_inset)
.finish(),
)
.with_min_width(edge_width.min)
.with_max_width(edge_width.max)
.finish();
let right_constrained = ConstrainedBox::new(
Container::new(right)
.with_padding_right(HEADER_EDGE_PADDING)
.finish(),
)
.with_min_width(edge_width.min)
.with_max_width(edge_width.max)
.finish();
let mut center_row = Flex::row()
.with_main_axis_alignment(MainAxisAlignment::Center)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_size(main_axis_size);
center_row.add_child(if is_pane_dragging {
title
} else {
Shrinkable::new(1., Clipped::new(title).finish()).finish()
});
let center = Align::new(center_row.finish()).finish();
let mut row = Flex::row()
.with_main_axis_size(main_axis_size)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_cross_axis_alignment(CrossAxisAlignment::Stretch);
row.add_child(if is_pane_dragging {
left_constrained
} else {
Shrinkable::new(1., left_constrained).finish()
});
row.add_child(if is_pane_dragging {
center
} else {
Shrinkable::new(1., center).finish()
});
row.add_child(right_constrained);
row.finish()
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,202 @@
use std::sync::Arc;
use warp_core::ui::appearance::Appearance;
use warpui::{
elements::Empty, platform::WindowStyle, App, AppContext, Element, Entity, TypedActionView,
View, ViewContext,
};
use crate::{
ai::blocklist::BlocklistAIHistoryModel,
auth::AuthStateProvider,
cloud_object::model::persistence::CloudModel,
menu::MenuItemFields,
pane_group::{focus_state::PaneFocusHandle, BackingView, PaneConfiguration, PaneId, PaneView},
server::server_api::{object::MockObjectClient, ServerApiProvider},
settings_view::keybindings::KeybindingChangedNotifier,
terminal::shared_session::permissions_manager::SessionPermissionsManager,
test_util::settings::initialize_settings_for_tests,
NetworkStatus, SyncQueue, TeamTesterStatus, UpdateManager, UserProfiles, UserWorkspaces,
};
use super::{Event, OpenOverlay};
#[cfg(test)]
use crate::server::server_api::workspace::MockWorkspaceClient;
#[cfg(test)]
use crate::server::server_api::team::MockTeamClient;
/// A dummy view that is also a backing pane view for testing purposes.
struct TestView {
counter: usize,
close_invoked: bool,
}
#[derive(Clone, Debug)]
enum TestViewAction {
IncrementCounter,
}
impl TestView {
fn new() -> Self {
Self {
counter: 0,
close_invoked: false,
}
}
}
impl Entity for TestView {
type Event = ();
}
impl View for TestView {
fn ui_name() -> &'static str {
"TestView"
}
fn render(&self, _app: &AppContext) -> Box<dyn Element> {
Empty::new().finish()
}
}
impl TypedActionView for TestView {
type Action = TestViewAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
TestViewAction::IncrementCounter => {
self.counter += 1;
ctx.notify();
}
}
}
}
impl BackingView for TestView {
type PaneHeaderOverflowMenuAction = TestViewAction;
type CustomAction = TestViewAction;
type AssociatedData = ();
fn handle_pane_header_overflow_menu_action(
&mut self,
action: &Self::PaneHeaderOverflowMenuAction,
ctx: &mut ViewContext<Self>,
) {
self.handle_action(action, ctx);
}
fn handle_custom_action(&mut self, action: &Self::CustomAction, ctx: &mut ViewContext<Self>) {
self.handle_action(action, ctx);
}
fn close(&mut self, ctx: &mut ViewContext<Self>) {
self.close_invoked = true;
ctx.notify();
}
fn focus_contents(&mut self, ctx: &mut ViewContext<Self>) {
ctx.focus_self();
}
fn render_header_content(
&self,
_ctx: &super::HeaderRenderContext<'_>,
_app: &AppContext,
) -> super::HeaderContent {
super::HeaderContent::simple("Test")
}
fn set_focus_handle(&mut self, _focus_handle: PaneFocusHandle, _ctx: &mut ViewContext<Self>) {}
}
fn initialize_app(app: &mut App) {
initialize_settings_for_tests(app);
app.add_singleton_model(|_| Appearance::mock());
app.add_singleton_model(|_| NetworkStatus::new());
let mock_team_client = Arc::new(MockTeamClient::new());
let mock_workspace_client = Arc::new(MockWorkspaceClient::new());
app.add_singleton_model(SyncQueue::mock);
app.add_singleton_model(|ctx| {
UserWorkspaces::mock(
mock_team_client.clone(),
mock_workspace_client.clone(),
vec![],
ctx,
)
});
app.add_singleton_model(TeamTesterStatus::new);
app.add_singleton_model(|_| ServerApiProvider::new_for_test());
app.add_singleton_model(|_| UserProfiles::new(Vec::new()));
app.add_singleton_model(CloudModel::mock);
app.add_singleton_model(|ctx| UpdateManager::new(None, Arc::new(MockObjectClient::new()), ctx));
app.add_singleton_model(SessionPermissionsManager::new);
app.add_singleton_model(|_| KeybindingChangedNotifier::mock());
app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
#[cfg(feature = "voice_input")]
app.add_singleton_model(voice_input::VoiceInput::new);
app.add_singleton_model(|_| AuthStateProvider::new_for_test());
}
#[test]
fn test_overflow_menu_items() {
App::test((), |mut app| async move {
initialize_app(&mut app);
let (_, pane_view) = app.add_window(WindowStyle::NotStealFocus, |ctx| {
let test_view = ctx.add_typed_action_view(|_| TestView::new());
let pane_config = ctx.add_model(|_| PaneConfiguration::new("Test"));
PaneView::new(PaneId::dummy_pane_id(), test_view, (), pane_config, ctx)
});
let header = pane_view.read(&app, |pane, _ctx| pane.header().to_owned());
let overflow_menu = header.read(&app, |header, _ctx| header.overflow_menu.to_owned());
let menu_item_label = "Increment counter";
let menu_items = vec![MenuItemFields::new(menu_item_label)
.with_on_select_action(TestViewAction::IncrementCounter)
.into_item()];
// Set the menu items and open the menu.
header.update(&mut app, |header, ctx| {
header.set_overflow_menu_items(menu_items, ctx);
header.open_overlay = OpenOverlay::OverflowMenu;
ctx.notify();
});
// Mimic what happens when clicking the item.
overflow_menu.update(&mut app, |menu, ctx| {
menu.set_selected_by_name(menu_item_label, ctx);
menu.mimic_confirm(ctx);
});
pane_view.read(&app, |view, ctx| {
assert_eq!(view.child(ctx).as_ref(ctx).counter, 1);
});
})
}
#[test]
fn test_handle_close() {
App::test((), |mut app| async move {
initialize_app(&mut app);
let (_, pane_view) = app.add_window(WindowStyle::NotStealFocus, |ctx| {
let test_view = ctx.add_view(|_| TestView::new());
let pane_config = ctx.add_model(|_| PaneConfiguration::new("Test"));
PaneView::new(PaneId::dummy_pane_id(), test_view, (), pane_config, ctx)
});
pane_view.update(&mut app, |pane_view, ctx| {
pane_view.header().update(ctx, |_header, ctx| {
// Mimic clicking the close button.
ctx.emit(Event::Close);
})
});
pane_view.read(&app, |view, ctx| {
assert!(view.child(ctx).as_ref(ctx).close_invoked);
});
})
}
@@ -0,0 +1,284 @@
//! Support for pane contents that are shareable, like sessions and Warp Drive objects.
//!
//! This is tightly coupled to the pane header so that different overlays (context menus, the
//! sharing dialog, and so on) are correctly displayed.
use warp_core::{features::FeatureFlag, ui::appearance::Appearance};
use warpui::{
elements::{MouseStateHandle, ParentElement},
platform::Cursor,
ui_components::components::UiComponent,
AppContext, Element, ViewContext, ViewHandle,
};
use warp_core::ui::theme::Fill;
use warpui::elements::ConstrainedBox;
use crate::{
drive::sharing::{
dialog::{SharingDialog, SharingDialogEvent},
ContentEditability, ShareableObject,
},
pane_group::BackingView,
server::telemetry::SharingDialogSource,
ui_components::buttons::{icon_button, icon_button_with_color},
ui_components::icons::Icon,
};
use super::{Event, OpenOverlay, PaneHeader, PaneHeaderAction};
const UNSHARABLE_CONVERSATION_TOOLTIP: &str =
"This conversation cannot be shared because it is not \
stored in the cloud.\nTo sync to cloud and share, enable the setting under Settings > Privacy, \
and then make another request.";
/// Pane header component for sharing the pane contents.
pub struct SharedPaneContent {
sharing_dialog: ViewHandle<SharingDialog>,
/// Mouse state handle for the primary sharing action.
/// * If the object is view-only, this is a "copy link" button
/// * Otherwise, this is a "share" button
primary_button_handle: MouseStateHandle,
/// Mouse state for the secondary view-only indicator.
view_only_icon_handle: MouseStateHandle,
}
impl SharedPaneContent {
pub fn new<P: BackingView>(ctx: &mut ViewContext<PaneHeader<P>>) -> Self {
let sharing_dialog = ctx.add_typed_action_view(|ctx| SharingDialog::new(None, ctx));
ctx.subscribe_to_view(&sharing_dialog, move |me, _, event, ctx| {
me.handle_sharing_dialog_event(event, ctx);
});
Self {
sharing_dialog,
primary_button_handle: Default::default(),
view_only_icon_handle: Default::default(),
}
}
}
impl<P: BackingView> PaneHeader<P> {
pub fn set_shareable_object(
&mut self,
shareable_object: Option<ShareableObject>,
ctx: &mut ViewContext<Self>,
) {
self.sharing_dialog().update(ctx, |dialog, ctx| {
dialog.set_target(shareable_object, ctx);
})
}
pub fn sharing_dialog(&self) -> &ViewHandle<SharingDialog> {
&self.shared_content.sharing_dialog
}
pub fn has_shareable_object<C: warpui::ViewAsRef>(&self, ctx: &C) -> bool {
self.sharing_dialog().as_ref(ctx).has_target()
}
pub fn has_shareable_shared_session<C: warpui::ViewAsRef>(&self, ctx: &C) -> bool {
self.sharing_dialog()
.as_ref(ctx)
.has_shared_session_target()
}
pub fn is_sharing_dialog_enabled<C: warpui::ViewAsRef>(&self, ctx: &C) -> bool {
let sharing_enabled = self.has_shareable_object(ctx);
if self.has_shareable_shared_session(ctx) {
sharing_enabled && FeatureFlag::SessionSharingAcls.is_enabled()
} else {
sharing_enabled
}
}
/// Share the panes' contents.
///
/// If the user can share the pane contents, this will bring up a sharing dialog. Otherwise, it copies
/// the backing object's URL.
pub fn share_pane_contents(
&mut self,
source: SharingDialogSource,
ctx: &mut ViewContext<Self>,
) {
if !self.is_sharing_dialog_enabled(ctx) {
return;
}
if !self
.sharing_dialog()
.as_ref(ctx)
.editability(ctx)
.can_edit()
{
self.sharing_dialog()
.update(ctx, |dialog, ctx| dialog.copy_link(ctx));
return;
}
let dialog_opened = match self.open_overlay {
OpenOverlay::OverflowMenu => {
self.open_overlay = OpenOverlay::SharingDialog;
ctx.emit(Event::PaneHeaderOverflowMenuToggled(false));
ctx.focus(&self.shared_content.sharing_dialog);
true
}
OpenOverlay::SharingDialog => {
self.close_overlay(ctx);
false
}
OpenOverlay::None => {
self.open_overlay = OpenOverlay::SharingDialog;
ctx.focus(&self.shared_content.sharing_dialog);
true
}
};
if dialog_opened {
self.sharing_dialog()
.update(ctx, |dialog, ctx| dialog.report_open(source, ctx));
}
ctx.notify();
}
fn handle_sharing_dialog_event(
&mut self,
event: &SharingDialogEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
SharingDialogEvent::Close => {
self.close_overlay(ctx);
}
}
}
/// Render controls for sharing the pane contents. The controls shown depend on the current
/// user's access level on the contents.
pub fn render_sharing_controls(
&self,
element: &mut impl ParentElement,
appearance: &Appearance,
icon_color_override: Option<Fill>,
button_size_override: Option<f32>,
app: &AppContext,
) {
if !self.is_sharing_dialog_enabled(app) {
return;
}
let is_unsharable_conversation = self
.sharing_dialog()
.as_ref(app)
.is_unsharable_conversation(app);
let editability = self.sharing_dialog().as_ref(app).editability(app);
let (primary_button_icon, primary_button_active, primary_tooltip_text) =
if is_unsharable_conversation {
(
Icon::Share,
false,
UNSHARABLE_CONVERSATION_TOOLTIP.to_string(),
)
} else if editability.can_edit() {
(
Icon::Share,
self.open_overlay == OpenOverlay::SharingDialog,
"Share".to_string(),
)
} else {
(Icon::Link, false, "Copy link".to_string())
};
let ui_builder = appearance.ui_builder().clone();
let theme = appearance.theme();
// When disabled, use the disabled text color for the icon
let icon_color = if is_unsharable_conversation {
Fill::Solid(theme.disabled_text_color(theme.background()).into())
} else {
icon_color_override
.unwrap_or_else(|| Fill::Solid(theme.main_text_color(theme.background()).into()))
};
let button_builder = icon_button_with_color(
appearance,
primary_button_icon,
primary_button_active,
self.shared_content.primary_button_handle.clone(),
icon_color,
)
.with_tooltip(move || {
ConstrainedBox::new(ui_builder.tool_tip(primary_tooltip_text).build().finish())
.with_max_width(400.)
.finish()
});
let mut primary_button = button_builder.build();
if !is_unsharable_conversation {
primary_button = primary_button.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(
PaneHeaderAction::<P::PaneHeaderOverflowMenuAction, P::CustomAction>::ShareContents,
)
});
}
let primary_button = primary_button
.with_cursor(if is_unsharable_conversation {
Cursor::Arrow
} else {
Cursor::PointingHand
})
.finish();
let primary_button = if let Some(size) = button_size_override {
ConstrainedBox::new(primary_button)
.with_width(size)
.with_height(size)
.finish()
} else {
primary_button
};
element.add_child(primary_button);
if !editability.can_edit() {
let mut tooltip_text = String::from("Read-only");
if matches!(editability, ContentEditability::RequiresLogin) {
tooltip_text.push_str(". Sign in to edit");
}
let ui_builder = appearance.ui_builder().clone();
let view_only_button = if let Some(icon_color) = icon_color_override {
icon_button_with_color(
appearance,
Icon::Eye,
false,
self.shared_content.view_only_icon_handle.clone(),
icon_color,
)
} else {
icon_button(
appearance,
Icon::Eye,
false,
self.shared_content.view_only_icon_handle.clone(),
)
}
.with_tooltip(move || ui_builder.tool_tip(tooltip_text).build().finish())
.build()
.with_cursor(Cursor::PointingHand)
.finish();
let view_only_button = if let Some(size) = button_size_override {
ConstrainedBox::new(view_only_button)
.with_width(size)
.with_height(size)
.finish()
} else {
view_only_button
};
element.add_child(view_only_button);
}
}
}
@@ -0,0 +1,152 @@
//! Types for declarative pane header content.
//!
//! This module provides the infrastructure for backing views to declaratively
//! specify their header content without worrying about draggable behavior.
use warp_core::ui::theme::Fill;
use warpui::{
elements::{DraggableState, MouseStateHandle},
fonts::Properties,
text_layout::ClipConfig,
AppContext, Element,
};
/// Closure that renders sharing controls (share button, view-only indicator) for a pane header.
/// Accepts optional icon color and button size overrides.
type RenderSharingControlsFn<'a> =
Box<dyn Fn(&AppContext, Option<Fill>, Option<f32>) -> Option<Box<dyn Element>> + 'a>;
/// Context provided to backing views when rendering header content.
///
/// This provides read-only access to appearance and configuration,
/// plus a helper for creating draggable spacer elements.
pub struct HeaderRenderContext<'a> {
/// Shared draggable state for the header.
pub draggable_state: DraggableState,
/// Mouse state for the pane close button (owned by PaneHeader).
pub close_button_mouse_state: MouseStateHandle,
/// Mouse state for the pane overflow button (owned by PaneHeader).
pub overflow_button_mouse_state: MouseStateHandle,
/// SavePosition ID for the overflow button (needed for overlay anchoring).
pub overflow_button_position_id: String,
/// Whether the overflow menu has any items to display.
pub has_overflow_items: bool,
/// Extra left inset for the header's left-side controls, used to avoid
/// overlap with a floating button overlay (e.g. the vertical tabs toggle).
pub header_left_inset: f32,
/// Closure that renders the sharing controls. Use [`Self::sharing_controls`] to call this.
pub(super) render_sharing_controls_fn: RenderSharingControlsFn<'a>,
}
impl HeaderRenderContext<'_> {
/// Renders the sharing controls (share button, view-only indicator) for this pane.
/// Returns `None` if sharing is not enabled.
pub fn sharing_controls(
&self,
app: &AppContext,
icon_color: Option<Fill>,
button_size: Option<f32>,
) -> Option<Box<dyn Element>> {
(self.render_sharing_controls_fn)(app, icon_color, button_size)
}
}
/// Render-time options for the header that apply to all header types.
/// These control visual aspects that were previously stored in PaneConfiguration.
#[derive(Default)]
pub struct StandardHeaderOptions {
/// If true, always show header icons (close button, overflow menu) regardless of hover state.
pub always_show_icons: bool,
/// If true, a menu within the header is currently open, so icons should remain visible.
pub has_open_menu: bool,
/// Width for the left and right edge containers (default: 80.0).
pub control_container_width: Option<f32>,
/// If true, hides the close button even when in a split pane.
/// Use for panes that should be closed via other means (e.g., accept/reject buttons).
pub hide_close_button: bool,
}
impl StandardHeaderOptions {
/// Default control container width.
pub const DEFAULT_CONTROL_CONTAINER_WIDTH: f32 = 80.0;
/// Returns the control container width, using default if not specified.
pub fn control_container_width(&self) -> f32 {
self.control_container_width
.unwrap_or(Self::DEFAULT_CONTROL_CONTAINER_WIDTH)
}
}
pub struct StandardHeader {
/// The title text to display.
pub title: String,
/// Optional secondary title text (displayed after main title).
pub title_secondary: Option<String>,
/// Optional title text styling.
pub title_style: Option<Properties>,
/// Configuration for clipping the title text when it overflows.
pub title_clip_config: ClipConfig,
/// Optional max width for the title in pixels.
/// If set, the title will be constrained to at most this width.
pub title_max_width: Option<f32>,
/// Optional element rendered immediately left of the title.
pub left_of_title: Option<Box<dyn Element>>,
/// Optional element rendered immediately right of the title.
pub right_of_title: Option<Box<dyn Element>>,
/// Optional element rendered left of the overflow menu button.
pub left_of_overflow: Option<Box<dyn Element>>,
/// Render options controlling visual behavior.
pub options: StandardHeaderOptions,
}
/// Content that a backing view can return for its pane header.
///
/// The framework handles wrapping the content with draggable behavior,
/// so backing views don't need to worry about drag-and-drop.
pub enum HeaderContent {
/// Standard header with title and optional customization points.
///
/// The framework renders this with the standard pane header layout:
/// `[toolbelt buttons] [left_of_title] [title] [right_of_title] ... [left_of_overflow] [overflow] [close]`
///
/// The entire header is wrapped with draggable behavior.
Standard(StandardHeader),
/// Fully custom header content.
///
/// The framework wraps the entire element with draggable behavior.
/// Use this for views that need complete control over header rendering
/// but still want automatic drag-and-drop support.
Custom {
/// The custom element to render.
element: Box<dyn Element>,
/// If `true`, the framework does NOT automatically wrap this with
/// draggable behavior. The view is responsible for calling
/// `PaneHeader::render_pane_header_draggable()` on the appropriate elements.
///
/// Use this for views like CodeView that have a custom tab bar where only
/// part of the header (the empty space) should be draggable.
has_custom_draggable_behavior: bool,
},
}
impl HeaderContent {
/// Creates a simple standard header with just a title.
///
/// Uses `ClipConfig::start()` and default options. This is the most common
/// header configuration for panes that just need to display a title.
pub fn simple(title: impl Into<String>) -> Self {
Self::Standard(StandardHeader {
title: title.into(),
title_secondary: None,
title_style: None,
title_clip_config: ClipConfig::start(),
title_max_width: None,
left_of_title: None,
right_of_title: None,
left_of_overflow: None,
options: StandardHeaderOptions::default(),
})
}
}
+453
View File
@@ -0,0 +1,453 @@
pub mod header;
pub mod header_content;
use crate::pane_group::pane::ActionOrigin;
use crate::{
appearance::Appearance,
pane_group::{Direction, SplitPaneState, TabBarHoverIndex},
server::telemetry::SharingDialogSource,
settings::{PaneSettings, PaneSettingsChangedEvent},
util::bindings::CustomAction,
};
use super::{
BackingView, PaneConfiguration, PaneConfigurationEvent, PaneId, PaneStack, PaneStackEvent,
};
use header::PaneHeader;
use warpui::{
elements::{
Border, Container, DropTarget, DropTargetData, Flex, MainAxisSize, ParentElement,
SavePosition, Shrinkable,
},
keymap::EditableBinding,
presenter::ChildView,
AppContext, Element, Entity, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext,
ViewHandle,
};
use crate::pane_group::focus_state::{PaneFocusHandle, PaneGroupFocusEvent};
pub use header::PaneHeaderAction;
pub use header::PaneHeaderAction::CustomAction as PaneHeaderCustomAction;
pub use header_content::{
HeaderContent, HeaderRenderContext, StandardHeader, StandardHeaderOptions,
};
const HAS_SHARED_OBJECT_CONTEXT_KEY: &str = "PaneView_HasSharedObject";
pub fn init(app: &mut AppContext) {
use warpui::keymap::macros::*;
app.register_editable_bindings([EditableBinding::new(
"pane:share_pane_contents",
"Share pane",
PaneAction::ShareContents,
)
.with_custom_action(CustomAction::SharePaneContents)
.with_context_predicate(id!("PaneView") & id!(HAS_SHARED_OBJECT_CONTEXT_KEY))]);
}
pub enum PaneViewEvent {
MovePaneWithinPaneGroup {
target_id: PaneId,
direction: Direction,
},
DroppedOnTabBar {
origin: ActionOrigin,
},
DraggedOntoTabBar {
origin: ActionOrigin,
tab_hover_index: TabBarHoverIndex,
hidden_pane_preview_direction: Direction,
},
PaneDraggedOutsideTabBarOrPaneGroup,
PaneDragEnded,
PaneHeaderClicked,
}
#[derive(Debug, Clone)]
pub enum PaneAction {
ShareContents,
}
impl<P: BackingView> Entity for PaneView<P> {
type Event = PaneViewEvent;
}
pub struct PaneView<P: BackingView> {
pane_id: PaneId,
/// Navigation stack of backing views.
pane_stack: ModelHandle<PaneStack<P>>,
pane_configuration: ModelHandle<PaneConfiguration>,
header: ViewHandle<PaneHeader<P>>,
is_being_dragged: bool,
focus_handle: Option<PaneFocusHandle>,
}
impl<P: BackingView> PaneView<P> {
pub(super) fn new(
pane_id: PaneId,
child: ViewHandle<P>,
child_data: P::AssociatedData,
pane_configuration: ModelHandle<PaneConfiguration>,
ctx: &mut ViewContext<Self>,
) -> Self {
// Create the pane stack model
let pane_stack = ctx.add_model(|ctx| PaneStack::new(child_data, child, ctx));
let header = ctx.add_typed_action_view(|ctx| {
// The PaneGroup will update the split pane state for the backing view once it's attached.
PaneHeader::new(pane_stack.clone(), pane_configuration.clone(), ctx)
});
ctx.subscribe_to_view(&header, |me, _, event, ctx| {
me.handle_header_event(event, ctx)
});
ctx.subscribe_to_model(&pane_configuration, |me, _, event, ctx| {
me.handle_pane_configuration_event(event, ctx);
});
ctx.subscribe_to_model(&pane_stack, |me, _, event, ctx| {
me.handle_pane_stack_event(event, ctx);
});
ctx.subscribe_to_model(&PaneSettings::handle(ctx), |_, _, event, ctx| {
if matches!(
event,
PaneSettingsChangedEvent::ShouldDimInactivePanes { .. }
) {
ctx.notify();
}
});
Self {
pane_id,
pane_stack,
pane_configuration,
header,
is_being_dragged: false,
focus_handle: None,
}
}
/// Sets the focus handle for this pane view, enabling it to track its split pane state.
pub fn set_focus_handle(&mut self, focus_handle: PaneFocusHandle, ctx: &mut ViewContext<Self>) {
ctx.subscribe_to_model(focus_handle.focus_state_handle(), |me, _, event, ctx| {
me.handle_focus_state_event(event, ctx);
});
self.header.update(ctx, |header, ctx| {
header.set_focus_handle(focus_handle.clone(), ctx);
});
// Set the focus handle for every pane in the stack.
let pane_stack = self.pane_stack.clone();
let views: Vec<_> = pane_stack.as_ref(ctx).views().cloned().collect();
for view in views {
view.update(ctx, |child, ctx| {
child.set_focus_handle(focus_handle.clone(), ctx);
});
}
self.focus_handle = Some(focus_handle);
}
fn handle_focus_state_event(
&mut self,
event: &PaneGroupFocusEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
PaneGroupFocusEvent::FocusChanged { .. }
| PaneGroupFocusEvent::InSplitPaneChanged
| PaneGroupFocusEvent::FocusedPaneMaximizedChanged => {
// Re-render to update dimming and header visibility.
ctx.notify();
}
PaneGroupFocusEvent::ActiveSessionChanged { .. } => {}
}
}
/// Returns the pane stack model.
pub fn pane_stack(&self) -> &ModelHandle<PaneStack<P>> {
&self.pane_stack
}
/// Returns the topmost (active) child view in the navigation stack.
pub fn child(&self, app: &AppContext) -> ViewHandle<P> {
self.pane_stack.as_ref(app).active_view().clone()
}
/// Returns the associated data for the active child view in the navigation stack.
pub fn child_data<'a>(&self, app: &'a AppContext) -> &'a P::AssociatedData {
self.pane_stack.as_ref(app).active_data()
}
pub fn header(&self) -> &ViewHandle<PaneHeader<P>> {
&self.header
}
pub fn is_being_dragged(&self) -> bool {
self.is_being_dragged
}
/// Handles events from the pane stack model.
fn handle_pane_stack_event(&mut self, event: &PaneStackEvent<P>, ctx: &mut ViewContext<Self>) {
// Set the focus handle for newly added views
if let PaneStackEvent::ViewAdded(view) = event {
if let Some(focus_handle) = &self.focus_handle {
view.update(ctx, |child, ctx| {
child.set_focus_handle(focus_handle.clone(), ctx);
});
}
}
let new_child = self.child(ctx);
// Refresh overflow menu items from the new active view.
let items = new_child.read(ctx, |view, ctx| view.pane_header_overflow_menu_items(ctx));
self.header.update(ctx, |header, ctx| {
header.set_overflow_menu_items(items, ctx);
});
// Refresh toolbelt buttons from the new active view.
let buttons = new_child.read(ctx, |view, ctx| view.pane_header_toolbelt_buttons(ctx));
self.header.update(ctx, |header, ctx| {
header.set_toolbelt_buttons(buttons, ctx);
});
// Focus the new active child.
new_child.update(ctx, |child, ctx| child.focus_contents(ctx));
// TODO(ben): Refresh the pane title.
ctx.notify();
}
fn handle_pane_configuration_event(
&mut self,
event: &PaneConfigurationEvent,
ctx: &mut ViewContext<Self>,
) {
match event {
PaneConfigurationEvent::ShowAccentBorderUpdated
| PaneConfigurationEvent::DimEvenIfFocusedUpdated => ctx.notify(),
PaneConfigurationEvent::RefreshPaneHeaderOverflowMenuItems => {
let child = self.child(ctx);
let items = child.read(ctx, |view, ctx| view.pane_header_overflow_menu_items(ctx));
self.header.update(ctx, |header, ctx| {
header.set_overflow_menu_items(items, ctx);
});
let buttons = child.read(ctx, |view, ctx| view.pane_header_toolbelt_buttons(ctx));
self.header.update(ctx, |header, ctx| {
header.set_toolbelt_buttons(buttons, ctx);
});
ctx.notify();
}
PaneConfigurationEvent::ShareableObjectChanged(object) => {
self.header.update(ctx, |header, ctx| {
header.set_shareable_object(object.clone(), ctx);
});
}
PaneConfigurationEvent::ToggleSharingDialog(source) => {
self.header.update(ctx, |header, ctx| {
header.share_pane_contents(*source, ctx);
});
}
_ => {}
}
}
fn handle_header_event(
&mut self,
event: &header::Event<P::PaneHeaderOverflowMenuAction, P::CustomAction>,
ctx: &mut ViewContext<Self>,
) {
match event {
header::Event::PaneHeaderClicked => ctx.emit(PaneViewEvent::PaneHeaderClicked),
header::Event::PaneHeaderOverflowMenuToggled(is_open) => {
self.child(ctx).update(ctx, |child, ctx| {
child.on_pane_header_overflow_menu_toggled(*is_open, ctx);
});
}
header::Event::SelectedOverflowMenuAction(action) => {
self.child(ctx).update(ctx, |child, ctx| {
child.handle_pane_header_overflow_menu_action(action, ctx);
});
}
header::Event::CustomAction(action) => self.child(ctx).update(ctx, |child, ctx| {
child.handle_custom_action(action, ctx);
}),
header::Event::Close => {
// Close all views in the stack so they can clean up.
let views: Vec<_> = self.pane_stack.as_ref(ctx).views().cloned().collect();
for view in views {
view.update(ctx, |child, ctx| {
child.close(ctx);
});
}
}
header::Event::MovePaneWithinPaneGroup {
target_id,
direction,
} => {
self.is_being_dragged = true;
ctx.emit(PaneViewEvent::MovePaneWithinPaneGroup {
target_id: *target_id,
direction: *direction,
});
ctx.notify();
}
header::Event::PaneDroppedWithinPaneGroup => {
ctx.emit(PaneViewEvent::PaneDragEnded);
self.is_being_dragged = false;
ctx.notify();
}
header::Event::DroppedOnTabBar { origin } => {
// If we're handling a drop event for a workspace pane, we want to get rid of the neutral background that obscures it.
if matches!(origin, ActionOrigin::Pane) {
self.is_being_dragged = false;
}
ctx.emit(PaneViewEvent::DroppedOnTabBar { origin: *origin });
ctx.notify();
}
header::Event::DraggedOverTabBar {
origin,
tab_hover_index,
hidden_pane_preview_direction,
} => {
// Adds a neutral background to the pane if it's being dragged over the workspace tab group.
if matches!(origin, ActionOrigin::Pane) {
self.is_being_dragged = true;
}
ctx.emit(PaneViewEvent::DraggedOntoTabBar {
origin: *origin,
tab_hover_index: *tab_hover_index,
hidden_pane_preview_direction: *hidden_pane_preview_direction,
});
ctx.notify();
}
header::Event::PaneDraggedOutsideTabBarOrPaneGroup => {
self.is_being_dragged = true;
ctx.emit(PaneViewEvent::PaneDraggedOutsideTabBarOrPaneGroup);
ctx.notify();
}
header::Event::PaneDroppedOutsideofTabBarOrPaneGroup => {
ctx.emit(PaneViewEvent::PaneDragEnded);
self.is_being_dragged = false;
ctx.notify();
}
header::Event::OverlayClosed => {
self.child(ctx)
.update(ctx, |child, ctx| child.focus_contents(ctx));
}
}
}
pub fn pane_id(&self) -> PaneId {
self.pane_id
}
}
#[derive(PartialEq, Copy, Clone, Debug)]
pub struct PaneDropTargetData {
id: PaneId,
}
impl<P: BackingView> View for PaneView<P> {
fn ui_name() -> &'static str {
"PaneView"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let pane_configuration = self.pane_configuration.as_ref(app);
// Check if pane is visible before deciding on flex sizing
if !self.header.as_ref(app).is_visible_in_pane_group() {
// When header is not visible (e.g. during drag operation), use Min sizing to avoid infinite constraint panic.
let column = Flex::column()
.with_main_axis_size(MainAxisSize::Min)
.with_child(ChildView::new(&self.header).finish());
return column.finish();
}
// Normal case: pane is visible (i.e. not being dragged), use Max sizing to fill available space
let mut column = Flex::column().with_main_axis_size(MainAxisSize::Max);
let split_pane_state = self
.focus_handle
.as_ref()
.map(|fh| fh.split_pane_state(app))
.unwrap_or(SplitPaneState::NotInSplitPane);
let active_child = self.child(app);
// If being dragged, we must render the pane header, since that's what receives drag events.
// Otherwise, if we stop rendering the header partway through a drag, the pane will be stuck
// in its dragged state.
if active_child.as_ref(app).should_render_header(app) || self.is_being_dragged {
column.add_child(ChildView::new(&self.header).finish());
}
// Add the underlying pane view.
column.add_child(Shrinkable::new(1., ChildView::new(&active_child).finish()).finish());
let mut container = Container::new(column.finish());
if pane_configuration.show_accent_border {
let border = Border::all(2.).with_border_fill(appearance.theme().accent());
container = container.with_border(border);
}
// Dim inactive panes.
let should_dim_inactive_panes = *PaneSettings::as_ref(app).should_dim_inactive_panes;
let dim_even_if_focused = pane_configuration.dim_even_if_focused();
if should_dim_inactive_panes {
if dim_even_if_focused {
// Focus is in a side panel: dim this pane regardless of split state or focus.
container =
container.with_foreground_overlay(appearance.theme().inactive_pane_overlay());
} else if split_pane_state.is_in_split_pane() && !split_pane_state.is_focused() {
// Normal behavior: in a split, dim only unfocused panes.
container =
container.with_foreground_overlay(appearance.theme().inactive_pane_overlay());
}
}
if self.is_being_dragged {
container = container.with_foreground_overlay(appearance.theme().surface_2())
}
SavePosition::new(
DropTarget::new(container.finish(), PaneDropTargetData { id: self.pane_id }).finish(),
&self.pane_id.position_id(),
)
.finish()
}
fn keymap_context(&self, ctx: &AppContext) -> warpui::keymap::Context {
let mut keymap_context = Self::default_keymap_context();
if self.header.as_ref(ctx).is_sharing_dialog_enabled(ctx) {
keymap_context.set.insert(HAS_SHARED_OBJECT_CONTEXT_KEY);
}
keymap_context
}
}
impl<P: BackingView> TypedActionView for PaneView<P> {
type Action = PaneAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
PaneAction::ShareContents => self.header.update(ctx, |header, ctx| {
header.share_pane_contents(SharingDialogSource::CommandPalette, ctx);
}),
}
}
}
impl DropTargetData for PaneDropTargetData {
fn as_any(&self) -> &dyn std::any::Any {
self
}
}
+104
View File
@@ -0,0 +1,104 @@
use std::path::PathBuf;
use warpui::{AppContext, ModelHandle, View, ViewContext, ViewHandle};
use crate::{
app_state::LeafContents,
pane_group::{
pane::{welcome_view::WelcomeView, ShareableLink, ShareableLinkError},
BackingView, PaneConfiguration, PaneContent, PaneGroup, PaneView,
},
};
use super::PaneId;
pub struct WelcomePane {
view: ViewHandle<PaneView<WelcomeView>>,
pane_configuration: ModelHandle<PaneConfiguration>,
}
impl WelcomePane {
pub fn new<V: View>(startup_directory: Option<PathBuf>, ctx: &mut ViewContext<V>) -> Self {
let welcome_view =
ctx.add_typed_action_view(|ctx| WelcomeView::new(startup_directory, ctx));
let pane_configuration = welcome_view.as_ref(ctx).pane_configuration();
let pane_view = ctx.add_typed_action_view(|ctx| {
let pane_id = PaneId::from_welcome_pane_ctx(ctx);
PaneView::new(pane_id, welcome_view, (), pane_configuration.clone(), ctx)
});
Self {
view: pane_view,
pane_configuration,
}
}
}
impl PaneContent for WelcomePane {
fn id(&self) -> PaneId {
PaneId::from_welcome_pane_view(&self.view)
}
fn attach(
&self,
_group: &PaneGroup,
focus_handle: crate::pane_group::focus_state::PaneFocusHandle,
ctx: &mut ViewContext<PaneGroup>,
) {
self.view
.update(ctx, |view, ctx| view.set_focus_handle(focus_handle, ctx));
let pane_id = self.id();
let child = self.view.as_ref(ctx).child(ctx);
ctx.subscribe_to_view(&child, move |pane_group, _, event, ctx| {
pane_group.handle_pane_event(pane_id, event, ctx);
});
}
fn detach(
&self,
_group: &PaneGroup,
_detach_type: super::DetachType,
ctx: &mut warpui::ViewContext<PaneGroup>,
) {
let child = self.view.as_ref(ctx).child(ctx);
ctx.unsubscribe_to_view(&child);
}
fn snapshot(&self, ctx: &AppContext) -> LeafContents {
LeafContents::Welcome {
startup_directory: self
.view
.as_ref(ctx)
.child(ctx)
.as_ref(ctx)
.startup_directory
.clone(),
}
}
fn has_application_focus(&self, ctx: &mut warpui::ViewContext<PaneGroup>) -> bool {
self.view.is_self_or_child_focused(ctx)
}
fn focus(&self, ctx: &mut warpui::ViewContext<PaneGroup>) {
self.view
.as_ref(ctx)
.child(ctx)
.update(ctx, BackingView::focus_contents)
}
fn shareable_link(
&self,
_ctx: &mut ViewContext<PaneGroup>,
) -> Result<ShareableLink, ShareableLinkError> {
Ok(ShareableLink::Base)
}
fn pane_configuration(&self) -> ModelHandle<PaneConfiguration> {
self.pane_configuration.clone()
}
fn is_pane_being_dragged(&self, ctx: &AppContext) -> bool {
self.view.as_ref(ctx).is_being_dragged()
}
}
+343
View File
@@ -0,0 +1,343 @@
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use itertools::Itertools as _;
use warp_core::context_flag::ContextFlag;
use warp_core::ui::appearance::Appearance;
use warpui::elements::{
Align, ChildView, ConstrainedBox, Container, CrossAxisAlignment, Flex, Icon, ParentElement,
};
use warpui::keymap::EditableBinding;
use warpui::platform::FilePickerConfiguration;
use warpui::ViewHandle;
use warpui::{
AppContext, Element, Entity, ModelHandle, SingletonEntity as _, TypedActionView, View,
ViewContext, WindowId,
};
use crate::code_review::diff_state::GitDeltaPreference;
use crate::code_review::telemetry_event::CodeReviewPaneEntrypoint;
use crate::pane_group::focus_state::PaneFocusHandle;
use crate::pane_group::{
pane::view, BackingView, NewTerminalOptions, PaneConfiguration, PaneEvent, PanesLayout,
};
use crate::projects::ProjectManagementModel;
use crate::search::binding_source::BindingSource;
use crate::search::welcome_palette::{Event as WelcomePaletteEvent, WelcomePalette};
use crate::util::bindings::{keybinding_name_to_display_string, BindingGroup, CustomAction};
use crate::view_components::DismissibleToast;
use crate::workspace::{ToastStack, Workspace};
pub fn init(app: &mut AppContext) {
use warpui::keymap::macros::*;
app.register_editable_bindings([
EditableBinding::new(
"workspace:new_tab",
"Terminal session",
WelcomeViewAction::CreateTerminalSession,
)
.with_context_predicate(id!("WelcomeView"))
.with_group(BindingGroup::Terminal.as_str())
.with_custom_action(CustomAction::NewTab)
.with_enabled(|| ContextFlag::CreateNewSession.is_enabled()),
EditableBinding::new(
"welcome_view:open_project",
"Add repository",
WelcomeViewAction::OpenProject,
)
.with_context_predicate(id!("WelcomeView"))
.with_group(BindingGroup::Folders.as_str())
.with_mac_key_binding("cmd-shift-N")
.with_linux_or_windows_key_binding("alt-n"),
]);
}
#[derive(Debug, Clone, Copy)]
pub enum WelcomeViewAction {
CreateTerminalSession,
OpenProject,
}
pub struct WelcomeView {
/// Configure which directory to open sessions into as per the "working directory for new
/// sessions" setting.
pub startup_directory: Option<PathBuf>,
pane_configuration: ModelHandle<PaneConfiguration>,
focus_handle: Option<PaneFocusHandle>,
palette: ViewHandle<WelcomePalette>,
}
impl WelcomeView {
pub fn new(startup_directory: Option<PathBuf>, ctx: &mut ViewContext<Self>) -> Self {
let pane_configuration = ctx.add_model(|_ctx| PaneConfiguration::new("New tab"));
let window_id = ctx.window_id();
let view_id = ctx.view_id();
let palette = ctx.add_typed_action_view(|ctx| {
let binding_source = BindingSource::View {
window_id,
view_id,
binding_filter_fn: Some(Arc::new(|binding| {
binding.action.as_ref().is_some_and(|action| {
action
.as_any()
.downcast_ref::<WelcomeViewAction>()
.is_some()
}) || binding.name == "workspace:show_settings"
})),
};
let open_project_keybinding =
keybinding_name_to_display_string("welcome_view:open_project", ctx);
let terminal_session_keybinding =
keybinding_name_to_display_string("workspace:new_tab", ctx);
WelcomePalette::new(
startup_directory.clone(),
binding_source,
open_project_keybinding,
terminal_session_keybinding,
ctx,
)
});
ctx.subscribe_to_view(&palette, |me, _, event, ctx| {
me.handle_palette_event(event, ctx);
});
Self {
startup_directory,
pane_configuration,
focus_handle: None,
palette,
}
}
pub fn pane_configuration(&self) -> ModelHandle<PaneConfiguration> {
self.pane_configuration.clone()
}
fn handle_palette_event(&mut self, event: &WelcomePaletteEvent, ctx: &mut ViewContext<Self>) {
match event {
WelcomePaletteEvent::Close => self.close(ctx),
WelcomePaletteEvent::ParentAction { action } => self.handle_action(action, ctx),
WelcomePaletteEvent::NewConversationInProject { path } => {
self.open_project_conversation(path, ctx);
self.close(ctx);
}
_ => {
// TODO
}
}
}
fn create_terminal_session(&mut self, ctx: &mut ViewContext<Self>) {
update_workspace(ctx.window_id(), ctx, |workspace, ctx| {
workspace.add_tab_with_pane_layout(
PanesLayout::SingleTerminal(Box::new(
NewTerminalOptions::default()
.with_initial_directory_opt(self.startup_directory.clone()),
)),
Arc::new(HashMap::new()),
None,
ctx,
);
});
}
fn open_project(&mut self, ctx: &mut ViewContext<Self>) {
let window_id = ctx.window_id();
ctx.open_file_picker(
move |result, ctx| match result {
Ok(paths) => {
if let Some(path) = paths.into_iter().next() {
save_and_open_project(path, window_id, ctx);
ctx.emit(PaneEvent::Close);
}
}
Err(err) => {
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
toast_stack.add_ephemeral_toast(
DismissibleToast::error(format!("{err}")),
window_id,
ctx,
);
});
}
},
FilePickerConfiguration::new().folders_only(),
);
}
fn open_project_conversation(&mut self, path: &String, ctx: &mut ViewContext<Self>) {
let path_buf = PathBuf::from(path);
// todo(jparker): What happens if the user deletes a project folder between when this list was generated and now?
update_workspace(ctx.window_id(), ctx, |workspace, ctx| {
// Create a new terminal tab with the project path as the initial directory
workspace.add_tab_with_pane_layout(
PanesLayout::SingleTerminal(Box::new(
NewTerminalOptions::default().with_initial_directory(&path_buf),
)),
Arc::new(HashMap::new()),
None,
ctx,
);
// Start AI mode in the new terminal
workspace
.active_tab_pane_group()
.update(ctx, |pane_group, ctx| {
pane_group.start_agent_mode_in_new_pane(None, None, ctx);
});
// Open code review pane
workspace.active_tab_pane_group().update(ctx, |tab, ctx| {
if let Some(active_terminal) = tab.active_session_view(ctx) {
active_terminal.update(ctx, |terminal, ctx| {
terminal.toggle_code_review_pane(
GitDeltaPreference::OnlyDirty,
CodeReviewPaneEntrypoint::Other,
None, // cli_agent
false, /* focus_new_pane */
ctx,
);
});
}
});
// Update project accesstime
ProjectManagementModel::handle(ctx).update(ctx, |projects, ctx| {
projects.upsert_project(path_buf, ctx);
});
});
}
}
fn update_workspace<F>(window_id: WindowId, ctx: &mut AppContext, update_fn: F)
where
F: FnOnce(&mut Workspace, &mut ViewContext<Workspace>),
{
if let Some(workspaces) = ctx.views_of_type::<Workspace>(window_id) {
if let Ok(workspace) = workspaces.into_iter().exactly_one() {
workspace.update(ctx, update_fn);
}
}
}
impl Entity for WelcomeView {
type Event = PaneEvent;
}
impl View for WelcomeView {
fn ui_name() -> &'static str {
"WelcomeView"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
Align::new(
Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_children([
Container::new(
ConstrainedBox::new(
Icon::new(
"bundled/svg/warp-logo-neutral.svg",
appearance.theme().foreground(),
)
.finish(),
)
.with_height(50.)
.with_width(50.)
.finish(),
)
.with_margin_bottom(40.)
.finish(),
Container::new(ChildView::new(&self.palette).finish())
.with_padding_bottom(140.)
.finish(),
])
.finish(),
)
.finish()
}
}
impl BackingView for WelcomeView {
type PaneHeaderOverflowMenuAction = ();
type CustomAction = ();
type AssociatedData = ();
fn handle_pane_header_overflow_menu_action(
&mut self,
_action: &Self::PaneHeaderOverflowMenuAction,
_ctx: &mut ViewContext<Self>,
) {
unimplemented!()
}
fn close(&mut self, ctx: &mut ViewContext<Self>) {
ctx.emit(PaneEvent::Close);
}
fn focus_contents(&mut self, ctx: &mut ViewContext<Self>) {
ctx.focus(&self.palette)
}
fn render_header_content(
&self,
_ctx: &view::HeaderRenderContext<'_>,
_app: &AppContext,
) -> view::HeaderContent {
view::HeaderContent::simple("New tab")
}
fn set_focus_handle(&mut self, focus_handle: PaneFocusHandle, _ctx: &mut ViewContext<Self>) {
self.focus_handle = Some(focus_handle);
}
}
impl TypedActionView for WelcomeView {
type Action = WelcomeViewAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
WelcomeViewAction::CreateTerminalSession => {
self.create_terminal_session(ctx);
self.close(ctx);
}
WelcomeViewAction::OpenProject => {
self.open_project(ctx);
}
}
}
}
/// WARNING - Don't use. The [`crate::workspace::WorkspaceAction::OpenRepository`] is the
/// source-of-truth for this now.
fn save_and_open_project(path: String, window_id: WindowId, ctx: &mut AppContext) {
ProjectManagementModel::handle(ctx).update(ctx, |projects, ctx| {
let path_buf = PathBuf::from(&path);
projects.upsert_project(path_buf.clone(), ctx);
update_workspace(window_id, ctx, move |workspace, ctx| {
workspace.add_tab_with_pane_layout(
PanesLayout::SingleTerminal(Box::new(
NewTerminalOptions::default()
.with_initial_directory(path)
.with_homepage_hidden(),
)),
Arc::new(HashMap::new()),
None,
ctx,
);
workspace.active_tab_pane_group().update(ctx, |tab, ctx| {
if let Some(active_terminal) = tab.active_session_view(ctx) {
active_terminal.update(ctx, |terminal, _ctx| {
terminal.maybe_set_pending_repo_init_path(path_buf);
});
}
});
});
});
}
+229
View File
@@ -0,0 +1,229 @@
use super::{
DetachType, PaneConfiguration, PaneContent, PaneGroup, PaneId, PaneView, ShareableLink,
ShareableLinkError,
};
use crate::{
app_state::{LeafContents, WorkflowPaneSnapshot},
drive::{items::WarpDriveItemId, OpenWarpDriveObjectSettings},
server::ids::SyncId,
workflows::{
manager::{WorkflowManager, WorkflowOpenSource},
workflow_view::{WorkflowView, WorkflowViewEvent},
WorkflowSelectionSource, WorkflowSource, WorkflowType, WorkflowViewMode,
},
workspaces::user_workspaces::UserWorkspaces,
};
use anyhow::Context;
use std::{collections::HashMap, sync::Arc};
use url::Url;
use warpui::{AppContext, ModelHandle, SingletonEntity, ViewContext, ViewHandle};
pub struct WorkflowPane {
view: ViewHandle<PaneView<WorkflowView>>,
pane_configuration: ModelHandle<PaneConfiguration>,
}
impl WorkflowPane {
pub fn new(view: ViewHandle<WorkflowView>, ctx: &mut AppContext) -> Self {
let pane_configuration = view.as_ref(ctx).pane_configuration().to_owned();
let view = ctx.add_typed_action_view(view.window_id(ctx), |ctx| {
let pane_id = PaneId::from_workflow_pane_ctx(ctx);
PaneView::new(pane_id, view, (), pane_configuration.clone(), ctx)
});
Self {
view,
pane_configuration,
}
}
pub fn restore(
workflow_id: Option<SyncId>,
settings: OpenWarpDriveObjectSettings,
ctx: &mut ViewContext<PaneGroup>,
) -> anyhow::Result<Self> {
let window_id = ctx.window_id();
let source = match workflow_id {
Some(id) => WorkflowOpenSource::Existing(id),
None => WorkflowOpenSource::New {
title: None,
content: None,
owner: UserWorkspaces::as_ref(ctx)
.personal_drive(ctx)
.context("personal drive unavailable")?,
initial_folder_id: None,
is_for_agent_mode: false,
},
};
// default to view mode on restore -- feels safer
Ok(WorkflowManager::handle(ctx).update(ctx, |manager, ctx| {
manager.create_pane(
&source,
&settings,
WorkflowViewMode::supported_view_mode(workflow_id, ctx),
window_id,
ctx,
)
}))
}
pub fn get_view(&self, ctx: &AppContext) -> ViewHandle<WorkflowView> {
self.view.as_ref(ctx).child(ctx)
}
}
impl PaneContent for WorkflowPane {
fn id(&self) -> PaneId {
PaneId::from_workflow_pane_view(&self.view)
}
/// Callback for when this leaf pane is added to a pane group.
///
/// This is called after the pane is added to the group's set of leaf panes, but before the
/// new pane is focused.
fn attach(
&self,
_group: &PaneGroup,
focus_handle: crate::pane_group::focus_state::PaneFocusHandle,
ctx: &mut ViewContext<PaneGroup>,
) {
self.view
.update(ctx, |view, ctx| view.set_focus_handle(focus_handle, ctx));
let pane_id = self.id();
ctx.subscribe_to_view(&self.view, move |group, _, event, ctx| {
group.handle_pane_view_event(pane_id, event, ctx);
});
ctx.subscribe_to_view(&self.get_view(ctx), move |group, _, event, ctx| {
handle_workflow_event(group, pane_id, event, ctx);
});
let pane_group_id = ctx.view_id();
let window_id = ctx.window_id();
WorkflowManager::handle(ctx).update(ctx, |manager, ctx| {
manager.register_pane(self, pane_group_id, window_id, ctx);
});
}
/// Callback for when this leaf pane is removed from a pane group.
///
/// This is called when:
/// - The pane is about to be closed
/// - The pane group is closed, but may be restored
/// - The pane is being moved to another tab, or upgraded to its own tab
fn detach(
&self,
_group: &PaneGroup,
_detach_type: DetachType,
ctx: &mut ViewContext<PaneGroup>,
) {
// Always unsubscribe from views
ctx.unsubscribe_to_view(&self.view);
ctx.unsubscribe_to_view(&self.get_view(ctx));
// Always deregister from WorkflowManager - it will be re-registered on attach if restored
WorkflowManager::handle(ctx).update(ctx, |manager, ctx| manager.deregister_pane(self, ctx));
}
/// Snapshot this pane for session restoration.
fn snapshot(&self, app: &AppContext) -> LeafContents {
let workflow_id = self.get_view(app).as_ref(app).workflow_id();
LeafContents::Workflow(WorkflowPaneSnapshot::CloudWorkflow {
workflow_id: Some(workflow_id),
settings: OpenWarpDriveObjectSettings::default(),
})
}
fn has_application_focus(&self, ctx: &mut ViewContext<PaneGroup>) -> bool {
self.view.is_self_or_child_focused(ctx)
}
/// Focus this pane's contents.
fn focus(&self, ctx: &mut ViewContext<PaneGroup>) {
self.get_view(ctx).update(ctx, |view, ctx| view.focus(ctx));
}
fn shareable_link(
&self,
ctx: &mut ViewContext<PaneGroup>,
) -> Result<ShareableLink, ShareableLinkError> {
self.get_view(ctx).read(ctx, |view, ctx| {
if let Some(link) = view.workflow_link(ctx) {
if let Ok(parsed_url) = Url::parse(link.as_str()) {
Ok(ShareableLink::Pane { url: parsed_url })
} else {
Err(ShareableLinkError::Unexpected(String::from(
"Failed to parse workflow url",
)))
}
} else {
Err(ShareableLinkError::Unexpected(String::from(
"Could not retrieve workflow url from view",
)))
}
})
}
/// Pane-agnostic state that all panes have.
fn pane_configuration(&self) -> ModelHandle<PaneConfiguration> {
self.pane_configuration.clone()
}
fn is_pane_being_dragged(&self, ctx: &AppContext) -> bool {
self.view.as_ref(ctx).is_being_dragged()
}
}
fn handle_workflow_event(
group: &mut PaneGroup,
pane_id: PaneId,
event: &WorkflowViewEvent,
ctx: &mut ViewContext<PaneGroup>,
) {
match event {
WorkflowViewEvent::Pane(pane_event) => group.handle_pane_event(pane_id, pane_event, ctx),
WorkflowViewEvent::ViewInWarpDrive(id) => view_in_warp_drive(*id, ctx),
WorkflowViewEvent::RunWorkflow {
workflow,
source,
argument_override,
} => run_workflow(workflow.clone(), *source, argument_override.clone(), ctx),
WorkflowViewEvent::UpdatedWorkflow(_id) => {
log::warn!("Updates not yet handled in pane")
}
WorkflowViewEvent::OpenDriveObjectShareDialog {
cloud_object_type_and_id,
invitee_email,
source,
} => {
ctx.emit(crate::pane_group::Event::OpenDriveObjectShareDialog {
cloud_object_type_and_id: *cloud_object_type_and_id,
invitee_email: invitee_email.clone(),
source: *source,
});
}
WorkflowViewEvent::CreatedWorkflow(_) => {
// No op in a pane.
}
}
}
fn run_workflow(
workflow: Arc<WorkflowType>,
workflow_source: WorkflowSource,
argument_override: Option<HashMap<String, String>>,
ctx: &mut ViewContext<PaneGroup>,
) {
ctx.emit(crate::pane_group::Event::RunWorkflow {
workflow,
workflow_source,
argument_override,
workflow_selection_source: WorkflowSelectionSource::WorkflowView,
});
}
fn view_in_warp_drive(id: WarpDriveItemId, ctx: &mut ViewContext<PaneGroup>) {
ctx.emit(crate::pane_group::Event::ViewInWarpDrive(id))
}
File diff suppressed because it is too large Load Diff
+701
View File
@@ -0,0 +1,701 @@
use super::*;
#[test]
fn test_split_pane_layout() {
let panes = [
PaneId::dummy_pane_id(),
PaneId::dummy_pane_id(),
PaneId::dummy_pane_id(),
];
let mut root_pane = PaneData::new(panes[0]);
// Add a pane to the right.
root_pane.split(panes[0], panes[1], Direction::Right);
assert_eq!(root_pane.pane_ids(), vec![panes[0], panes[1]]);
// Insert a vertical (below) pane after the first pane.
root_pane.split(panes[0], panes[2], Direction::Down);
assert_eq!(root_pane.pane_ids(), vec![panes[0], panes[2], panes[1]]);
// Remove the last pane.
root_pane.remove(panes[1]);
assert_eq!(root_pane.pane_ids(), vec![panes[0], panes[2]]);
let panes = [PaneId::dummy_pane_id(); 3];
let mut root_pane = PaneData::new(panes[0]);
// Add a pane to the left.
root_pane.split(panes[0], panes[1], Direction::Left);
assert_eq!(root_pane.pane_ids(), vec![panes[1], panes[0]]);
// Add a pane above the first pane.
root_pane.split(panes[0], panes[2], Direction::Up);
assert_eq!(root_pane.pane_ids(), vec![panes[2], panes[0], panes[1]]);
}
#[test]
fn test_left_pane_split() {
let panes = [
PaneId::dummy_pane_id(),
PaneId::dummy_pane_id(),
PaneId::dummy_pane_id(),
PaneId::dummy_pane_id(),
];
let mut root_pane = PaneData::new(panes[0]);
root_pane.split(panes[0], panes[1], Direction::Left);
assert_eq!(root_pane.pane_ids(), vec![panes[1], panes[0]]);
root_pane.split(panes[0], panes[2], Direction::Left);
assert_eq!(root_pane.pane_ids(), vec![panes[1], panes[2], panes[0]]);
root_pane.split(panes[0], panes[3], Direction::Left);
assert_eq!(
root_pane.pane_ids(),
vec![panes[1], panes[2], panes[3], panes[0]]
);
}
#[test]
fn test_root_split_leaf() {
let panes = [PaneId::dummy_pane_id(), PaneId::dummy_pane_id()];
let mut tree = PaneData::new(panes[0]);
tree.split_root(panes[1], Direction::Down);
assert_eq!(tree.pane_ids(), vec![panes[0], panes[1]]);
assert_eq!(
tree.root.as_branch().expect("Should be a branch").axis(),
SplitDirection::Vertical
);
}
#[test]
fn test_root_split_same_axis() {
let panes = [
PaneId::dummy_pane_id(),
PaneId::dummy_pane_id(),
PaneId::dummy_pane_id(),
PaneId::dummy_pane_id(),
];
// Start with a horizontal split.
let mut tree = PaneData::new(panes[0]);
tree.split(panes[0], panes[1], Direction::Right);
// Add a pane at the start of the split.
tree.split_root(panes[2], Direction::Left);
// Add a pane at the end of the split.
tree.split_root(panes[3], Direction::Right);
let root = tree.root.as_branch().expect("Should be a branch");
assert_eq!(root.axis(), SplitDirection::Horizontal);
assert_eq!(
root.direct_children(),
vec![panes[2], panes[0], panes[1], panes[3]]
);
}
#[test]
fn test_root_split_different_axis() {
let panes = [
PaneId::dummy_pane_id(),
PaneId::dummy_pane_id(),
PaneId::dummy_pane_id(),
PaneId::dummy_pane_id(),
];
// Start with a horizontal split:
// -------------
// | 0 | 1 |
// -------------
let mut tree = PaneData::new(panes[0]);
tree.split(panes[0], panes[1], Direction::Right);
// Add a pane above, converting the root to a vertical split:
// -------------
// | 2 |
// -------------
// | 0 | 1 |
// -------------
tree.split_root(panes[2], Direction::Up);
let root = tree.root.as_branch().expect("Should be a branch");
assert_eq!(root.axis(), SplitDirection::Vertical);
assert_eq!(root.node(0).as_leaf(), Some(panes[2]));
assert_eq!(
root.node(1)
.as_branch()
.expect("Should be a branch")
.direct_children(),
vec![panes[0], panes[1]]
);
// Add a pane to the right, converting the root to a horizontal split.
// -------------------
// | 2 | |
// ------------+ 3 |
// | 0 | 1 | |
// -------------------
tree.split_root(panes[3], Direction::Right);
let root = tree.root.as_branch().expect("Should be a branch");
assert_eq!(
root.node(0)
.as_branch()
.expect("Should be a branch")
.get_children(),
vec![panes[2], panes[0], panes[1]]
);
assert_eq!(root.node(1).as_leaf(), Some(panes[3]));
}
#[test]
fn test_move_pane_basic() {
let panes = [PaneId::dummy_pane_id(), PaneId::dummy_pane_id()];
// Start with a horizontal split:
// -------------
// | 0 | 1 |
// -------------
let mut tree = PaneData::new(panes[0]);
tree.split(panes[0], panes[1], Direction::Right);
// Move pane 0 to the right of pane 1, which should result in
// -------------
// | 1 | 0 |
// -------------
tree.move_pane(panes[0], panes[1], Direction::Right);
let root = tree.root.as_branch().expect("Should be a branch");
assert_eq!(root.axis(), SplitDirection::Horizontal);
assert_eq!(root.direct_children(), vec![panes[1], panes[0]]);
// Move pane 0 on top of pane 1, which should result in
// --------------
// | 0 |
// -------------
// | 1 |
// -------------
tree.move_pane(panes[0], panes[1], Direction::Up);
let root = tree.root.as_branch().expect("Should be a branch");
assert_eq!(root.axis(), SplitDirection::Vertical);
assert_eq!(root.direct_children(), vec![panes[0], panes[1]]);
}
#[test]
fn test_move_pane_multiple_splits() {
let panes = [
PaneId::dummy_pane_id(),
PaneId::dummy_pane_id(),
PaneId::dummy_pane_id(),
PaneId::dummy_pane_id(),
];
// Start with a horizontal split:
// -------------
// | 0 | 1 |
// -------------
let mut tree = PaneData::new(panes[0]);
tree.split(panes[0], panes[1], Direction::Right);
// Add a pane above, converting the root to a vertical split:
// -------------
// | 2 |
// -------------
// | 0 | 1 |
// -------------
tree.split_root(panes[2], Direction::Up);
let root = tree.root.as_branch().expect("Should be a branch");
assert_eq!(root.axis(), SplitDirection::Vertical);
assert_eq!(root.node(0).as_leaf(), Some(panes[2]));
assert_eq!(
root.node(1)
.as_branch()
.expect("Should be a branch")
.direct_children(),
vec![panes[0], panes[1]]
);
// Add a pane to the right, converting the root to a horizontal split.
// -------------------
// | 2 | |
// ------------+ 3 |
// | 0 | 1 | |
// -------------------
tree.split_root(panes[3], Direction::Right);
let root = tree.root.as_branch().expect("Should be a branch");
assert_eq!(
root.node(0)
.as_branch()
.expect("Should be a branch")
.get_children(),
vec![panes[2], panes[0], panes[1]]
);
assert_eq!(root.node(1).as_leaf(), Some(panes[3]));
// Move Pane 2 to the left of pane 3, which would result in
// -------------------------
// | | | | |
// | 0 | 1 | 2 | 3 |
// | | | | |
// -------------------------
tree.move_pane(panes[2], panes[3], Direction::Left);
let root = tree.root.as_branch().expect("Should be a branch");
assert_eq!(root.axis(), SplitDirection::Horizontal);
assert_eq!(
root.node(0).as_branch().expect("should be branch").axis(),
SplitDirection::Horizontal
);
assert_eq!(
root.node(0)
.as_branch()
.expect("Should be a branch")
.get_children(),
vec![panes[0], panes[1]]
);
assert_eq!(root.node(1).as_leaf(), Some(panes[2]));
assert_eq!(root.node(2).as_leaf(), Some(panes[3]));
}
#[test]
fn test_move_pane_no_short_circuit() {
let panes = [
PaneId::dummy_pane_id(),
PaneId::dummy_pane_id(),
PaneId::dummy_pane_id(),
];
// Setup
// -------------
// | 0 |
// -------------
// | 1 | 2 |
// -------------
let mut tree = PaneData::new(panes[0]);
tree.split(panes[0], panes[1], Direction::Down);
tree.split(panes[1], panes[2], Direction::Right);
// Move Pane 1 to the bottom of pane 0. This should result in a single vertical split
// with 3 panes, but currently is short circuiting because 1 is already below 0.
tree.move_pane(panes[1], panes[0], Direction::Down);
let root = tree.root.as_branch().expect("Should be a branch");
assert_eq!(root.axis(), SplitDirection::Vertical);
assert_eq!(root.direct_children(), panes.to_vec());
}
#[test]
fn test_move_pane_no_short_circuit_2() {
let panes = [
PaneId::dummy_pane_id(),
PaneId::dummy_pane_id(),
PaneId::dummy_pane_id(),
];
// Setup
// -------------
// | 0 |
// -------------
// | 1 |
// -------------
// | 2 |
// -------------
let mut tree = PaneData::new(panes[0]);
tree.split(panes[0], panes[1], Direction::Down);
tree.split(panes[1], panes[2], Direction::Down);
// Move Pane 1 to the left of pane 2. This should result in a horizontal split
// with 2 panes, below pane 0.
tree.move_pane(panes[1], panes[2], Direction::Left);
let root = tree.root.as_branch().expect("Should be a branch");
assert_eq!(root.axis(), SplitDirection::Vertical);
assert_eq!(root.node(0).as_leaf().expect("Should be a leaf"), panes[0]);
assert_eq!(
root.node(1)
.as_branch()
.expect("Should be a branch")
.direct_children(),
vec![panes[1], panes[2]]
);
}
#[test]
fn test_sibling_by_direction() {
let panes = [
PaneId::dummy_pane_id(),
PaneId::dummy_pane_id(),
PaneId::dummy_pane_id(),
PaneId::dummy_pane_id(),
PaneId::dummy_pane_id(),
];
// Setup
// -----------------------
// | 0 |
// -----------------------
// | | | 3 |
// | 1 | 2 |---------|
// | | | 4 |
// -----------------------
let mut tree = PaneData::new(panes[0]);
tree.split(panes[0], panes[1], Direction::Down);
tree.split(panes[1], panes[2], Direction::Right);
tree.split(panes[2], panes[3], Direction::Right);
tree.split(panes[3], panes[4], Direction::Down);
assert_eq!(
tree.sibling_by_direction(panes[1], Direction::Right),
Some(panes[2])
);
assert_eq!(
tree.sibling_by_direction(panes[2], Direction::Left),
Some(panes[1])
);
assert_eq!(tree.sibling_by_direction(panes[0], Direction::Right), None);
assert_eq!(tree.sibling_by_direction(panes[0], Direction::Left), None);
assert_eq!(tree.sibling_by_direction(panes[2], Direction::Right), None);
assert_eq!(tree.sibling_by_direction(panes[1], Direction::Left), None);
assert_eq!(tree.sibling_by_direction(panes[1], Direction::Up), None);
assert_eq!(tree.sibling_by_direction(panes[1], Direction::Down), None);
assert_eq!(tree.sibling_by_direction(panes[0], Direction::Up), None);
assert_eq!(tree.sibling_by_direction(panes[0], Direction::Down), None);
assert_eq!(tree.sibling_by_direction(panes[3], Direction::Up), None);
assert_eq!(
tree.sibling_by_direction(panes[3], Direction::Down),
Some(panes[4])
);
assert_eq!(
tree.sibling_by_direction(panes[4], Direction::Up),
Some(panes[3])
);
assert_eq!(tree.sibling_by_direction(panes[4], Direction::Down), None);
}
#[test]
fn test_pane_by_direction_simple() {
let panes = [
PaneId::dummy_pane_id(),
PaneId::dummy_pane_id(),
PaneId::dummy_pane_id(),
];
let mut tree = PaneData::new(panes[0]);
tree.split(panes[0], panes[1], Direction::Right);
assert_eq!(
tree.root.panes_by_direction(panes[0], Direction::Right),
FindPaneByDirectionResult::Found(HashSet::from([panes[1]]))
);
assert_eq!(
tree.root.panes_by_direction(panes[0], Direction::Left),
FindPaneByDirectionResult::Located
);
assert_eq!(
tree.root.panes_by_direction(panes[1], Direction::Right),
FindPaneByDirectionResult::Located
);
assert_eq!(
tree.root.panes_by_direction(panes[1], Direction::Left),
FindPaneByDirectionResult::Found(HashSet::from([panes[0]]))
);
assert_eq!(
tree.root.panes_by_direction(panes[0], Direction::Up),
FindPaneByDirectionResult::Located
);
assert_eq!(
tree.root.panes_by_direction(panes[0], Direction::Down),
FindPaneByDirectionResult::Located
);
assert_eq!(
tree.root.panes_by_direction(panes[1], Direction::Up),
FindPaneByDirectionResult::Located
);
assert_eq!(
tree.root.panes_by_direction(panes[1], Direction::Down),
FindPaneByDirectionResult::Located
);
assert_eq!(
tree.root.panes_by_direction(panes[2], Direction::Right),
FindPaneByDirectionResult::NotFound
);
assert_eq!(
tree.root.panes_by_direction(panes[2], Direction::Left),
FindPaneByDirectionResult::NotFound
);
assert_eq!(
tree.root.panes_by_direction(panes[2], Direction::Up),
FindPaneByDirectionResult::NotFound
);
assert_eq!(
tree.root.panes_by_direction(panes[2], Direction::Down),
FindPaneByDirectionResult::NotFound
);
}
#[test]
fn test_pane_by_direction_multi_split() {
let panes = [
PaneId::dummy_pane_id(),
PaneId::dummy_pane_id(),
PaneId::dummy_pane_id(),
PaneId::dummy_pane_id(),
];
let mut tree = PaneData::new(panes[0]);
tree.split(panes[0], panes[1], Direction::Right);
tree.split(panes[0], panes[2], Direction::Down);
tree.split(panes[1], panes[3], Direction::Down);
assert_eq!(
tree.root.panes_by_direction(panes[0], Direction::Right),
FindPaneByDirectionResult::Found(HashSet::from([panes[1], panes[3]]))
);
assert_eq!(
tree.root.panes_by_direction(panes[0], Direction::Left),
FindPaneByDirectionResult::Located
);
assert_eq!(
tree.root.panes_by_direction(panes[0], Direction::Up),
FindPaneByDirectionResult::Located
);
assert_eq!(
tree.root.panes_by_direction(panes[0], Direction::Down),
FindPaneByDirectionResult::Found(HashSet::from([panes[2]]))
);
assert_eq!(
tree.root.panes_by_direction(panes[1], Direction::Right),
FindPaneByDirectionResult::Located
);
assert_eq!(
tree.root.panes_by_direction(panes[1], Direction::Left),
FindPaneByDirectionResult::Found(HashSet::from([panes[0], panes[2]]))
);
assert_eq!(
tree.root.panes_by_direction(panes[1], Direction::Up),
FindPaneByDirectionResult::Located
);
assert_eq!(
tree.root.panes_by_direction(panes[1], Direction::Down),
FindPaneByDirectionResult::Found(HashSet::from([panes[3]]))
);
assert_eq!(
tree.root.panes_by_direction(panes[2], Direction::Right),
FindPaneByDirectionResult::Found(HashSet::from([panes[1], panes[3]]))
);
assert_eq!(
tree.root.panes_by_direction(panes[2], Direction::Left),
FindPaneByDirectionResult::Located
);
assert_eq!(
tree.root.panes_by_direction(panes[2], Direction::Up),
FindPaneByDirectionResult::Found(HashSet::from([panes[0]]))
);
assert_eq!(
tree.root.panes_by_direction(panes[2], Direction::Down),
FindPaneByDirectionResult::Located
);
assert_eq!(
tree.root.panes_by_direction(panes[3], Direction::Right),
FindPaneByDirectionResult::Located
);
assert_eq!(
tree.root.panes_by_direction(panes[3], Direction::Left),
FindPaneByDirectionResult::Found(HashSet::from([panes[0], panes[2]]))
);
assert_eq!(
tree.root.panes_by_direction(panes[3], Direction::Up),
FindPaneByDirectionResult::Found(HashSet::from([panes[1]]))
);
assert_eq!(
tree.root.panes_by_direction(panes[3], Direction::Down),
FindPaneByDirectionResult::Located
);
}
#[test]
fn test_pane_by_direction_multi_level_split() {
let panes = [
PaneId::dummy_pane_id(),
PaneId::dummy_pane_id(),
PaneId::dummy_pane_id(),
PaneId::dummy_pane_id(),
PaneId::dummy_pane_id(),
PaneId::dummy_pane_id(),
PaneId::dummy_pane_id(),
];
let mut tree = PaneData::new(panes[0]);
tree.split(panes[0], panes[3], Direction::Right);
tree.split(panes[0], panes[2], Direction::Down);
tree.split(panes[0], panes[1], Direction::Right);
tree.split(panes[3], panes[6], Direction::Down);
tree.split(panes[3], panes[5], Direction::Right);
tree.split(panes[3], panes[4], Direction::Down);
assert_eq!(
tree.root.panes_by_direction(panes[0], Direction::Right),
FindPaneByDirectionResult::Found(HashSet::from([panes[1]]))
);
assert_eq!(
tree.root.panes_by_direction(panes[1], Direction::Right),
FindPaneByDirectionResult::Found(HashSet::from([panes[3], panes[4], panes[6]]))
);
assert_eq!(
tree.root.panes_by_direction(panes[5], Direction::Left),
FindPaneByDirectionResult::Found(HashSet::from([panes[3], panes[4]]))
);
assert_eq!(
tree.root.panes_by_direction(panes[6], Direction::Up),
FindPaneByDirectionResult::Found(HashSet::from([panes[4], panes[5]]))
);
assert_eq!(
tree.root.panes_by_direction(panes[4], Direction::Down),
FindPaneByDirectionResult::Found(HashSet::from([panes[6]]))
);
}
#[test]
fn test_are_rects_overlapping_on_axis() {
let rect1 = RectF::from_points(Vector2F::new(0.0, 0.0), Vector2F::new(10.0, 10.0));
let rect2 = RectF::from_points(Vector2F::new(10.0, -5.0), Vector2F::new(20.0, 5.0));
let rect3 = RectF::from_points(Vector2F::new(10.0, 10.0), Vector2F::new(20.0, 20.0));
let rect4 = RectF::from_points(Vector2F::new(-5.0, 10.0), Vector2F::new(5.0, 20.0));
let rect5 = RectF::from_points(Vector2F::new(30.0, 30.0), Vector2F::new(40.0, 40.0));
let rect6 = RectF::from_points(Vector2F::new(-20.0, -20.0), Vector2F::new(-10.0, -10.0));
assert!(PaneData::are_rects_overlapping(
&rect1,
&rect2,
SplitDirection::Horizontal
));
assert!(!PaneData::are_rects_overlapping(
&rect1,
&rect5,
SplitDirection::Horizontal
));
assert!(!PaneData::are_rects_overlapping(
&rect1,
&rect3,
SplitDirection::Horizontal
));
assert!(!PaneData::are_rects_overlapping(
&rect1,
&rect6,
SplitDirection::Horizontal
));
assert!(PaneData::are_rects_overlapping(
&rect1,
&rect4,
SplitDirection::Vertical
));
assert!(!PaneData::are_rects_overlapping(
&rect1,
&rect5,
SplitDirection::Vertical
),);
assert!(!PaneData::are_rects_overlapping(
&rect1,
&rect3,
SplitDirection::Vertical
));
}
#[test]
fn test_hide_and_show_child_agent_pane() {
let panes = [PaneId::dummy_pane_id(), PaneId::dummy_pane_id()];
let mut tree = PaneData::new(panes[0]);
tree.split(panes[0], panes[1], Direction::Right);
// Both panes visible initially.
assert_eq!(tree.visible_pane_ids(), vec![panes[0], panes[1]]);
assert!(!tree.is_pane_hidden(&panes[1]));
// Hide the child agent pane.
tree.hide_pane_for_child_agent(panes[1]);
assert!(tree.is_pane_hidden(&panes[1]));
assert_eq!(tree.visible_pane_ids(), vec![panes[0]]);
// pane_ids still includes hidden panes (they remain in the tree).
assert_eq!(tree.pane_ids(), vec![panes[0], panes[1]]);
// Show the child agent pane.
tree.show_pane_for_child_agent(panes[1]);
assert!(!tree.is_pane_hidden(&panes[1]));
assert_eq!(tree.visible_pane_ids(), vec![panes[0], panes[1]]);
}
#[test]
fn test_hide_child_agent_pane_is_idempotent() {
let panes = [PaneId::dummy_pane_id(), PaneId::dummy_pane_id()];
let mut tree = PaneData::new(panes[0]);
tree.split(panes[0], panes[1], Direction::Right);
// Hiding the same pane twice should not create duplicate entries.
tree.hide_pane_for_child_agent(panes[1]);
tree.hide_pane_for_child_agent(panes[1]);
assert_eq!(tree.num_hidden_panes(), 1);
// A single show call should fully unhide it.
tree.show_pane_for_child_agent(panes[1]);
assert!(!tree.is_pane_hidden(&panes[1]));
assert_eq!(tree.num_hidden_panes(), 0);
}
#[test]
fn test_original_pane_for_replacement() {
let original = PaneId::dummy_pane_id();
let replacement = PaneId::dummy_pane_id();
let unrelated = PaneId::dummy_pane_id();
let mut tree = PaneData::new(original);
tree.split(original, unrelated, Direction::Right);
// No replacement yet.
assert_eq!(tree.original_pane_for_replacement(original), None);
assert_eq!(tree.original_pane_for_replacement(replacement), None);
// Perform a temporary replacement.
assert!(tree.replace_pane(original, replacement, true));
assert_eq!(
tree.original_pane_for_replacement(replacement),
Some(original)
);
// The original itself is not a replacement.
assert_eq!(tree.original_pane_for_replacement(original), None);
// Unrelated pane is unaffected.
assert_eq!(tree.original_pane_for_replacement(unrelated), None);
// Revert — lookup should return None again.
assert_eq!(
tree.revert_temporary_replacement(replacement),
Some(original)
);
assert_eq!(tree.original_pane_for_replacement(replacement), None);
}
#[test]
fn test_hide_multiple_child_agent_panes() {
let panes = [
PaneId::dummy_pane_id(),
PaneId::dummy_pane_id(),
PaneId::dummy_pane_id(),
];
let mut tree = PaneData::new(panes[0]);
tree.split(panes[0], panes[1], Direction::Right);
tree.split(panes[1], panes[2], Direction::Right);
tree.hide_pane_for_child_agent(panes[1]);
tree.hide_pane_for_child_agent(panes[2]);
assert_eq!(tree.visible_pane_ids(), vec![panes[0]]);
// Reveal only one child.
tree.show_pane_for_child_agent(panes[1]);
assert_eq!(tree.visible_pane_ids(), vec![panes[0], panes[1]]);
assert!(tree.is_pane_hidden(&panes[2]));
}
+750
View File
@@ -0,0 +1,750 @@
#[cfg(feature = "local_fs")]
use indexmap::IndexSet;
#[cfg(feature = "local_fs")]
use repo_metadata::repositories::DetectedRepositories;
use std::collections::HashMap;
#[cfg(feature = "local_fs")]
use std::collections::HashSet;
use std::path::{Path, PathBuf};
#[cfg(feature = "local_fs")]
use warpui::{AppContext, SingletonEntity as _};
use warpui::{Entity, EntityId, ModelContext};
use warpui::{ModelHandle, ViewHandle};
#[cfg(feature = "local_fs")]
use crate::code::file_tree::FileTreeView;
use crate::code_review::comments::{
AttachedReviewComment, PendingImportedReviewComment, ReviewCommentBatch,
};
use crate::code_review::{
code_review_view::CodeReviewView,
diff_state::{DiffMode, DiffStateModel},
};
use crate::workspace::view::global_search::view::GlobalSearchView;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WorkingDirectory {
pub path: PathBuf,
pub terminal_id: Option<EntityId>,
}
/// Events emitted when the set of working directories changes
#[derive(Clone, Debug)]
pub enum WorkingDirectoriesEvent {
/// The set of working directories has changed for a specific pane group.
DirectoriesChanged {
/// The PaneGroup whose directories changed
pane_group_id: EntityId,
/// All active directories (deduplicated) in most to least recently added order.
directories: Vec<WorkingDirectory>,
},
/// The set of repositories has changed for a specific pane group.
RepositoriesChanged {
/// The PaneGroup whose repositories changed
pane_group_id: EntityId,
/// All active repository roots (deduplicated) in most to least recently added order.
repositories: Vec<PathBuf>,
},
/// The focused repository changed for a specific pane group.
/// This fires when the user focuses a different pane or CDs within the focused pane.
FocusedRepoChanged {
/// The PaneGroup whose focused repo changed
pane_group_id: EntityId,
/// All active repository-terminal ID pairs (deduplicated)
repository_terminal_map: HashMap<PathBuf, EntityId>,
/// The repository path of the focused terminal, if any
focused_repo: Option<PathBuf>,
},
}
#[derive(Default)]
#[cfg(feature = "local_fs")]
/// Workspace model that tracks working directories across all pane groups.
/// Emits events when the set of directories changes for any pane group.
pub struct WorkingDirectoriesModel {
/// Per-pane-group tracking of active directories as a deduplicated, ordered set.
///
/// IMPORTANT: This stores the *display roots* for the left panel (file tree / global search),
/// not the raw working directories reported by each pane.
///
/// Concretely, for each pane group's active paths we store:
/// - the detected repository root when the path belongs to a repo
/// - otherwise, the normalized path itself
///
/// IndexSet maintains insertion order - most recently added directories appear later.
pane_groups: HashMap<EntityId, IndexSet<PathBuf>>,
/// Per-pane-group tracking of active repository roots as a deduplicated, ordered set.
/// IndexSet maintains insertion order - most recently added repositories appear later.
repository_roots: HashMap<EntityId, IndexSet<PathBuf>>,
/// Per-pane-group mapping from root paths to a matching terminal view ID.
/// This allows looking up which terminal is associated with each root path.
/// Note, a single root path can be associated with multiple terminals.
/// we're just storing an arbitrary terminal ID for each root path.
directory_to_terminal: HashMap<EntityId, HashMap<PathBuf, EntityId>>,
/// Global mapping from repository root paths to their DiffStateModel.
/// Since git state is inherently tied to a repository (not a pane group),
/// this is stored globally and shared across all pane groups viewing the same repo.
diff_state_models: HashMap<PathBuf, ModelHandle<DiffStateModel>>,
/// Global mapping from repository root paths to their CommentBatch.
/// Like the DiffStateModel mapping, comments are inherently tied to git diffs
/// and are shared across all pane groups viewing the same repo.
comment_models: HashMap<PathBuf, ModelHandle<ReviewCommentBatch>>,
/// Per-pane-group mapping from repository root paths to their CodeReviewView.
/// This allows reusing code review views across multiple requests for the same repo.
code_review_views: HashMap<EntityId, HashMap<PathBuf, ViewHandle<CodeReviewView>>>,
/// Per-pane-group tracking of the focused repository root path.
focused_repo: HashMap<EntityId, Option<PathBuf>>,
global_search_views: HashMap<EntityId, ViewHandle<GlobalSearchView>>,
file_tree_views: HashMap<EntityId, ViewHandle<FileTreeView>>,
}
#[derive(Default)]
#[cfg(not(feature = "local_fs"))]
/// Does nothing without a local file system
pub struct WorkingDirectoriesModel {}
/// Index Sets are ordered by insertion order. This function updates an index set to match a new set of items.
#[cfg(feature = "local_fs")]
pub fn update_index_set(
index_set: &mut IndexSet<PathBuf>,
new_items: impl IntoIterator<Item = PathBuf>,
) {
let new_items: Vec<PathBuf> = new_items.into_iter().collect();
index_set.retain(|item| new_items.iter().any(|new_item| new_item == item));
for item in new_items {
index_set.insert(item);
}
}
#[cfg(feature = "local_fs")]
impl WorkingDirectoriesModel {
pub fn new() -> Self {
Self::default()
}
/// Get the unique directories for a specific pane group in insertion order (oldest first).
fn least_recent_directories_for_pane_group(
&self,
pane_group_id: EntityId,
) -> Option<&IndexSet<PathBuf>> {
self.pane_groups.get(&pane_group_id)
}
/// Get the unique directories for a specific pane group in most to least recently added order.
pub fn most_recent_directories_for_pane_group(
&self,
pane_group_id: EntityId,
) -> Option<impl Iterator<Item = WorkingDirectory> + '_> {
self.least_recent_directories_for_pane_group(pane_group_id)
.map(move |dirs| {
dirs.iter().rev().map(move |path| WorkingDirectory {
path: path.clone(),
terminal_id: self.get_terminal_id_for_root_path(pane_group_id, path),
})
})
}
/// Get the unique repository roots for a specific pane group in insertion order (oldest first).
fn least_recent_repositories_for_pane_group(
&self,
pane_group_id: EntityId,
) -> Option<&IndexSet<PathBuf>> {
self.repository_roots.get(&pane_group_id)
}
/// Get the unique repository roots for a specific pane group in most to least recently added order.
pub fn most_recent_repositories_for_pane_group(
&self,
pane_group_id: EntityId,
) -> Option<impl Iterator<Item = PathBuf> + '_> {
self.least_recent_repositories_for_pane_group(pane_group_id)
.map(|repos| repos.iter().rev().cloned())
}
/// Get the terminal view ID associated with a specific root path in a pane group.
pub fn get_terminal_id_for_root_path(
&self,
pane_group_id: EntityId,
root_path: &Path,
) -> Option<EntityId> {
self.directory_to_terminal
.get(&pane_group_id)
.and_then(|roots| roots.get(root_path).copied())
}
/// Get or create a DiffStateModel for a specific repository.
/// If the model doesn't exist, it will be created.
pub fn get_or_create_diff_state_model(
&mut self,
repo_path: PathBuf,
ctx: &mut ModelContext<Self>,
) -> Option<ModelHandle<DiffStateModel>> {
if let Some(model) = self.diff_state_models.get(&repo_path) {
return Some(model.clone());
}
// Create new DiffStateModel for this repo
let diff_state_model =
ctx.add_model(|ctx| DiffStateModel::new(Some(repo_path.display().to_string()), ctx));
self.diff_state_models
.insert(repo_path.clone(), diff_state_model.clone());
Some(diff_state_model)
}
/// DiffStateModels are shared across tabs. When you delete repos from one tab,
/// we should check if its still in use in any tab. If not, stop its watcher and delete it.
fn drop_unused_diff_state_models(
&mut self,
removed_repos: impl Iterator<Item = PathBuf>,
ctx: &mut ModelContext<Self>,
) {
for repo_path in removed_repos {
if self
.repository_roots
.values()
.all(|tab| !tab.contains(&repo_path))
{
if let Some(model) = self.diff_state_models.remove(&repo_path) {
model.update(ctx, |model, ctx| {
model.stop_active_watcher(ctx);
});
}
}
}
}
/// Get or create a ReviewCommentBatch for a specific repository.
/// If the model doesn't exist, it will be created.
pub fn get_or_create_code_review_comments(
&mut self,
repo_path: &Path,
ctx: &mut ModelContext<Self>,
) -> Option<ModelHandle<ReviewCommentBatch>> {
if let Some(existing) = self.comment_models.get(repo_path) {
return Some(existing.clone());
}
let model = ctx.add_model(|_ctx| ReviewCommentBatch::default());
self.comment_models
.insert(repo_path.to_path_buf(), model.clone());
Some(model)
}
/// Store a CodeReviewView for a specific repository in a pane group.
pub fn store_code_review_view(
&mut self,
pane_group_id: EntityId,
repo_path: PathBuf,
view: ViewHandle<CodeReviewView>,
) {
let pane_group_views = self.code_review_views.entry(pane_group_id).or_default();
pane_group_views.insert(repo_path, view);
// Remove any inactive code reviews here. This allows these to be garbage collected.
self.remove_inactive_code_reviews(pane_group_id);
}
/// Remove any code review view state that is not active in any of the terminal views that belong to this pane group.
fn remove_inactive_code_reviews(&mut self, pane_group_id: EntityId) {
let Some(code_review_views) = self.code_review_views.get_mut(&pane_group_id) else {
return;
};
let Some(terminal_mapping) = self.directory_to_terminal.get(&pane_group_id) else {
return;
};
code_review_views.retain(|path, _| terminal_mapping.contains_key(path));
}
/// Get an existing CodeReviewView for a specific repository in a pane group.
/// Returns None if no view exists for this combination.
pub fn get_code_review_view(
&self,
pane_group_id: EntityId,
repo_path: &Path,
) -> Option<ViewHandle<CodeReviewView>> {
self.code_review_views
.get(&pane_group_id)
.and_then(|pane_group_views| pane_group_views.get(repo_path))
.cloned()
}
pub fn store_global_search_view(
&mut self,
pane_group_id: EntityId,
view: ViewHandle<GlobalSearchView>,
) {
self.global_search_views.insert(pane_group_id, view);
}
pub fn get_global_search_view(
&self,
pane_group_id: EntityId,
) -> Option<ViewHandle<GlobalSearchView>> {
self.global_search_views.get(&pane_group_id).cloned()
}
pub fn store_file_tree_view(
&mut self,
pane_group_id: EntityId,
view: ViewHandle<FileTreeView>,
) {
self.file_tree_views.insert(pane_group_id, view);
}
pub fn get_file_tree_view(&self, pane_group_id: EntityId) -> Option<ViewHandle<FileTreeView>> {
self.file_tree_views.get(&pane_group_id).cloned()
}
/// Permanently removes all state associated with a pane group.
/// This should be called when a tab is closed (pane group is destroyed),
/// as opposed to handle_empty_pane_group which is called when working directories
/// become empty but the pane group still exists (e.g., settings page).
pub fn remove_pane_group(&mut self, pane_group_id: EntityId, ctx: &mut ModelContext<Self>) {
// Clean up directories, terminals, and repos (emits events for subscribers)
self.handle_empty_pane_group(pane_group_id, ctx);
// Clean up views that should persist in handle_empty_pane_group e.g. there's only a settings pane in the pane group
// but need to be removed when the pane group is destroyed
self.global_search_views.remove(&pane_group_id);
self.file_tree_views.remove(&pane_group_id);
self.code_review_views.remove(&pane_group_id);
self.focused_repo.remove(&pane_group_id);
}
fn handle_empty_pane_group(&mut self, pane_group_id: EntityId, ctx: &mut ModelContext<Self>) {
let did_remove_dirs = self.pane_groups.remove(&pane_group_id).is_some();
let did_remove_terminals = self.directory_to_terminal.remove(&pane_group_id).is_some();
let removed_repos = self.repository_roots.remove(&pane_group_id);
let did_remove_repos = removed_repos.is_some();
if let Some(removed_repos) = removed_repos {
self.drop_unused_diff_state_models(removed_repos.into_iter(), ctx);
}
if did_remove_dirs {
ctx.emit(WorkingDirectoriesEvent::DirectoriesChanged {
pane_group_id,
directories: vec![],
});
}
if did_remove_repos {
ctx.emit(WorkingDirectoriesEvent::RepositoriesChanged {
pane_group_id,
repositories: vec![],
});
}
if did_remove_terminals {
ctx.emit(WorkingDirectoriesEvent::FocusedRepoChanged {
pane_group_id,
repository_terminal_map: HashMap::new(),
focused_repo: None,
});
}
}
/// If `focused_terminal_id` is provided, the repo_to_terminal map will prioritize
pub fn refresh_working_directories_for_pane_group(
&mut self,
pane_group_id: EntityId,
terminal_cwds: Vec<(EntityId, String)>,
local_paths: Vec<(EntityId, String)>,
focused_terminal_id: Option<EntityId>,
ctx: &mut ModelContext<Self>,
) {
if terminal_cwds.is_empty() && local_paths.is_empty() {
self.handle_empty_pane_group(pane_group_id, ctx);
return;
}
let old_directories: Vec<WorkingDirectory> = self
.least_recent_directories_for_pane_group(pane_group_id)
.map(|dirs| {
dirs.iter()
.map(|dir| WorkingDirectory {
path: dir.clone(),
terminal_id: self.get_terminal_id_for_root_path(pane_group_id, dir),
})
.collect()
})
.unwrap_or_default();
let old_repos: Vec<PathBuf> = self
.least_recent_repositories_for_pane_group(pane_group_id)
.map(|repos| repos.iter().cloned().collect())
.unwrap_or_default();
let old_focused_repo: Option<PathBuf> =
self.focused_repo.get(&pane_group_id).cloned().flatten();
// Resolve a path to its detected repository root, or keep the path as-is if no repo is found.
let root_for_path = |path: PathBuf| {
DetectedRepositories::as_ref(ctx)
.get_root_for_path(&path)
.unwrap_or(path)
};
let root_for_raw_path = |raw_path: &str| normalize_cwd(raw_path).map(root_for_path);
// Collapse working directories to their nearest repository root (when detected).
let mut file_path_ancestors: HashSet<PathBuf> = terminal_cwds
.iter()
.filter_map(|(_, cwd)| root_for_raw_path(cwd))
.collect();
let local_cwds: Vec<(EntityId, String)> = local_paths
.into_iter()
.filter_map(|(view_id, path)| {
let path_buf = PathBuf::from(&path);
let resolved_path = self
.get_repo_root_for_path(&path_buf, ctx)
.or_else(|| path_buf.parent().map(|p| p.to_path_buf()))?;
if file_path_ancestors.insert(resolved_path.clone()) {
Some((view_id, resolved_path.display().to_string()))
} else {
None
}
})
.collect();
// FYI we have the 3 entity types terminal, code, and notebook below but we're merging them in a way that we only care about the actual paths
// Be careful to not mix the entity IDs if we end up using them in the future!!!
//
// NOTE: We intentionally collapse paths to their repo root when possible, so this is a
// "working roots" list rather than raw per-pane working directories.
let new_root_paths: Vec<PathBuf> = terminal_cwds
.iter()
.chain(local_cwds.iter())
.filter_map(|(_, cwd)| root_for_raw_path(cwd))
.collect();
// Get or create the IndexSet for this pane group
// (IndexSet maintains insertion order and auto-deduplicates)
let pane_group_roots = self.pane_groups.entry(pane_group_id).or_default();
update_index_set(pane_group_roots, new_root_paths.clone());
// Build repo roots and their terminal associations
// First pass: collect all repo roots and build initial mapping
let new_repo_roots: Vec<PathBuf> = self
.pane_groups
.get(&pane_group_id)
.into_iter()
.flat_map(|dirs| dirs.iter())
.filter_map(|dir| self.get_repo_root_for_path(dir, ctx))
.collect();
let mut new_roots: HashSet<PathBuf> = HashSet::from_iter(new_repo_roots.iter().cloned());
new_roots.extend(new_root_paths.iter().cloned());
// Build mapping from directories to their terminal IDs
let mut new_root_to_terminal: HashMap<PathBuf, EntityId> = terminal_cwds
.iter()
.filter_map(|(terminal_id, cwd)| root_for_raw_path(cwd).map(|p| (p, *terminal_id)))
.collect();
new_root_to_terminal.retain(|cwd, _terminal_id| new_roots.contains(cwd));
// Second pass: if we have a focused terminal, ensure its repo maps to it
// This ensures the dropdown selects the correct repo when a pane is focused or CD'd
let mut focused_repo: Option<PathBuf> = None;
if let Some(focused_id) = focused_terminal_id {
let mut repos_to_insert = Vec::new();
for (dir, terminal_id) in &new_root_to_terminal {
if *terminal_id == focused_id {
if let Some(repo_root) = self.get_repo_root_for_path(dir, ctx) {
repos_to_insert.push((repo_root.clone(), focused_id));
focused_repo = Some(repo_root);
}
}
}
for (repo_root, focused_id) in repos_to_insert {
new_root_to_terminal.insert(repo_root, focused_id);
}
}
// Get or create the IndexSet for repository roots
// (IndexSet maintains insertion order and auto-deduplicates)
let pane_group_repos = self.repository_roots.entry(pane_group_id).or_default();
update_index_set(pane_group_repos, new_repo_roots);
// Update the repo to terminal mapping
self.directory_to_terminal
.insert(pane_group_id, new_root_to_terminal);
let new_directories: Vec<WorkingDirectory> = self
.pane_groups
.get(&pane_group_id)
.map(|dirs| {
dirs.iter()
.map(|dir| WorkingDirectory {
path: dir.clone(),
terminal_id: self.get_terminal_id_for_root_path(pane_group_id, dir),
})
.collect()
})
.unwrap_or_default();
let new_deduplicated_repos: Vec<PathBuf> = self
.repository_roots
.get(&pane_group_id)
.map(|repos| repos.iter().cloned().collect())
.unwrap_or_default();
if old_directories != new_directories {
self.emit_directories_changed(pane_group_id, ctx);
}
if old_repos != new_deduplicated_repos {
self.drop_unused_diff_state_models(
old_repos
.into_iter()
.filter(|repo| !new_deduplicated_repos.contains(repo)),
ctx,
);
self.emit_repositories_changed(pane_group_id, ctx);
}
if old_focused_repo != focused_repo {
self.focused_repo
.insert(pane_group_id, focused_repo.clone());
self.emit_focused_repo_changed(pane_group_id, focused_repo, ctx);
}
}
/// Get the repository root for a given path.
fn get_repo_root_for_path(&self, path: &Path, ctx: &AppContext) -> Option<PathBuf> {
DetectedRepositories::as_ref(ctx).get_root_for_path(path)
}
/// Emit a DirectoriesChanged event with the current state for a specific pane group.
/// Directories are returned in most recent first order for use in the UI.
fn emit_directories_changed(&mut self, pane_group_id: EntityId, ctx: &mut ModelContext<Self>) {
ctx.emit(WorkingDirectoriesEvent::DirectoriesChanged {
pane_group_id,
directories: self
.most_recent_directories_for_pane_group(pane_group_id)
.map(|iter| iter.collect())
.unwrap_or_default(),
});
}
/// Emit a RepositoriesChanged event with the current state for a specific pane group.
/// Repositories are returned in most recent first order for use in the UI.
fn emit_repositories_changed(&mut self, pane_group_id: EntityId, ctx: &mut ModelContext<Self>) {
ctx.emit(WorkingDirectoriesEvent::RepositoriesChanged {
pane_group_id,
repositories: self
.most_recent_repositories_for_pane_group(pane_group_id)
.map(|iter| iter.collect())
.unwrap_or_default(),
});
}
fn emit_focused_repo_changed(
&mut self,
pane_group_id: EntityId,
focused_repo: Option<PathBuf>,
ctx: &mut ModelContext<Self>,
) {
ctx.emit(WorkingDirectoriesEvent::FocusedRepoChanged {
pane_group_id,
repository_terminal_map: self
.directory_to_terminal
.get(&pane_group_id)
.cloned()
.unwrap_or_default(),
focused_repo,
});
}
pub(crate) fn insert_code_review_comments(
&mut self,
pane_group_id: EntityId,
repo_path: &Path,
comments: &Vec<PendingImportedReviewComment>,
diff_mode: &DiffMode,
ctx: &mut ModelContext<Self>,
) {
if let Some(code_review_view) = self.get_code_review_view(pane_group_id, repo_path) {
code_review_view.update(ctx, |code_review_view, ctx| {
code_review_view.set_diff_base(diff_mode.to_owned(), ctx);
code_review_view.expand_comment_list(ctx);
})
} else {
log::error!(
"WorkingDirectoriesModel did not find CodeReviewView for repo path {:?}",
repo_path
);
}
if let Some(comment_batch) = self.get_or_create_code_review_comments(repo_path, ctx) {
let comments = comments.to_owned();
comment_batch.update(ctx, |comment_batch, ctx| {
comment_batch.add_pending_imported_comments(comments, diff_mode.to_owned(), ctx);
})
}
}
/// Inserts pre-flattened (already attached) review comments into the comment batch for the
/// given repository, creating the batch if needed. Unlike `insert_code_review_comments`, these
/// comments have already been thread-flattened and converted to `AttachedReviewComment`, so
/// they are ready to be repositioned onto diff editors immediately.
pub(crate) fn upsert_flattened_code_review_comments(
&mut self,
repo_path: &Path,
comments: Vec<AttachedReviewComment>,
ctx: &mut ModelContext<Self>,
) {
if let Some(comment_batch) = self.get_or_create_code_review_comments(repo_path, ctx) {
comment_batch.update(ctx, |comment_batch, ctx| {
comment_batch.upsert_imported_comments(comments, ctx);
});
}
}
}
#[cfg(not(feature = "local_fs"))]
impl WorkingDirectoriesModel {
pub fn new() -> Self {
Self::default()
}
/// Get the unique directories for a specific pane group in most to least recently added order.
pub fn most_recent_directories_for_pane_group(
&self,
_pane_group_id: EntityId,
) -> Option<impl Iterator<Item = WorkingDirectory> + '_> {
Option::<std::iter::Empty<WorkingDirectory>>::None
}
/// Get the unique repository roots for a specific pane group in most to least recently added order.
pub fn most_recent_repositories_for_pane_group(
&self,
_pane_group_id: EntityId,
) -> Option<impl Iterator<Item = PathBuf> + '_> {
Option::<std::iter::Empty<PathBuf>>::None
}
/// Get the terminal view ID associated with a specific repository in a pane group.
pub fn get_terminal_id_for_root_path(
&self,
_pane_group_id: EntityId,
_root_path: &Path,
) -> Option<EntityId> {
None
}
pub fn refresh_working_directories_for_pane_group(
&mut self,
_pane_group_id: EntityId,
_terminal_cwds: Vec<(EntityId, String)>,
_local_paths: Vec<(EntityId, String)>,
_focused_terminal_id: Option<EntityId>,
_ctx: &mut ModelContext<Self>,
) {
}
pub fn get_or_create_diff_state_model(
&mut self,
_repo_path: PathBuf,
_ctx: &mut ModelContext<Self>,
) -> Option<ModelHandle<DiffStateModel>> {
None
}
pub fn get_or_create_code_review_comments(
&mut self,
_repo_path: &Path,
_ctx: &mut ModelContext<Self>,
) -> Option<ModelHandle<ReviewCommentBatch>> {
None
}
pub fn store_code_review_view(
&mut self,
_pane_group_id: EntityId,
_repo_path: PathBuf,
_view: ViewHandle<CodeReviewView>,
) {
}
pub fn get_code_review_view(
&self,
_pane_group_id: EntityId,
_repo_path: &Path,
) -> Option<ViewHandle<CodeReviewView>> {
None
}
pub fn store_global_search_view(
&mut self,
_pane_group_id: EntityId,
_view: ViewHandle<GlobalSearchView>,
) {
}
pub fn get_global_search_view(
&self,
_pane_group_id: EntityId,
) -> Option<ViewHandle<GlobalSearchView>> {
None
}
pub fn store_file_tree_view(
&mut self,
_pane_group_id: EntityId,
_view: ViewHandle<crate::code::file_tree::FileTreeView>,
) {
}
pub fn get_file_tree_view(
&self,
_pane_group_id: EntityId,
) -> Option<ViewHandle<crate::code::file_tree::FileTreeView>> {
None
}
pub fn remove_pane_group(&mut self, _pane_group_id: EntityId, _ctx: &mut ModelContext<Self>) {}
pub(crate) fn insert_code_review_comments(
&mut self,
_pane_group_id: EntityId,
_repo_path: &Path,
_comments: &Vec<PendingImportedReviewComment>,
_diff_mode: &DiffMode,
_ctx: &mut ModelContext<Self>,
) {
}
pub(crate) fn upsert_flattened_code_review_comments(
&mut self,
_repo_path: &Path,
_comments: Vec<AttachedReviewComment>,
_ctx: &mut ModelContext<Self>,
) {
}
}
impl Entity for WorkingDirectoriesModel {
type Event = WorkingDirectoriesEvent;
}
/// Normalize a CWD path string to a canonical PathBuf
///
/// This function attempts to canonicalize (resolve symlinks, make absolute)
///
/// Returns None if the path is empty, invalid, or cannot be canonicalized.
/// Canonicalization failure may indicate remote paths or non-existent directories,
/// which could be supported in the future.
#[cfg(feature = "local_fs")]
fn normalize_cwd(raw_cwd: &str) -> Option<PathBuf> {
if raw_cwd.is_empty() {
return None;
}
let path = PathBuf::from(raw_cwd.to_string());
// Use dunce::canonicalize to avoid Windows extended-length path prefix (\\?\)
// which would cause path comparison mismatches with CanonicalizedPath.
dunce::canonicalize(&path).ok()
}
#[cfg(test)]
#[path = "working_directories_tests.rs"]
mod tests;
@@ -0,0 +1,116 @@
#![cfg(feature = "local_fs")]
use std::collections::HashSet;
use std::fs;
use std::path::PathBuf;
use repo_metadata::repositories::DetectedRepositories;
use warpui::{App, EntityId};
use crate::pane_group::WorkingDirectoriesModel;
#[test]
fn refresh_working_directories_collapses_subroots_to_nearest_repo_root() {
App::test((), |mut app| async move {
let detected_repos_handle = app.add_singleton_model(|_| DetectedRepositories::default());
let temp_dir = tempfile::TempDir::new().expect("temp dir");
let repo_root = temp_dir.path().join("repo");
let repo_a = repo_root.join("a");
let repo_b = repo_root.join("b");
fs::create_dir_all(&repo_a).expect("create repo/a");
fs::create_dir_all(&repo_b).expect("create repo/b");
// Use dunce::canonicalize to match the behavior of warp_util::standardized_path::StandardizedPath and normalize_cwd,
// which strip the Windows extended-length path prefix (\\?\) for consistent comparison.
let canonical_repo_root = dunce::canonicalize(&repo_root).expect("canonical repo root");
// Seed DetectedRepositories so get_root_for_path resolves to this repo.
detected_repos_handle.update(&mut app, |repos, _ctx| {
let canonical =
warp_util::standardized_path::StandardizedPath::from_local_canonicalized(
canonical_repo_root.as_path(),
)
.expect("canonicalized path");
repos.insert_test_repo_root(canonical);
});
let pane_group_id = EntityId::new();
let terminal_1 = EntityId::new();
let terminal_2 = EntityId::new();
let working_directories_handle = app.add_model(|_| WorkingDirectoriesModel::new());
let roots: Vec<PathBuf> = working_directories_handle.update(&mut app, |model, ctx| {
model.refresh_working_directories_for_pane_group(
pane_group_id,
vec![
(terminal_1, repo_a.to_string_lossy().to_string()),
(terminal_2, repo_b.to_string_lossy().to_string()),
],
vec![],
Some(terminal_1),
ctx,
);
model
.most_recent_directories_for_pane_group(pane_group_id)
.expect("pane group exists")
.map(|dir| dir.path)
.collect()
});
assert_eq!(roots.len(), 1);
assert_eq!(roots[0], canonical_repo_root);
});
}
#[test]
fn refresh_working_directories_preserves_non_repo_paths_and_dedupes() {
App::test((), |mut app| async move {
app.add_singleton_model(|_| DetectedRepositories::default());
let temp_dir = tempfile::TempDir::new().expect("temp dir");
let dir_1 = temp_dir.path().join("dir-1");
let dir_2 = temp_dir.path().join("dir-2");
fs::create_dir_all(&dir_1).expect("create dir-1");
fs::create_dir_all(&dir_2).expect("create dir-2");
// Use dunce::canonicalize to match the behavior of normalize_cwd,
// which strips the Windows extended-length path prefix (\\?\) for consistent comparison.
let canonical_1 = dunce::canonicalize(&dir_1).expect("canonical dir-1");
let canonical_2 = dunce::canonicalize(&dir_2).expect("canonical dir-2");
let pane_group_id = EntityId::new();
let terminal_1 = EntityId::new();
let terminal_2 = EntityId::new();
let terminal_3 = EntityId::new();
let working_directories_handle = app.add_model(|_| WorkingDirectoriesModel::new());
let roots: HashSet<PathBuf> = working_directories_handle.update(&mut app, |model, ctx| {
model.refresh_working_directories_for_pane_group(
pane_group_id,
vec![
(terminal_1, dir_1.to_string_lossy().to_string()),
(terminal_2, dir_2.to_string_lossy().to_string()),
// Duplicate root should be deduped.
(terminal_3, dir_1.to_string_lossy().to_string()),
],
vec![],
Some(terminal_1),
ctx,
);
model
.most_recent_directories_for_pane_group(pane_group_id)
.expect("pane group exists")
.map(|dir| dir.path)
.collect()
});
assert_eq!(
roots,
HashSet::from_iter([canonical_1, canonical_2]),
"should preserve non-repo roots and dedupe exact paths"
);
});
}