Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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(¬ebook_view);
|
||||
ctx.unsubscribe_to_model(¬ebook_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,
|
||||
});
|
||||
}
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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]));
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user