Add Rig native model providers
This commit is contained in:
@@ -684,9 +684,11 @@ impl ShellCommandExecutor {
|
||||
.force_refresh_senders
|
||||
.keys()
|
||||
.find(|selector| {
|
||||
selector
|
||||
.get_block(&terminal_model)
|
||||
.is_some_and(|block| block.id() == block_id)
|
||||
selector.get_block(&terminal_model).is_some_and(|block| {
|
||||
block.id() == block_id
|
||||
&& block.is_active_and_long_running()
|
||||
&& !block.finished()
|
||||
})
|
||||
})
|
||||
.cloned();
|
||||
drop(terminal_model);
|
||||
|
||||
@@ -103,11 +103,14 @@ fn force_refresh_block_reports_and_resolves_matching_poll() {
|
||||
ActiveSession::new(sessions.clone(), model_event_dispatcher.clone(), ctx)
|
||||
});
|
||||
let terminal_model = Arc::new(FairMutex::new(TerminalModel::mock(None, None)));
|
||||
terminal_model
|
||||
.lock()
|
||||
.simulate_long_running_block("sleep 120", "still running");
|
||||
let block_id = terminal_model.lock().active_block_id().clone();
|
||||
let executor = app.add_model(|ctx| {
|
||||
ShellCommandExecutor::new(
|
||||
active_session,
|
||||
terminal_model,
|
||||
terminal_model.clone(),
|
||||
&model_event_dispatcher,
|
||||
terminal_view_id,
|
||||
ctx,
|
||||
@@ -124,6 +127,17 @@ fn force_refresh_block_reports_and_resolves_matching_poll() {
|
||||
});
|
||||
|
||||
assert!(matches!(rx.try_recv(), Ok(Some(()))));
|
||||
|
||||
let (tx, _rx) = oneshot::channel();
|
||||
executor.update(&mut app, |executor, _| {
|
||||
executor
|
||||
.force_refresh_senders
|
||||
.insert(BlockSelector::Id(block_id.clone()), tx);
|
||||
});
|
||||
terminal_model.lock().finish_block();
|
||||
assert!(executor.update(&mut app, |executor, _| {
|
||||
!executor.force_refresh_block(&block_id)
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -265,6 +265,8 @@ impl CLISubagentController {
|
||||
let block_id = block.id().clone();
|
||||
let conversation_id = block.ai_conversation_id();
|
||||
let requested_command_action_id = block.requested_command_action_id().cloned();
|
||||
let should_skip_completion_assessment =
|
||||
!should_request_completion_assessment(block.long_running_control_state());
|
||||
let completion = match (&block_completed_event.block_type, conversation_id) {
|
||||
(BlockType::User(completed), Some(conversation_id)) => {
|
||||
let command = if completed.command_with_obfuscated_secrets.is_empty() {
|
||||
@@ -310,17 +312,49 @@ impl CLISubagentController {
|
||||
};
|
||||
drop(terminal_model);
|
||||
|
||||
let Some(subagent_state) = me.active_subagents_by_block.get_mut(&block_id) else {
|
||||
let Some(has_last_snapshot) = me
|
||||
.active_subagents_by_block
|
||||
.get(&block_id)
|
||||
.map(|state| state.last_snapshot_at.is_some())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if subagent_state.last_snapshot_at.is_some() {
|
||||
if has_last_snapshot {
|
||||
ctx.emit(CLISubagentEvent::UpdatedLastSnapshot);
|
||||
}
|
||||
subagent_state.completion = completion;
|
||||
if subagent_state.completion.is_none() {
|
||||
|
||||
// A Stop takeover intentionally cancels the subagent. The command may still
|
||||
// finish later, but that completion must not start a new assessment turn. Also
|
||||
// clean up the in-memory monitor state so the stopped subagent cannot linger in
|
||||
// the UI or intercept later refreshes.
|
||||
if should_skip_completion_assessment {
|
||||
me.finish_subagent(
|
||||
&block_id,
|
||||
conversation_id,
|
||||
requested_command_action_id,
|
||||
ctx,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let has_completion = {
|
||||
let Some(subagent_state) = me.active_subagents_by_block.get_mut(&block_id)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
subagent_state.completion = completion;
|
||||
subagent_state.completion.is_some()
|
||||
};
|
||||
if !has_completion {
|
||||
log::warn!(
|
||||
"CLI monitor block {block_id:?} completed without final command metadata"
|
||||
);
|
||||
me.finish_subagent(
|
||||
&block_id,
|
||||
conversation_id,
|
||||
requested_command_action_id,
|
||||
ctx,
|
||||
);
|
||||
return;
|
||||
}
|
||||
me.advance_completed_subagent(&block_id, ctx);
|
||||
@@ -380,7 +414,12 @@ impl CLISubagentController {
|
||||
}
|
||||
|
||||
if completion.final_turn_started {
|
||||
self.finish_completed_subagent(block_id, ctx);
|
||||
self.finish_subagent(
|
||||
block_id,
|
||||
Some(completion.conversation_id),
|
||||
completion.initial_requested_command_action_id,
|
||||
ctx,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -404,38 +443,55 @@ impl CLISubagentController {
|
||||
}
|
||||
}
|
||||
|
||||
fn finish_completed_subagent(&mut self, block_id: &BlockId, ctx: &mut ModelContext<Self>) {
|
||||
fn finish_subagent(
|
||||
&mut self,
|
||||
block_id: &BlockId,
|
||||
conversation_id: Option<AIConversationId>,
|
||||
initial_requested_command_action_id: Option<AIAgentActionId>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let Some(state) = self.active_subagents_by_block.remove(block_id) else {
|
||||
return;
|
||||
};
|
||||
let Some(completion) = state.completion else {
|
||||
return;
|
||||
};
|
||||
let conversation_id = conversation_id.or_else(|| {
|
||||
state
|
||||
.completion
|
||||
.as_ref()
|
||||
.map(|completion| completion.conversation_id)
|
||||
});
|
||||
let initial_requested_command_action_id = initial_requested_command_action_id
|
||||
.or_else(|| {
|
||||
state
|
||||
.completion
|
||||
.as_ref()
|
||||
.and_then(|completion| completion.initial_requested_command_action_id.clone())
|
||||
})
|
||||
.or(state.initial_requested_command_action_id);
|
||||
|
||||
let deactivate_result =
|
||||
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, _| {
|
||||
history_model.deactivate_cli_subagent_task_for_conversation(
|
||||
block_id,
|
||||
completion.conversation_id,
|
||||
)
|
||||
});
|
||||
if let Err(error) = deactivate_result {
|
||||
log::error!(
|
||||
"Failed to deactivate completed CLI monitor for block {block_id:?}: {error:?}"
|
||||
);
|
||||
if let Some(conversation_id) = conversation_id {
|
||||
let deactivate_result =
|
||||
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, _| {
|
||||
history_model
|
||||
.deactivate_cli_subagent_task_for_conversation(block_id, conversation_id)
|
||||
});
|
||||
if let Err(error) = deactivate_result {
|
||||
log::error!("Failed to deactivate CLI monitor for block {block_id:?}: {error:?}");
|
||||
}
|
||||
}
|
||||
|
||||
ctx.emit(CLISubagentEvent::FinishedSubagent {
|
||||
block_id: block_id.clone(),
|
||||
conversation_id: Some(completion.conversation_id),
|
||||
initial_requested_command_action_id: completion.initial_requested_command_action_id,
|
||||
conversation_id,
|
||||
initial_requested_command_action_id,
|
||||
});
|
||||
|
||||
if let Some(agent_view_controller) = &self.agent_view_controller {
|
||||
if let (Some(agent_view_controller), Some(conversation_id)) =
|
||||
(&self.agent_view_controller, conversation_id)
|
||||
{
|
||||
agent_view_controller.update(ctx, |controller, ctx| {
|
||||
let is_this_inline_conversation = controller.is_inline()
|
||||
&& controller.agent_view_state().active_conversation_id()
|
||||
== Some(completion.conversation_id);
|
||||
== Some(conversation_id);
|
||||
if is_this_inline_conversation {
|
||||
controller.exit_agent_view(ctx);
|
||||
}
|
||||
@@ -919,3 +975,42 @@ fn command_finished_block_id(result: &AIAgentActionResultType) -> Option<&BlockI
|
||||
| AIAgentActionResultType::WaitForEvents(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn should_request_completion_assessment(
|
||||
control_state: Option<&LongRunningCommandControlState>,
|
||||
) -> bool {
|
||||
!control_state
|
||||
.and_then(LongRunningCommandControlState::user_take_over_reason)
|
||||
.is_some_and(UserTakeOverReason::is_stop)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn stop_takeover_does_not_request_a_completion_assessment() {
|
||||
let state = LongRunningCommandControlState::User {
|
||||
reason: UserTakeOverReason::Stop,
|
||||
};
|
||||
|
||||
assert!(!should_request_completion_assessment(Some(&state)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_stop_control_states_can_request_a_completion_assessment() {
|
||||
let agent_state = LongRunningCommandControlState::Agent {
|
||||
is_blocked: false,
|
||||
should_hide_responses: false,
|
||||
};
|
||||
let transfer_state = LongRunningCommandControlState::User {
|
||||
reason: UserTakeOverReason::TransferFromAgent {
|
||||
reason: "needs user input".to_owned(),
|
||||
},
|
||||
};
|
||||
|
||||
assert!(should_request_completion_assessment(None));
|
||||
assert!(should_request_completion_assessment(Some(&agent_state)));
|
||||
assert!(should_request_completion_assessment(Some(&transfer_state)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,6 +230,8 @@ impl ResponseStream {
|
||||
kind: client_config.kind,
|
||||
base_url: client_config.base_url.clone(),
|
||||
api_key: client_config.api_key.clone(),
|
||||
project_id: client_config.project_id.clone(),
|
||||
location: client_config.location.clone(),
|
||||
model: client_config
|
||||
.model
|
||||
.clone()
|
||||
|
||||
@@ -167,6 +167,8 @@ impl CrosscheckReviewer {
|
||||
kind: client_config.kind,
|
||||
base_url: client_config.base_url.clone(),
|
||||
api_key: client_config.api_key.clone(),
|
||||
project_id: client_config.project_id.clone(),
|
||||
location: client_config.location.clone(),
|
||||
model: client_config
|
||||
.model
|
||||
.clone()
|
||||
|
||||
+105
-8
@@ -5,6 +5,11 @@ use std::sync::{Arc, OnceLock};
|
||||
|
||||
use ai::api_keys::ApiKeyManager;
|
||||
pub use ai::LLMId;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use galaxy_agent_rig::{
|
||||
discover_anthropic_models, discover_gemini_models, validate_vertex_ai_credentials,
|
||||
vertex_ai_model_catalog, RigModelInfo,
|
||||
};
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxy_core::ui::icons::Icon;
|
||||
use galaxy_core::user_preferences::GetUserPreferences;
|
||||
@@ -1005,6 +1010,8 @@ impl LLMPreferences {
|
||||
bool,
|
||||
String,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Vec<OpenAIModelConfig>,
|
||||
);
|
||||
let mut provider_entries: Vec<OpenAIProviderEntry> = Vec::new();
|
||||
@@ -1037,6 +1044,8 @@ impl LLMPreferences {
|
||||
true,
|
||||
base_url,
|
||||
api_key,
|
||||
None,
|
||||
None,
|
||||
single_provider_models,
|
||||
));
|
||||
}
|
||||
@@ -1047,11 +1056,19 @@ impl LLMPreferences {
|
||||
.value()
|
||||
.iter()
|
||||
.filter_map(|provider| {
|
||||
if !provider.enabled
|
||||
|| (provider.kind == OpenAIProviderKind::OpenAICompatible
|
||||
&& provider.base_url.trim().is_empty())
|
||||
|| provider.models.is_empty()
|
||||
{
|
||||
let missing_credentials = match provider.kind {
|
||||
OpenAIProviderKind::OpenAICompatible => provider.base_url.trim().is_empty(),
|
||||
OpenAIProviderKind::Anthropic | OpenAIProviderKind::Gemini => provider
|
||||
.api_key
|
||||
.as_deref()
|
||||
.is_none_or(|key| key.trim().is_empty()),
|
||||
OpenAIProviderKind::VertexAI => provider
|
||||
.project_id
|
||||
.as_deref()
|
||||
.is_none_or(|project| project.trim().is_empty()),
|
||||
OpenAIProviderKind::ChatGPTSubscription => false,
|
||||
};
|
||||
if !provider.enabled || missing_credentials || provider.models.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some((
|
||||
@@ -1060,6 +1077,8 @@ impl LLMPreferences {
|
||||
provider.enabled,
|
||||
provider.base_url.clone(),
|
||||
provider.api_key.clone(),
|
||||
provider.project_id.clone(),
|
||||
provider.location.clone(),
|
||||
provider.models.clone(),
|
||||
))
|
||||
}),
|
||||
@@ -1071,8 +1090,16 @@ impl LLMPreferences {
|
||||
|
||||
let mut total_injected = 0;
|
||||
let mut seen_model_ids: HashSet<String> = HashSet::new();
|
||||
for (provider_name, provider_kind, provider_enabled, base_url, api_key, models) in
|
||||
provider_entries
|
||||
for (
|
||||
provider_name,
|
||||
provider_kind,
|
||||
provider_enabled,
|
||||
base_url,
|
||||
api_key,
|
||||
provider_project_id,
|
||||
provider_location,
|
||||
models,
|
||||
) in provider_entries
|
||||
{
|
||||
if !provider_enabled {
|
||||
continue;
|
||||
@@ -1109,12 +1136,14 @@ impl LLMPreferences {
|
||||
kind: provider_kind,
|
||||
base_url: base_url.clone(),
|
||||
api_key: api_key.clone(),
|
||||
project_id: provider_project_id.clone(),
|
||||
location: provider_location.clone(),
|
||||
model: Some(model.model_id.clone()),
|
||||
reasoning_effort: reasoning_effort.clone(),
|
||||
max_input_tokens: Some(openai_model_context_size(model)),
|
||||
max_output_tokens: model.max_output_tokens,
|
||||
use_rig: model.use_rig
|
||||
|| provider_kind == OpenAIProviderKind::ChatGPTSubscription,
|
||||
|| !matches!(provider_kind, OpenAIProviderKind::OpenAICompatible),
|
||||
supports_system_messages: model.supports_system_messages(),
|
||||
};
|
||||
self.openai_provider_routing
|
||||
@@ -1557,6 +1586,54 @@ impl LLMPreferences {
|
||||
pub(crate) async fn discover_openai_provider_models(
|
||||
provider: OpenAIProviderConfig,
|
||||
) -> Result<Vec<OpenAIModelConfig>, String> {
|
||||
let native_models = match provider.kind {
|
||||
OpenAIProviderKind::Anthropic => {
|
||||
let api_key = provider
|
||||
.api_key
|
||||
.as_deref()
|
||||
.filter(|key| !key.trim().is_empty())
|
||||
.ok_or_else(|| {
|
||||
"Enter an Anthropic API key before testing the connection.".to_string()
|
||||
})?;
|
||||
Some(discover_anthropic_models(api_key).await?)
|
||||
}
|
||||
OpenAIProviderKind::Gemini => {
|
||||
let api_key = provider
|
||||
.api_key
|
||||
.as_deref()
|
||||
.filter(|key| !key.trim().is_empty())
|
||||
.ok_or_else(|| {
|
||||
"Enter a Gemini API key before testing the connection.".to_string()
|
||||
})?;
|
||||
Some(discover_gemini_models(api_key).await?)
|
||||
}
|
||||
OpenAIProviderKind::VertexAI => {
|
||||
if provider
|
||||
.project_id
|
||||
.as_deref()
|
||||
.is_none_or(|project| project.trim().is_empty())
|
||||
{
|
||||
return Err(
|
||||
"Enter a Google Cloud project ID before testing the connection."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
validate_vertex_ai_credentials(
|
||||
provider.project_id.as_deref().unwrap_or_default(),
|
||||
provider.location.as_deref().unwrap_or("global"),
|
||||
)?;
|
||||
Some(vertex_ai_model_catalog())
|
||||
}
|
||||
OpenAIProviderKind::OpenAICompatible | OpenAIProviderKind::ChatGPTSubscription => None,
|
||||
};
|
||||
|
||||
if let Some(models) = native_models {
|
||||
if models.is_empty() {
|
||||
return Err("The provider responded, but no models were found.".to_string());
|
||||
}
|
||||
return Ok(Self::rig_models_to_openai_models(models));
|
||||
}
|
||||
|
||||
if provider.base_url.trim().is_empty() {
|
||||
return Err("Enter a provider URL before testing the connection.".to_string());
|
||||
}
|
||||
@@ -1586,6 +1663,26 @@ impl LLMPreferences {
|
||||
Ok(models)
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
fn rig_models_to_openai_models(models: Vec<RigModelInfo>) -> Vec<OpenAIModelConfig> {
|
||||
models
|
||||
.into_iter()
|
||||
.map(|model| OpenAIModelConfig {
|
||||
model_id: model.id,
|
||||
display_name: model.display_name,
|
||||
vision_supported: false,
|
||||
context_size: model.context_size.unwrap_or(128_000),
|
||||
max_input_tokens: model.context_size,
|
||||
max_output_tokens: None,
|
||||
provider: None,
|
||||
use_rig: true,
|
||||
supports_system_messages: Some(true),
|
||||
reasoning_efforts: Vec::new(),
|
||||
enabled: true,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Returns the `LLMInfo` for the base LLM to be used for an Agent Mode request.
|
||||
pub fn get_active_base_model<'a>(
|
||||
&'a self,
|
||||
|
||||
@@ -11,6 +11,8 @@ pub struct OpenAIClientConfig {
|
||||
pub kind: OpenAIProviderKind,
|
||||
pub base_url: String,
|
||||
pub api_key: Option<String>,
|
||||
pub project_id: Option<String>,
|
||||
pub location: Option<String>,
|
||||
pub model: Option<String>,
|
||||
pub reasoning_effort: Option<String>,
|
||||
pub max_input_tokens: Option<u32>,
|
||||
|
||||
@@ -8,8 +8,9 @@ use galaxy_agent_core::{
|
||||
ToolCallDecision, ToolEvent, ToolPolicy, ToolResult, TurnCommand,
|
||||
};
|
||||
use galaxy_agent_rig::{
|
||||
ChatGPTSubscriptionRuntime, ChatGPTSubscriptionRuntimeConfig, OpenAICompatibleRuntime,
|
||||
OpenAICompatibleRuntimeConfig,
|
||||
AnthropicRuntime, AnthropicRuntimeConfig, ChatGPTSubscriptionRuntime,
|
||||
ChatGPTSubscriptionRuntimeConfig, GeminiRuntime, GeminiRuntimeConfig, OpenAICompatibleRuntime,
|
||||
OpenAICompatibleRuntimeConfig, VertexAiRuntime, VertexAiRuntimeConfig,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
use warp_multi_agent_api::ToolType;
|
||||
@@ -73,6 +74,52 @@ pub(crate) fn rig_openai_response_stream(
|
||||
cancellation_rx,
|
||||
)
|
||||
}
|
||||
OpenAIProviderKind::Anthropic => {
|
||||
let runtime = AnthropicRuntime::new(AnthropicRuntimeConfig {
|
||||
api_key: config.api_key.unwrap_or_default(),
|
||||
model: model_id,
|
||||
max_output_tokens: config.max_output_tokens.map(u64::from),
|
||||
});
|
||||
rig_response_stream(
|
||||
runtime,
|
||||
prepared,
|
||||
skill_path_origin,
|
||||
config.max_input_tokens,
|
||||
"rig_anthropic",
|
||||
cancellation_rx,
|
||||
)
|
||||
}
|
||||
OpenAIProviderKind::Gemini => {
|
||||
let runtime = GeminiRuntime::new(GeminiRuntimeConfig {
|
||||
api_key: config.api_key.unwrap_or_default(),
|
||||
model: model_id,
|
||||
max_output_tokens: config.max_output_tokens.map(u64::from),
|
||||
});
|
||||
rig_response_stream(
|
||||
runtime,
|
||||
prepared,
|
||||
skill_path_origin,
|
||||
config.max_input_tokens,
|
||||
"rig_gemini",
|
||||
cancellation_rx,
|
||||
)
|
||||
}
|
||||
OpenAIProviderKind::VertexAI => {
|
||||
let runtime = VertexAiRuntime::new(VertexAiRuntimeConfig {
|
||||
project_id: config.project_id.unwrap_or_default(),
|
||||
location: config.location.unwrap_or_else(|| "global".to_string()),
|
||||
model: model_id,
|
||||
max_output_tokens: config.max_output_tokens.map(u64::from),
|
||||
});
|
||||
rig_response_stream(
|
||||
runtime,
|
||||
prepared,
|
||||
skill_path_origin,
|
||||
config.max_input_tokens,
|
||||
"rig_vertex_ai",
|
||||
cancellation_rx,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ fn config() -> OpenAIClientConfig {
|
||||
kind: crate::settings::OpenAIProviderKind::OpenAICompatible,
|
||||
base_url: "http://localhost:4000/v1".to_string(),
|
||||
api_key: None,
|
||||
project_id: None,
|
||||
location: None,
|
||||
model: Some("provider-model".to_string()),
|
||||
reasoning_effort: None,
|
||||
max_input_tokens: Some(128_000),
|
||||
|
||||
@@ -1,32 +1,12 @@
|
||||
use std::env::current_dir;
|
||||
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use galaxyui::App;
|
||||
|
||||
use super::expand_dirs;
|
||||
use crate::cloud_object::model::persistence::CloudModel;
|
||||
use crate::network::NetworkStatus;
|
||||
use crate::server::cloud_objects::update_manager::UpdateManager;
|
||||
use crate::server::sync_queue::SyncQueue;
|
||||
use crate::workspaces::team_tester::TeamTesterStatus;
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
use crate::{GlobalResourceHandles, GlobalResourceHandlesProvider};
|
||||
|
||||
#[test]
|
||||
fn test_expand_directories() {
|
||||
App::test((), |mut app| async move {
|
||||
app.update(crate::settings::init_and_register_user_preferences);
|
||||
|
||||
let global_resource_handles = GlobalResourceHandles::mock(&mut app);
|
||||
app.add_singleton_model(|_| GlobalResourceHandlesProvider::new(global_resource_handles));
|
||||
app.add_singleton_model(CloudModel::mock);
|
||||
app.add_singleton_model(UserWorkspaces::default_mock);
|
||||
app.add_singleton_model(|_| Appearance::mock());
|
||||
app.add_singleton_model(|_| NetworkStatus::new());
|
||||
app.add_singleton_model(SyncQueue::mock);
|
||||
app.add_singleton_model(TeamTesterStatus::mock);
|
||||
app.add_singleton_model(UpdateManager::mock);
|
||||
|
||||
App::test((), |_| async move {
|
||||
let directory = current_dir()
|
||||
.expect("current directory should exist")
|
||||
.parent()
|
||||
|
||||
@@ -18,6 +18,7 @@ use super::modal_body::{ImportModalBody, ImportModalBodyAction, ImportModalBodyE
|
||||
use crate::appearance::Appearance;
|
||||
use crate::cloud_object::model::persistence::CloudModel;
|
||||
use crate::cloud_object::{CloudObject, Owner};
|
||||
use crate::local_object_repository::local_owner;
|
||||
use crate::server::ids::SyncId;
|
||||
use crate::server::sync_queue::SyncQueue;
|
||||
use crate::themes::theme::GalaxyTheme;
|
||||
@@ -88,7 +89,8 @@ impl ImportModal {
|
||||
let window_id = ctx.window_id();
|
||||
let import_body_id = self.import_modal.id();
|
||||
|
||||
let sync_queue_is_dequeueing = SyncQueue::as_ref(ctx).is_dequeueing();
|
||||
let sync_queue_is_dequeueing =
|
||||
self.owner != Some(local_owner()) && SyncQueue::as_ref(ctx).is_dequeueing();
|
||||
|
||||
let allowed_file_types = vec![FileType::Yaml, FileType::Markdown];
|
||||
|
||||
@@ -98,7 +100,7 @@ impl ImportModal {
|
||||
|
||||
// Files under a folder could only be uploaded when the folder is created on the server.
|
||||
// When sync queue is not dequeueing, disable folder upload in the import modal.
|
||||
if sync_queue_is_dequeueing {
|
||||
if sync_queue_is_dequeueing || self.owner == Some(local_owner()) {
|
||||
file_picker_config = file_picker_config.allow_folder();
|
||||
}
|
||||
|
||||
@@ -165,9 +167,13 @@ impl ImportModal {
|
||||
// Convert to a Space for display, in case we're importing into a shared folder.
|
||||
self.owner
|
||||
.map(|owner| {
|
||||
UserWorkspaces::as_ref(app)
|
||||
.owner_to_space(owner, app)
|
||||
.name(app)
|
||||
if owner == local_owner() {
|
||||
"Personal".to_string()
|
||||
} else {
|
||||
UserWorkspaces::as_ref(app)
|
||||
.owner_to_space(owner, app)
|
||||
.name(app)
|
||||
}
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
0,
|
||||
|
||||
@@ -21,6 +21,7 @@ use super::nodes::{
|
||||
use super::queue::{ImportQueue, ImportQueueArgs, ImportQueueEvent, ParentId, RequestContent};
|
||||
use crate::appearance::Appearance;
|
||||
use crate::cloud_object::Owner;
|
||||
use crate::local_object_repository::local_owner;
|
||||
use crate::server::ids::{ClientId, SyncId};
|
||||
use crate::server::sync_queue::SyncQueue;
|
||||
use crate::ui_components::icons::Icon;
|
||||
@@ -96,7 +97,7 @@ pub struct ImportModalBody {
|
||||
|
||||
impl ImportModalBody {
|
||||
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
|
||||
let import_queue = ctx.add_model(ImportQueue::new);
|
||||
let import_queue = ctx.add_model(|_| ImportQueue::new());
|
||||
ctx.subscribe_to_model(&import_queue, |me, _, event, ctx| {
|
||||
me.handle_import_queue_event(event, ctx)
|
||||
});
|
||||
@@ -152,7 +153,8 @@ impl ImportModalBody {
|
||||
}
|
||||
}
|
||||
|
||||
let sync_queue_dequeueing = SyncQueue::as_ref(ctx).is_dequeueing();
|
||||
let sync_queue_dequeueing =
|
||||
self.owner != Some(local_owner()) && SyncQueue::as_ref(ctx).is_dequeueing();
|
||||
|
||||
if !sync_queue_dequeueing && state.all_files_saved_locally() {
|
||||
ctx.emit(ImportModalBodyEvent::AllFileSavedLocally);
|
||||
@@ -177,7 +179,8 @@ impl ImportModalBody {
|
||||
// Whether there is an active upload in progress (If all uploads are completed,
|
||||
// we don't consider the import modal upload to be in progress).
|
||||
pub fn upload_in_progress(&self, app: &AppContext) -> bool {
|
||||
let sync_queue_dequeueing = SyncQueue::as_ref(app).is_dequeueing();
|
||||
let sync_queue_dequeueing =
|
||||
self.owner != Some(local_owner()) && SyncQueue::as_ref(app).is_dequeueing();
|
||||
|
||||
match &self.state {
|
||||
ImportState::Upload => false,
|
||||
@@ -498,7 +501,8 @@ impl View for ImportModalBody {
|
||||
}
|
||||
|
||||
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let sync_queue_dequeueing = SyncQueue::as_ref(app).is_dequeueing();
|
||||
let sync_queue_dequeueing =
|
||||
self.owner != Some(local_owner()) && SyncQueue::as_ref(app).is_dequeueing();
|
||||
let appearance = Appearance::as_ref(app);
|
||||
|
||||
match &self.state {
|
||||
|
||||
+165
-77
@@ -5,7 +5,7 @@ use galaxyui::{Entity, ModelContext, SingletonEntity};
|
||||
use super::nodes::{self, FileId};
|
||||
use crate::cloud_object::model::persistence::CloudModel;
|
||||
use crate::cloud_object::{CloudObjectEventEntrypoint, Owner};
|
||||
use crate::drive::folders::FolderId;
|
||||
use crate::local_object_repository::{local_owner, LocalObjectRepository};
|
||||
use crate::notebooks::CloudNotebookModel;
|
||||
use crate::server::cloud_objects::update_manager::{
|
||||
InitiatedBy, ObjectOperation, OperationSuccessType, UpdateManager, UpdateManagerEvent,
|
||||
@@ -94,31 +94,40 @@ impl FileCompletionCounter {
|
||||
|
||||
pub(super) struct ImportQueue {
|
||||
queue: Vec<ImportQueueArgs>,
|
||||
client_to_server_id: HashMap<ClientId, Option<FolderId>>,
|
||||
client_to_folder_id: HashMap<ClientId, Option<SyncId>>,
|
||||
client_to_node_folder_id: HashMap<ClientId, nodes::FolderId>,
|
||||
file_completion: FileCompletionCounter,
|
||||
remote_subscription_initialized: bool,
|
||||
}
|
||||
|
||||
impl ImportQueue {
|
||||
pub fn new(ctx: &mut ModelContext<Self>) -> Self {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
queue: Vec::new(),
|
||||
client_to_folder_id: HashMap::default(),
|
||||
file_completion: Default::default(),
|
||||
client_to_node_folder_id: HashMap::default(),
|
||||
remote_subscription_initialized: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_remote_subscription(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
if self.remote_subscription_initialized {
|
||||
return;
|
||||
}
|
||||
|
||||
let update_manager = UpdateManager::handle(ctx);
|
||||
ctx.subscribe_to_model(&update_manager, |me, _, event, ctx| {
|
||||
me.handle_update_manager_event(event, ctx);
|
||||
});
|
||||
|
||||
Self {
|
||||
queue: Vec::new(),
|
||||
client_to_server_id: HashMap::default(),
|
||||
file_completion: Default::default(),
|
||||
client_to_node_folder_id: HashMap::default(),
|
||||
}
|
||||
self.remote_subscription_initialized = true;
|
||||
}
|
||||
|
||||
// Whether all dependencies of an item has been sync-ed.
|
||||
fn dependency_synced(&self, item: &ImportQueueArgs) -> bool {
|
||||
match &item.parent_id {
|
||||
ParentId::FolderToUpload(id) => self
|
||||
.client_to_server_id
|
||||
.client_to_folder_id
|
||||
.get(id)
|
||||
.map(|item| item.is_some())
|
||||
.unwrap_or(false),
|
||||
@@ -128,6 +137,11 @@ impl ImportQueue {
|
||||
|
||||
// Enqueue a new request to the import queue.
|
||||
pub fn enqueue(&mut self, arg: ImportQueueArgs, ctx: &mut ModelContext<Self>) {
|
||||
let is_local = arg.owner == local_owner();
|
||||
if !is_local {
|
||||
self.ensure_remote_subscription(ctx);
|
||||
}
|
||||
|
||||
// Update internal tracker of the object.
|
||||
match &arg.content {
|
||||
RequestContent::Folder {
|
||||
@@ -135,17 +149,23 @@ impl ImportQueue {
|
||||
folder_id,
|
||||
..
|
||||
} => {
|
||||
self.client_to_server_id.insert(*client_id, None);
|
||||
self.client_to_folder_id.insert(*client_id, None);
|
||||
self.client_to_node_folder_id.insert(*client_id, *folder_id);
|
||||
}
|
||||
RequestContent::Notebook {
|
||||
client_id, file_id, ..
|
||||
} => self.file_completion.add_entry(*client_id, *file_id),
|
||||
} => {
|
||||
if !is_local {
|
||||
self.file_completion.add_entry(*client_id, *file_id);
|
||||
}
|
||||
}
|
||||
RequestContent::Workflow {
|
||||
workflows, file_id, ..
|
||||
} => {
|
||||
for (_, client_id) in workflows {
|
||||
self.file_completion.add_entry(*client_id, *file_id);
|
||||
if !is_local {
|
||||
for (_, client_id) in workflows {
|
||||
self.file_completion.add_entry(*client_id, *file_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -167,31 +187,47 @@ impl ImportQueue {
|
||||
{
|
||||
let dequeued_item = self.queue.remove(idx);
|
||||
let parent_id = match dequeued_item.parent_id {
|
||||
ParentId::FolderToUpload(client_id) => Some(SyncId::ServerId(
|
||||
self.client_to_server_id
|
||||
ParentId::FolderToUpload(client_id) => Some(
|
||||
self.client_to_folder_id
|
||||
.get(&client_id)
|
||||
.expect("Client id entry should exist")
|
||||
.expect("Server id entry should exist")
|
||||
.into(),
|
||||
)),
|
||||
.expect("Folder id entry should exist"),
|
||||
),
|
||||
ParentId::InitialFolder(folder_id) => folder_id,
|
||||
};
|
||||
|
||||
let is_local = dequeued_item.owner == local_owner();
|
||||
match dequeued_item.content {
|
||||
RequestContent::Folder {
|
||||
name, client_id, ..
|
||||
} => {
|
||||
UpdateManager::handle(ctx).update(ctx, |update_manager, ctx| {
|
||||
update_manager.create_folder(
|
||||
name,
|
||||
dequeued_item.owner,
|
||||
client_id,
|
||||
parent_id,
|
||||
false,
|
||||
InitiatedBy::User,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
if is_local {
|
||||
let local_id = SyncId::ClientId(client_id);
|
||||
LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
|
||||
repository.create_folder_with_id(local_id, name, parent_id, ctx);
|
||||
});
|
||||
self.client_to_folder_id.insert(client_id, Some(local_id));
|
||||
ctx.emit(ImportQueueEvent::FolderCompleted {
|
||||
folder_id: self
|
||||
.client_to_node_folder_id
|
||||
.get(&client_id)
|
||||
.copied()
|
||||
.expect("Folder node id should exist"),
|
||||
server_id: Some(local_id.uid()),
|
||||
});
|
||||
} else {
|
||||
UpdateManager::handle(ctx).update(ctx, |update_manager, ctx| {
|
||||
update_manager.create_folder(
|
||||
name,
|
||||
dequeued_item.owner,
|
||||
client_id,
|
||||
parent_id,
|
||||
false,
|
||||
InitiatedBy::User,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
RequestContent::Notebook {
|
||||
title,
|
||||
@@ -199,56 +235,104 @@ impl ImportQueue {
|
||||
client_id,
|
||||
file_id,
|
||||
} => {
|
||||
UpdateManager::handle(ctx).update(ctx, |update_manager, ctx| {
|
||||
update_manager.create_notebook(
|
||||
client_id,
|
||||
dequeued_item.owner,
|
||||
parent_id,
|
||||
CloudNotebookModel {
|
||||
title,
|
||||
data,
|
||||
ai_document_id: None,
|
||||
conversation_id: None,
|
||||
},
|
||||
CloudObjectEventEntrypoint::ImportModal,
|
||||
false,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
ctx.emit(ImportQueueEvent::FileSavedLocally(file_id));
|
||||
if is_local {
|
||||
let local_id = SyncId::ClientId(client_id);
|
||||
LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
|
||||
repository.create_notebook_with_id(
|
||||
local_id,
|
||||
parent_id,
|
||||
CloudNotebookModel {
|
||||
title,
|
||||
data,
|
||||
ai_document_id: None,
|
||||
conversation_id: None,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
ctx.emit(ImportQueueEvent::FileCompleted {
|
||||
file_id,
|
||||
server_id: Some(local_id.uid()),
|
||||
});
|
||||
} else {
|
||||
UpdateManager::handle(ctx).update(ctx, |update_manager, ctx| {
|
||||
update_manager.create_notebook(
|
||||
client_id,
|
||||
dequeued_item.owner,
|
||||
parent_id,
|
||||
CloudNotebookModel {
|
||||
title,
|
||||
data,
|
||||
ai_document_id: None,
|
||||
conversation_id: None,
|
||||
},
|
||||
CloudObjectEventEntrypoint::ImportModal,
|
||||
false,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
ctx.emit(ImportQueueEvent::FileSavedLocally(file_id));
|
||||
}
|
||||
}
|
||||
RequestContent::Workflow {
|
||||
workflows,
|
||||
workflow_enums,
|
||||
file_id,
|
||||
} => {
|
||||
UpdateManager::handle(ctx).update(ctx, |update_manager, ctx| {
|
||||
// Create any new workflow enums
|
||||
for (client_id, workflow_enum) in workflow_enums {
|
||||
update_manager.create_workflow_enum(
|
||||
workflow_enum,
|
||||
dequeued_item.owner,
|
||||
client_id,
|
||||
CloudObjectEventEntrypoint::ImportModal,
|
||||
false,
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
if is_local {
|
||||
let local_object_id = workflows
|
||||
.first()
|
||||
.map(|(_, client_id)| SyncId::ClientId(*client_id).uid());
|
||||
LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
|
||||
for (client_id, workflow_enum) in workflow_enums {
|
||||
repository.create_workflow_enum_with_id(
|
||||
SyncId::ClientId(client_id),
|
||||
workflow_enum,
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
for (workflow, client_id) in workflows {
|
||||
repository.create_workflow_with_id(
|
||||
SyncId::ClientId(client_id),
|
||||
parent_id,
|
||||
workflow,
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
});
|
||||
ctx.emit(ImportQueueEvent::FileCompleted {
|
||||
file_id,
|
||||
server_id: local_object_id,
|
||||
});
|
||||
} else {
|
||||
UpdateManager::handle(ctx).update(ctx, |update_manager, ctx| {
|
||||
// Create any new workflow enums
|
||||
for (client_id, workflow_enum) in workflow_enums {
|
||||
update_manager.create_workflow_enum(
|
||||
workflow_enum,
|
||||
dequeued_item.owner,
|
||||
client_id,
|
||||
CloudObjectEventEntrypoint::ImportModal,
|
||||
false,
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
|
||||
// Create the workflow
|
||||
for (workflow, client_id) in workflows {
|
||||
update_manager.create_workflow(
|
||||
workflow,
|
||||
dequeued_item.owner,
|
||||
parent_id,
|
||||
client_id,
|
||||
CloudObjectEventEntrypoint::ImportModal,
|
||||
false,
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
});
|
||||
ctx.emit(ImportQueueEvent::FileSavedLocally(file_id));
|
||||
// Create the workflow
|
||||
for (workflow, client_id) in workflows {
|
||||
update_manager.create_workflow(
|
||||
workflow,
|
||||
dequeued_item.owner,
|
||||
parent_id,
|
||||
client_id,
|
||||
CloudObjectEventEntrypoint::ImportModal,
|
||||
false,
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
});
|
||||
ctx.emit(ImportQueueEvent::FileSavedLocally(file_id));
|
||||
}
|
||||
}
|
||||
}
|
||||
self.dequeue(ctx);
|
||||
@@ -294,14 +378,14 @@ impl ImportQueue {
|
||||
|
||||
let Some(folder_id) = cloud_model
|
||||
.get_folder_by_uid(&result.server_id.expect("Expect id").uid())
|
||||
.and_then(|folder| folder.id.into_server())
|
||||
.map(|folder| folder.id)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
let replaced = match self.client_to_server_id.get_mut(&client_id) {
|
||||
let replaced = match self.client_to_folder_id.get_mut(&client_id) {
|
||||
Some(value) if value.is_none() => {
|
||||
*value = Some(folder_id.into());
|
||||
*value = Some(folder_id);
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
@@ -323,3 +407,7 @@ impl ImportQueue {
|
||||
impl Entity for ImportQueue {
|
||||
type Event = ImportQueueEvent;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "queue_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::rc::Rc;
|
||||
|
||||
use galaxyui::{App, SingletonEntity};
|
||||
|
||||
use super::*;
|
||||
use crate::cloud_object::model::persistence::CloudModel;
|
||||
use crate::local_object_repository::local_owner;
|
||||
use crate::server::ids::ClientId;
|
||||
use crate::workflows::workflow_enum::{EnumVariants, WorkflowEnum};
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum EventKind {
|
||||
Folder {
|
||||
folder_id: nodes::FolderId,
|
||||
object_id: Option<String>,
|
||||
},
|
||||
File {
|
||||
file_id: FileId,
|
||||
object_id: Option<String>,
|
||||
},
|
||||
FileSavedLocally(FileId),
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_import_queue_persists_nested_content_and_reports_completion() {
|
||||
App::test((), |mut app| async move {
|
||||
app.add_singleton_model(|_| CloudModel::new(None, Vec::new(), None));
|
||||
app.add_singleton_model(|ctx| {
|
||||
crate::local_object_repository::LocalObjectRepository::new(None, None, ctx)
|
||||
});
|
||||
|
||||
let queue = app.add_model(|_| ImportQueue::new());
|
||||
let events = Rc::new(RefCell::new(Vec::new()));
|
||||
let events_for_subscription = events.clone();
|
||||
app.update(|ctx| {
|
||||
ctx.subscribe_to_model(&queue, move |_, event: &ImportQueueEvent, _| {
|
||||
let event = match event {
|
||||
ImportQueueEvent::FolderCompleted {
|
||||
folder_id,
|
||||
server_id,
|
||||
} => EventKind::Folder {
|
||||
folder_id: *folder_id,
|
||||
object_id: server_id.clone(),
|
||||
},
|
||||
ImportQueueEvent::FileCompleted { file_id, server_id } => EventKind::File {
|
||||
file_id: *file_id,
|
||||
object_id: server_id.clone(),
|
||||
},
|
||||
ImportQueueEvent::FileSavedLocally(file_id) => {
|
||||
EventKind::FileSavedLocally(*file_id)
|
||||
}
|
||||
};
|
||||
events_for_subscription.borrow_mut().push(event);
|
||||
});
|
||||
});
|
||||
|
||||
let parent_client_id = ClientId::new();
|
||||
let child_client_id = ClientId::new();
|
||||
let notebook_client_id = ClientId::new();
|
||||
let workflow_client_id = ClientId::new();
|
||||
let workflow_enum_client_id = ClientId::new();
|
||||
|
||||
queue.update(&mut app, |queue, ctx| {
|
||||
queue.enqueue(
|
||||
ImportQueueArgs {
|
||||
owner: local_owner(),
|
||||
parent_id: ParentId::InitialFolder(None),
|
||||
content: RequestContent::Folder {
|
||||
name: "Imported".to_string(),
|
||||
client_id: parent_client_id,
|
||||
folder_id: nodes::FolderId::from(1),
|
||||
},
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
queue.enqueue(
|
||||
ImportQueueArgs {
|
||||
owner: local_owner(),
|
||||
parent_id: ParentId::FolderToUpload(parent_client_id),
|
||||
content: RequestContent::Folder {
|
||||
name: "Nested".to_string(),
|
||||
client_id: child_client_id,
|
||||
folder_id: nodes::FolderId::from(2),
|
||||
},
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
queue.enqueue(
|
||||
ImportQueueArgs {
|
||||
owner: local_owner(),
|
||||
parent_id: ParentId::FolderToUpload(child_client_id),
|
||||
content: RequestContent::Notebook {
|
||||
title: "Imported notes".to_string(),
|
||||
data: "hello".to_string(),
|
||||
client_id: notebook_client_id,
|
||||
file_id: FileId(0),
|
||||
},
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
queue.enqueue(
|
||||
ImportQueueArgs {
|
||||
owner: local_owner(),
|
||||
parent_id: ParentId::FolderToUpload(child_client_id),
|
||||
content: RequestContent::Workflow {
|
||||
workflows: vec![(
|
||||
crate::workflows::workflow::Workflow::new(
|
||||
"Imported workflow",
|
||||
"echo imported",
|
||||
),
|
||||
workflow_client_id,
|
||||
)],
|
||||
workflow_enums: HashMap::from([(
|
||||
workflow_enum_client_id,
|
||||
WorkflowEnum {
|
||||
name: "Environment".to_string(),
|
||||
is_shared: false,
|
||||
variants: EnumVariants::Static(vec!["dev".to_string()]),
|
||||
},
|
||||
)]),
|
||||
file_id: FileId(1),
|
||||
},
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
let parent_id = SyncId::ClientId(parent_client_id);
|
||||
let child_id = SyncId::ClientId(child_client_id);
|
||||
let notebook_id = SyncId::ClientId(notebook_client_id);
|
||||
let workflow_id = SyncId::ClientId(workflow_client_id);
|
||||
let workflow_enum_id = SyncId::ClientId(workflow_enum_client_id);
|
||||
|
||||
app.update(|ctx| {
|
||||
let cloud_model = CloudModel::as_ref(ctx);
|
||||
let parent = cloud_model.get_folder(&parent_id).expect("parent folder");
|
||||
assert_eq!(parent.permissions.owner, local_owner());
|
||||
assert_eq!(
|
||||
cloud_model
|
||||
.get_folder(&child_id)
|
||||
.unwrap()
|
||||
.metadata
|
||||
.folder_id,
|
||||
Some(parent_id)
|
||||
);
|
||||
assert_eq!(
|
||||
cloud_model
|
||||
.get_notebook(¬ebook_id)
|
||||
.unwrap()
|
||||
.metadata
|
||||
.folder_id,
|
||||
Some(child_id)
|
||||
);
|
||||
assert_eq!(
|
||||
cloud_model
|
||||
.get_workflow(&workflow_id)
|
||||
.unwrap()
|
||||
.metadata
|
||||
.folder_id,
|
||||
Some(child_id)
|
||||
);
|
||||
assert_eq!(
|
||||
cloud_model
|
||||
.get_workflow_enum(&workflow_enum_id)
|
||||
.unwrap()
|
||||
.model()
|
||||
.string_model
|
||||
.name,
|
||||
"Environment"
|
||||
);
|
||||
});
|
||||
|
||||
let events = events.borrow();
|
||||
assert!(events.contains(&EventKind::Folder {
|
||||
folder_id: nodes::FolderId::from(1),
|
||||
object_id: Some(parent_id.uid()),
|
||||
}));
|
||||
assert!(events.contains(&EventKind::Folder {
|
||||
folder_id: nodes::FolderId::from(2),
|
||||
object_id: Some(child_id.uid()),
|
||||
}));
|
||||
assert!(events.contains(&EventKind::File {
|
||||
file_id: FileId(0),
|
||||
object_id: Some(notebook_id.uid()),
|
||||
}));
|
||||
assert!(events.contains(&EventKind::File {
|
||||
file_id: FileId(1),
|
||||
object_id: Some(workflow_id.uid()),
|
||||
}));
|
||||
});
|
||||
}
|
||||
+100
-21
@@ -68,7 +68,7 @@ use crate::drive::panel::DrivePanelAction;
|
||||
use crate::editor::{EditorView, Event as EditorEvent, SingleLineEditorOptions};
|
||||
use crate::env_vars::CloudEnvVarCollection;
|
||||
use crate::features::FeatureFlag;
|
||||
use crate::local_object_repository::LocalObjectRepository;
|
||||
use crate::local_object_repository::{local_owner, LocalObjectRepository};
|
||||
use crate::menu::{Event, Menu, MenuItem, MenuItemFields};
|
||||
use crate::network::NetworkStatus;
|
||||
use crate::notebooks::CloudNotebookModel;
|
||||
@@ -1064,6 +1064,12 @@ impl DriveIndex {
|
||||
NetworkStatus::as_ref(app).is_online()
|
||||
}
|
||||
|
||||
fn is_local_folder(folder_id: &SyncId, app: &AppContext) -> bool {
|
||||
CloudModel::as_ref(app)
|
||||
.get_folder(folder_id)
|
||||
.is_some_and(|folder| folder.permissions.owner == local_owner())
|
||||
}
|
||||
|
||||
pub fn scroll_item_into_view(&mut self, item_id: WarpDriveItemId, ctx: &mut ViewContext<Self>) {
|
||||
self.clipped_scroll_state.scroll_to_position(ScrollTarget {
|
||||
position_id: item_id.drive_row_position_id(),
|
||||
@@ -3330,9 +3336,15 @@ impl DriveIndex {
|
||||
match new_location {
|
||||
CloudObjectLocation::Space(space) => self.open_section_of_space(space),
|
||||
CloudObjectLocation::Folder(folder_id) => {
|
||||
cloud_model.update(ctx, |cloud_model, ctx| {
|
||||
cloud_model.open_folder(folder_id, ctx);
|
||||
});
|
||||
if Self::is_local_folder(&folder_id, ctx) {
|
||||
LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
|
||||
repository.open_folder(folder_id, ctx);
|
||||
});
|
||||
} else {
|
||||
cloud_model.update(ctx, |cloud_model, ctx| {
|
||||
cloud_model.open_folder(folder_id, ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
// If location is the trash, then the above move_[object]_to_location call already trashed the object
|
||||
CloudObjectLocation::Trash => {}
|
||||
@@ -3508,9 +3520,15 @@ impl DriveIndex {
|
||||
if !new_name.is_empty() {
|
||||
self.reset_menus(ctx);
|
||||
|
||||
UpdateManager::handle(ctx).update(ctx, move |update_manager, ctx| {
|
||||
update_manager.rename_folder(folder_id, new_name, ctx);
|
||||
});
|
||||
if Self::is_local_folder(&folder_id, ctx) {
|
||||
LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
|
||||
repository.update_folder_name(folder_id, new_name, ctx);
|
||||
});
|
||||
} else {
|
||||
UpdateManager::handle(ctx).update(ctx, move |update_manager, ctx| {
|
||||
update_manager.rename_folder(folder_id, new_name, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
self.cloud_object_naming_dialog.close(ctx);
|
||||
ctx.notify();
|
||||
@@ -3542,6 +3560,11 @@ impl DriveIndex {
|
||||
repository.set_env_var_collection_trashed(id, true, ctx);
|
||||
});
|
||||
}
|
||||
CloudObjectTypeAndId::Folder(id) if Self::is_local_folder(&id, ctx) => {
|
||||
LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
|
||||
repository.set_folder_trashed(id, true, ctx);
|
||||
});
|
||||
}
|
||||
CloudObjectTypeAndId::Folder(_) | CloudObjectTypeAndId::GenericStringObject { .. } => {
|
||||
UpdateManager::handle(ctx).update(ctx, move |update_manager, ctx| {
|
||||
update_manager.trash_object(cloud_object_type_and_id, ctx);
|
||||
@@ -3585,6 +3608,14 @@ impl DriveIndex {
|
||||
ctx.notify();
|
||||
return;
|
||||
}
|
||||
CloudObjectTypeAndId::Folder(id) if Self::is_local_folder(id, ctx) => {
|
||||
LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
|
||||
repository.set_folder_trashed(*id, false, ctx);
|
||||
});
|
||||
self.reset_menus(ctx);
|
||||
ctx.notify();
|
||||
return;
|
||||
}
|
||||
CloudObjectTypeAndId::Folder(_) | CloudObjectTypeAndId::GenericStringObject { .. } => {}
|
||||
}
|
||||
|
||||
@@ -3736,6 +3767,11 @@ impl DriveIndex {
|
||||
repository.delete_env_var_collection(*id, ctx);
|
||||
});
|
||||
}
|
||||
CloudObjectTypeAndId::Folder(id) if Self::is_local_folder(id, ctx) => {
|
||||
LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
|
||||
repository.delete_folder(*id, ctx);
|
||||
});
|
||||
}
|
||||
CloudObjectTypeAndId::Folder(_) | CloudObjectTypeAndId::GenericStringObject { .. } => {
|
||||
UpdateManager::handle(ctx).update(ctx, move |update_manager, ctx| {
|
||||
update_manager.delete_object_by_user(*cloud_object_type_and_id, ctx);
|
||||
@@ -5088,14 +5124,35 @@ impl DriveIndex {
|
||||
}
|
||||
}
|
||||
CloudObjectTypeAndId::Folder(id) => {
|
||||
CloudModel::handle(ctx).update(ctx, |cloud_model, ctx| match key {
|
||||
DriveIndexAction::EnterKey => {
|
||||
cloud_model.toggle_folder_open(*id, ctx);
|
||||
}
|
||||
DriveIndexAction::LeftArrowKey => cloud_model.close_folder(*id, ctx),
|
||||
DriveIndexAction::RightArrowKey => cloud_model.open_folder(*id, ctx),
|
||||
_ => {}
|
||||
});
|
||||
if Self::is_local_folder(id, ctx) {
|
||||
LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
|
||||
match key {
|
||||
DriveIndexAction::EnterKey => {
|
||||
repository.toggle_folder_open(*id, ctx);
|
||||
}
|
||||
DriveIndexAction::LeftArrowKey => {
|
||||
repository.close_folder(*id, ctx)
|
||||
}
|
||||
DriveIndexAction::RightArrowKey => {
|
||||
repository.open_folder(*id, ctx)
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
CloudModel::handle(ctx).update(ctx, |cloud_model, ctx| match key {
|
||||
DriveIndexAction::EnterKey => {
|
||||
cloud_model.toggle_folder_open(*id, ctx);
|
||||
}
|
||||
DriveIndexAction::LeftArrowKey => {
|
||||
cloud_model.close_folder(*id, ctx)
|
||||
}
|
||||
DriveIndexAction::RightArrowKey => {
|
||||
cloud_model.open_folder(*id, ctx)
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
}
|
||||
}
|
||||
CloudObjectTypeAndId::GenericStringObject { object_type, id: _ } => {
|
||||
if let GenericStringObjectFormat::Json(JsonObjectType::EnvVarCollection) =
|
||||
@@ -5556,14 +5613,36 @@ impl TypedActionView for DriveIndex {
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
CloudModel::handle(ctx).update(ctx, |cloud_model, ctx| {
|
||||
cloud_model.toggle_folder_open(*id, ctx);
|
||||
});
|
||||
if Self::is_local_folder(id, ctx) {
|
||||
LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
|
||||
repository.toggle_folder_open(*id, ctx);
|
||||
});
|
||||
} else {
|
||||
CloudModel::handle(ctx).update(ctx, |cloud_model, ctx| {
|
||||
cloud_model.toggle_folder_open(*id, ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
DriveIndexAction::CollapseAllInLocation(location) => {
|
||||
CloudModel::handle(ctx).update(ctx, |cloud_model, ctx| {
|
||||
cloud_model.collapse_all_in_location(*location, self.index_variant, ctx);
|
||||
});
|
||||
if let CloudObjectLocation::Folder(folder_id) = location {
|
||||
if Self::is_local_folder(folder_id, ctx) {
|
||||
LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
|
||||
repository.collapse_local_folders_in_location(*location, ctx);
|
||||
});
|
||||
} else {
|
||||
CloudModel::handle(ctx).update(ctx, |cloud_model, ctx| {
|
||||
cloud_model.collapse_all_in_location(
|
||||
*location,
|
||||
self.index_variant,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
CloudModel::handle(ctx).update(ctx, |cloud_model, ctx| {
|
||||
cloud_model.collapse_all_in_location(*location, self.index_variant, ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
DriveIndexAction::TrashObject {
|
||||
cloud_object_type_and_id,
|
||||
|
||||
+22
-11
@@ -178,17 +178,28 @@ impl DrivePanel {
|
||||
} => match Self::new_object_owner(*space, initial_folder_id.as_ref(), ctx) {
|
||||
Some(owner) => {
|
||||
let client_id = ClientId::default();
|
||||
UpdateManager::handle(ctx).update(ctx, |update_manager, ctx| {
|
||||
update_manager.create_folder(
|
||||
title.clone(),
|
||||
owner,
|
||||
client_id,
|
||||
*initial_folder_id,
|
||||
true,
|
||||
InitiatedBy::User,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
if owner == local_owner() {
|
||||
LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
|
||||
repository.create_folder_with_id(
|
||||
SyncId::ClientId(client_id),
|
||||
title.clone(),
|
||||
*initial_folder_id,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
} else {
|
||||
UpdateManager::handle(ctx).update(ctx, |update_manager, ctx| {
|
||||
update_manager.create_folder(
|
||||
title.clone(),
|
||||
owner,
|
||||
client_id,
|
||||
*initial_folder_id,
|
||||
true,
|
||||
InitiatedBy::User,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
None => {
|
||||
log::error!("Cannot identify a folder owner from {space:?}");
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use settings::macros::define_settings_group;
|
||||
use settings::{RespectUserSyncSetting, Setting, SupportedPlatforms, SyncToCloud};
|
||||
|
||||
@@ -37,14 +36,8 @@ define_settings_group!(WarpDriveSettings, settings: [
|
||||
|
||||
impl WarpDriveSettings {
|
||||
/// Returns whether Warp Drive should be considered enabled.
|
||||
/// Returns `false` when the user is anonymous or fully logged out,
|
||||
/// regardless of the user setting.
|
||||
pub fn is_warp_drive_enabled(app: &galaxyui::AppContext) -> bool {
|
||||
use galaxyui::SingletonEntity as _;
|
||||
let is_anonymous_or_logged_out = FeatureFlag::SkipFirebaseAnonymousUser.is_enabled()
|
||||
&& crate::auth::AuthStateProvider::as_ref(app)
|
||||
.get()
|
||||
.is_anonymous_or_logged_out();
|
||||
*Self::as_ref(app).enable_warp_drive && !is_anonymous_or_logged_out
|
||||
*Self::as_ref(app).enable_warp_drive
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,16 +15,18 @@ use crate::auth::UserUid;
|
||||
use crate::cloud_object::model::generic_string_model::GenericStringObjectId;
|
||||
use crate::cloud_object::model::persistence::{CloudModel, CloudModelEvent};
|
||||
use crate::cloud_object::{
|
||||
CloudObject, CloudObjectMetadata, CloudObjectPermissions, CloudObjectStatuses,
|
||||
CloudObjectSyncStatus, GenericCloudObject, GenericStringObjectFormat, JsonObjectType,
|
||||
ObjectIdType, Owner, Revision,
|
||||
CloudObject, CloudObjectLocation, CloudObjectMetadata, CloudObjectPermissions,
|
||||
CloudObjectStatuses, CloudObjectSyncStatus, GenericCloudObject, GenericStringObjectFormat,
|
||||
JsonObjectType, ObjectIdType, Owner, Revision, Space,
|
||||
};
|
||||
use crate::drive::folders::{CloudFolder, CloudFolderModel};
|
||||
use crate::drive::CloudObjectTypeAndId;
|
||||
use crate::env_vars::{CloudEnvVarCollection, CloudEnvVarCollectionModel, EnvVarCollection};
|
||||
use crate::notebooks::{CloudNotebook, CloudNotebookModel};
|
||||
use crate::persistence::ModelEvent;
|
||||
use crate::server::ids::{ClientId, SyncId};
|
||||
use crate::workflows::workflow::Workflow;
|
||||
use crate::workflows::workflow_enum::{CloudWorkflowEnum, CloudWorkflowEnumModel, WorkflowEnum};
|
||||
use crate::workflows::{CloudWorkflow, CloudWorkflowModel};
|
||||
|
||||
const LOCAL_OWNER_ID: &str = "local-galaxy-user";
|
||||
@@ -213,6 +215,140 @@ impl LocalObjectRepository {
|
||||
CloudModel::as_ref(app).get_notebook(id).cloned()
|
||||
}
|
||||
|
||||
pub fn folder(&self, id: &SyncId, app: &AppContext) -> Option<CloudFolder> {
|
||||
CloudModel::as_ref(app).get_folder(id).cloned()
|
||||
}
|
||||
|
||||
pub fn create_folder_with_id(
|
||||
&mut self,
|
||||
id: SyncId,
|
||||
name: String,
|
||||
parent_folder_id: Option<SyncId>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
self.upsert_folder(
|
||||
new_local_folder(id, parent_folder_id, CloudFolderModel::new(&name, false)),
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
|
||||
pub fn update_folder_name(
|
||||
&mut self,
|
||||
id: SyncId,
|
||||
name: String,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> bool {
|
||||
let Some(mut folder) = self.folder(&id, ctx) else {
|
||||
return false;
|
||||
};
|
||||
folder.set_model(CloudFolderModel {
|
||||
name,
|
||||
is_open: folder.model().is_open,
|
||||
is_warp_pack: folder.model().is_warp_pack,
|
||||
});
|
||||
set_locally_saved_metadata(&mut folder.metadata);
|
||||
self.upsert_folder(folder, ctx);
|
||||
true
|
||||
}
|
||||
|
||||
pub fn set_folder_trashed(
|
||||
&mut self,
|
||||
id: SyncId,
|
||||
trashed: bool,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> bool {
|
||||
let Some(mut folder) = self.folder(&id, ctx) else {
|
||||
return false;
|
||||
};
|
||||
folder.metadata.trashed_ts = trashed.then(|| ServerTimestamp::new(Utc::now()));
|
||||
set_locally_saved_metadata(&mut folder.metadata);
|
||||
self.upsert_folder(folder, ctx);
|
||||
true
|
||||
}
|
||||
|
||||
pub fn delete_folder(&mut self, id: SyncId, ctx: &mut ModelContext<Self>) -> bool {
|
||||
if self.folder(&id, ctx).is_none() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let ids = CloudModel::handle(ctx).update(ctx, |cloud_model, ctx| {
|
||||
cloud_model.delete_object_and_descendants(id.uid(), ctx)
|
||||
});
|
||||
if ids.is_empty() {
|
||||
return false;
|
||||
}
|
||||
self.save(ModelEvent::DeleteObjects { ids });
|
||||
true
|
||||
}
|
||||
|
||||
pub fn open_folder(&mut self, id: SyncId, ctx: &mut ModelContext<Self>) {
|
||||
self.set_folder_open_state(id, true, ctx);
|
||||
}
|
||||
|
||||
pub fn close_folder(&mut self, id: SyncId, ctx: &mut ModelContext<Self>) {
|
||||
self.set_folder_open_state(id, false, ctx);
|
||||
}
|
||||
|
||||
pub fn toggle_folder_open(&mut self, id: SyncId, ctx: &mut ModelContext<Self>) {
|
||||
let Some(folder) = self.folder(&id, ctx) else {
|
||||
return;
|
||||
};
|
||||
self.set_folder_open_state(id, !folder.model().is_open, ctx);
|
||||
}
|
||||
|
||||
pub fn collapse_local_folders_in_location(
|
||||
&mut self,
|
||||
location: CloudObjectLocation,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let folder_ids = {
|
||||
let cloud_model = CloudModel::as_ref(ctx);
|
||||
cloud_model
|
||||
.get_all_active_and_inactive_folders()
|
||||
.filter(|folder| folder.permissions.owner == local_owner())
|
||||
.filter(|folder| match location {
|
||||
CloudObjectLocation::Folder(parent_id) => {
|
||||
if folder.id == parent_id {
|
||||
return true;
|
||||
}
|
||||
|
||||
let mut ancestor_id = folder.metadata.folder_id;
|
||||
while let Some(id) = ancestor_id {
|
||||
if id == parent_id {
|
||||
return true;
|
||||
}
|
||||
ancestor_id = cloud_model
|
||||
.get_folder(&id)
|
||||
.and_then(|ancestor| ancestor.metadata.folder_id);
|
||||
}
|
||||
false
|
||||
}
|
||||
CloudObjectLocation::Space(Space::Personal) => true,
|
||||
CloudObjectLocation::Space(Space::Shared)
|
||||
| CloudObjectLocation::Space(Space::Team { .. })
|
||||
| CloudObjectLocation::Trash => false,
|
||||
})
|
||||
.map(|folder| folder.id)
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
for folder_id in folder_ids {
|
||||
self.close_folder(folder_id, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
fn set_folder_open_state(&self, id: SyncId, is_open: bool, ctx: &mut ModelContext<Self>) {
|
||||
let Some(mut folder) = self.folder(&id, ctx) else {
|
||||
return;
|
||||
};
|
||||
folder.set_model(CloudFolderModel {
|
||||
name: folder.model().name.clone(),
|
||||
is_open,
|
||||
is_warp_pack: folder.model().is_warp_pack,
|
||||
});
|
||||
self.upsert_folder(folder, ctx);
|
||||
}
|
||||
|
||||
pub fn create_notebook_with_id(
|
||||
&mut self,
|
||||
id: SyncId,
|
||||
@@ -480,6 +616,23 @@ impl LocalObjectRepository {
|
||||
self.upsert_workflow(new_local_workflow(id, folder_id, workflow), ctx);
|
||||
}
|
||||
|
||||
pub fn create_workflow_enum_with_id(
|
||||
&mut self,
|
||||
id: SyncId,
|
||||
workflow_enum: WorkflowEnum,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
self.upsert_workflow_enum(
|
||||
GenericCloudObject::new(
|
||||
id,
|
||||
CloudWorkflowEnumModel::new(workflow_enum),
|
||||
locally_saved_metadata(None),
|
||||
local_permissions(),
|
||||
),
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
|
||||
pub fn update_workflow(
|
||||
&mut self,
|
||||
id: SyncId,
|
||||
@@ -591,6 +744,13 @@ impl LocalObjectRepository {
|
||||
self.save(ModelEvent::UpsertNotebook { notebook });
|
||||
}
|
||||
|
||||
fn upsert_folder(&self, folder: CloudFolder, ctx: &mut ModelContext<Self>) {
|
||||
CloudModel::handle(ctx).update(ctx, |cloud_model, ctx| {
|
||||
cloud_model.upsert_local_object(folder.clone(), ctx);
|
||||
});
|
||||
self.save(ModelEvent::UpsertFolder { folder });
|
||||
}
|
||||
|
||||
fn upsert_workflow(&self, workflow: CloudWorkflow, ctx: &mut ModelContext<Self>) {
|
||||
CloudModel::handle(ctx).update(ctx, |cloud_model, ctx| {
|
||||
cloud_model.upsert_local_object(workflow.clone(), ctx);
|
||||
@@ -598,6 +758,15 @@ impl LocalObjectRepository {
|
||||
self.save(ModelEvent::UpsertWorkflow { workflow });
|
||||
}
|
||||
|
||||
fn upsert_workflow_enum(&self, workflow_enum: CloudWorkflowEnum, ctx: &mut ModelContext<Self>) {
|
||||
CloudModel::handle(ctx).update(ctx, |cloud_model, ctx| {
|
||||
cloud_model.upsert_local_object(workflow_enum.clone(), ctx);
|
||||
});
|
||||
self.save(ModelEvent::UpsertGenericStringObject {
|
||||
object: Box::new(workflow_enum),
|
||||
});
|
||||
}
|
||||
|
||||
fn delete_local_object(
|
||||
&self,
|
||||
id: SyncId,
|
||||
@@ -658,6 +827,19 @@ pub(crate) fn new_local_notebook(
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn new_local_folder(
|
||||
id: SyncId,
|
||||
folder_id: Option<SyncId>,
|
||||
folder: CloudFolderModel,
|
||||
) -> CloudFolder {
|
||||
CloudFolder::new(
|
||||
id,
|
||||
folder,
|
||||
locally_saved_metadata(folder_id),
|
||||
local_permissions(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn new_local_workflow(
|
||||
id: SyncId,
|
||||
folder_id: Option<SyncId>,
|
||||
|
||||
@@ -172,6 +172,71 @@ fn create_update_and_delete_notebook_are_local_and_persisted() {
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_update_open_trash_and_delete_folder_are_local_and_persisted() {
|
||||
App::test((), |mut app| async move {
|
||||
let receiver = initialize_app(&mut app);
|
||||
let repository = LocalObjectRepository::handle(&app);
|
||||
let parent_id = SyncId::ClientId(ClientId::new());
|
||||
let child_id = SyncId::ClientId(ClientId::new());
|
||||
|
||||
repository.update(&mut app, |repository, ctx| {
|
||||
repository.create_folder_with_id(parent_id, "Projects".to_string(), None, ctx);
|
||||
});
|
||||
let ModelEvent::UpsertFolder { folder } = receiver.recv().unwrap() else {
|
||||
panic!("expected a local folder upsert");
|
||||
};
|
||||
assert_eq!(folder.id, parent_id);
|
||||
assert_eq!(folder.model().name, "Projects");
|
||||
assert!(!folder.model().is_open);
|
||||
|
||||
repository.update(&mut app, |repository, ctx| {
|
||||
repository.create_folder_with_id(child_id, "Rust".to_string(), Some(parent_id), ctx);
|
||||
});
|
||||
assert!(
|
||||
matches!(receiver.recv().unwrap(), ModelEvent::UpsertFolder { folder } if folder.id == child_id && folder.metadata.folder_id == Some(parent_id))
|
||||
);
|
||||
|
||||
assert!(repository.update(&mut app, |repository, ctx| {
|
||||
repository.update_folder_name(parent_id, "Projects 2026".to_string(), ctx)
|
||||
}));
|
||||
assert!(
|
||||
matches!(receiver.recv().unwrap(), ModelEvent::UpsertFolder { folder } if folder.model().name == "Projects 2026")
|
||||
);
|
||||
|
||||
repository.update(&mut app, |repository, ctx| {
|
||||
repository.open_folder(parent_id, ctx);
|
||||
});
|
||||
assert!(
|
||||
matches!(receiver.recv().unwrap(), ModelEvent::UpsertFolder { folder } if folder.model().is_open)
|
||||
);
|
||||
|
||||
assert!(repository.update(&mut app, |repository, ctx| {
|
||||
repository.set_folder_trashed(parent_id, true, ctx)
|
||||
}));
|
||||
assert!(
|
||||
matches!(receiver.recv().unwrap(), ModelEvent::UpsertFolder { folder } if folder.metadata.trashed_ts.is_some())
|
||||
);
|
||||
|
||||
assert!(repository.update(&mut app, |repository, ctx| {
|
||||
repository.set_folder_trashed(parent_id, false, ctx)
|
||||
}));
|
||||
assert!(
|
||||
matches!(receiver.recv().unwrap(), ModelEvent::UpsertFolder { folder } if folder.metadata.trashed_ts.is_none())
|
||||
);
|
||||
|
||||
assert!(repository.update(&mut app, |repository, ctx| {
|
||||
repository.delete_folder(parent_id, ctx)
|
||||
}));
|
||||
assert!(matches!(
|
||||
receiver.recv().unwrap(),
|
||||
ModelEvent::DeleteObjects { ids }
|
||||
if ids.contains(&(parent_id, ObjectIdType::Folder))
|
||||
&& ids.contains(&(child_id, ObjectIdType::Folder))
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_update_and_delete_workflow_are_local_and_persisted() {
|
||||
App::test((), |mut app| async move {
|
||||
|
||||
@@ -926,6 +926,13 @@ pub enum OpenAIProviderKind {
|
||||
OpenAICompatible,
|
||||
/// The ChatGPT subscription backend, authenticated with ChatGPT OAuth.
|
||||
ChatGPTSubscription,
|
||||
/// Anthropic's native Messages API.
|
||||
Anthropic,
|
||||
/// Google's Gemini API.
|
||||
Gemini,
|
||||
/// Google's Gemini models hosted through Vertex AI.
|
||||
#[serde(rename = "vertex_ai", alias = "vertex_a_i")]
|
||||
VertexAI,
|
||||
}
|
||||
|
||||
/// Configuration for a single OpenAI-compatible provider endpoint.
|
||||
@@ -951,6 +958,12 @@ pub struct OpenAIProviderConfig {
|
||||
#[schemars(description = "API key for this endpoint (optional if the proxy handles auth).")]
|
||||
pub api_key: Option<String>,
|
||||
#[serde(default)]
|
||||
#[schemars(description = "Google Cloud project ID for Vertex AI providers.")]
|
||||
pub project_id: Option<String>,
|
||||
#[serde(default)]
|
||||
#[schemars(description = "Google Cloud location for Vertex AI providers.")]
|
||||
pub location: Option<String>,
|
||||
#[serde(default)]
|
||||
#[schemars(description = "Models available from this provider.")]
|
||||
pub models: Vec<OpenAIModelConfig>,
|
||||
}
|
||||
@@ -1021,6 +1034,8 @@ pub(crate) fn default_chatgpt_provider() -> OpenAIProviderConfig {
|
||||
name: "ChatGPT Subscription".to_string(),
|
||||
base_url: String::new(),
|
||||
api_key: None,
|
||||
project_id: None,
|
||||
location: None,
|
||||
models: default_chatgpt_models(),
|
||||
}
|
||||
}
|
||||
@@ -1035,6 +1050,8 @@ fn default_openai_providers() -> Vec<OpenAIProviderConfig> {
|
||||
// Credentials are deliberately never committed. Set this locally in
|
||||
// ~/.galaxy/settings.toml before sending a request.
|
||||
api_key: None,
|
||||
project_id: None,
|
||||
location: None,
|
||||
models: vec![OpenAIModelConfig {
|
||||
model_id: INITIAL_RIG_MODEL_ID.to_string(),
|
||||
display_name: "Codex GPT-5.6 SOL (xhigh)".to_string(),
|
||||
|
||||
@@ -423,6 +423,34 @@ fn initial_litellm_provider_maps_codex_model_to_rig_without_a_committed_key() {
|
||||
assert!(instant.reasoning_efforts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_provider_settings_roundtrip_with_vertex_configuration() {
|
||||
let provider: OpenAIProviderConfig = serde_json::from_value(serde_json::json!({
|
||||
"kind": "vertex_ai",
|
||||
"enabled": true,
|
||||
"name": "Vertex production",
|
||||
"base_url": "",
|
||||
"project_id": "galaxy-project",
|
||||
"location": "us-central1",
|
||||
"models": []
|
||||
}))
|
||||
.expect("Vertex provider settings should deserialize");
|
||||
|
||||
assert_eq!(provider.kind, OpenAIProviderKind::VertexAI);
|
||||
assert_eq!(provider.project_id.as_deref(), Some("galaxy-project"));
|
||||
assert_eq!(provider.location.as_deref(), Some("us-central1"));
|
||||
|
||||
let legacy: OpenAIProviderConfig = serde_json::from_value(serde_json::json!({
|
||||
"name": "Legacy provider",
|
||||
"base_url": "http://localhost:4000/v1",
|
||||
"models": []
|
||||
}))
|
||||
.expect("Legacy provider settings should remain compatible");
|
||||
assert_eq!(legacy.kind, OpenAIProviderKind::OpenAICompatible);
|
||||
assert_eq!(legacy.project_id, None);
|
||||
assert_eq!(legacy.location, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_litellm_model_infers_missing_system_message_capability() {
|
||||
let mut model = default_openai_providers().remove(0).models.remove(0);
|
||||
|
||||
@@ -7564,13 +7564,13 @@ impl SettingsWidget for ModelsOverviewWidget {
|
||||
.with_spacing(8.)
|
||||
.with_child(build_sub_header(appearance, "Models", None).finish())
|
||||
.with_child(render_ai_setting_description(
|
||||
"Configure Galaxy's direct model providers and agent runtimes in one place. OpenAI-compatible endpoints and Bedrock models run through Rig. ACP coding agents use the same Galaxy runtime boundary while retaining their own model, login, session, and tool loop.",
|
||||
"Configure Galaxy's direct model providers and agent runtimes in one place. OpenAI-compatible, Anthropic, Gemini, Vertex AI, and Bedrock models run through Rig. ACP coding agents use the same Galaxy runtime boundary while retaining their own model, login, session, and tool loop.",
|
||||
true,
|
||||
app,
|
||||
))
|
||||
.with_child(render_ai_setting_description(
|
||||
format!(
|
||||
"{endpoint_count} OpenAI-compatible provider(s) with {endpoint_model_count} model(s); {bedrock_model_count} Bedrock model(s); {agent_runtime_count} enabled agent runtime(s)."
|
||||
"{endpoint_count} configured provider(s) with {endpoint_model_count} model(s); {bedrock_model_count} Bedrock model(s); {agent_runtime_count} enabled agent runtime(s)."
|
||||
),
|
||||
true,
|
||||
app,
|
||||
@@ -7680,6 +7680,9 @@ impl OpenAIProviderSettingsWidget {
|
||||
match provider.kind {
|
||||
OpenAIProviderKind::OpenAICompatible => "OpenAI-compatible API",
|
||||
OpenAIProviderKind::ChatGPTSubscription => "ChatGPT subscription",
|
||||
OpenAIProviderKind::Anthropic => "Anthropic",
|
||||
OpenAIProviderKind::Gemini => "Google Gemini",
|
||||
OpenAIProviderKind::VertexAI => "Google Vertex AI",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7905,33 +7908,37 @@ impl SettingsWidget for OpenAIProviderSettingsWidget {
|
||||
app,
|
||||
));
|
||||
column.add_child(render_ai_setting_description(
|
||||
"Connect a ChatGPT subscription, OpenAI-compatible endpoint, AWS Bedrock account, or ACP agent runtime. Each provider can be enabled independently.",
|
||||
"Connect a ChatGPT subscription, OpenAI-compatible endpoint, Anthropic, Gemini, Vertex AI, AWS Bedrock account, or ACP agent runtime. Each provider can be enabled independently.",
|
||||
true,
|
||||
app,
|
||||
));
|
||||
|
||||
column.add_child(self.render_builtin_provider_card(
|
||||
settings.bedrock_connection_name.value().as_str(),
|
||||
"Use AWS credentials to access Bedrock foundation models directly.",
|
||||
*settings.bedrock_enabled.value(),
|
||||
self.bedrock_enabled_toggle.clone(),
|
||||
AISettingsPageAction::ToggleBedrockEnabled,
|
||||
&self.bedrock_edit_button,
|
||||
&self.bedrock_remove_button,
|
||||
appearance,
|
||||
));
|
||||
if cfg!(unix) && FeatureFlag::AgentClientProtocol.is_enabled() {
|
||||
if !settings.bedrock_models.value().is_empty() {
|
||||
column.add_child(self.render_builtin_provider_card(
|
||||
settings.acp_connection_name.value().as_str(),
|
||||
"Use a local session-oriented agent that owns its model and authentication.",
|
||||
*settings.acp_enabled.value(),
|
||||
self.acp_enabled_toggle.clone(),
|
||||
AISettingsPageAction::ToggleAcpEnabled,
|
||||
&self.acp_edit_button,
|
||||
&self.acp_remove_button,
|
||||
settings.bedrock_connection_name.value().as_str(),
|
||||
"Use AWS credentials to access Bedrock foundation models directly.",
|
||||
*settings.bedrock_enabled.value(),
|
||||
self.bedrock_enabled_toggle.clone(),
|
||||
AISettingsPageAction::ToggleBedrockEnabled,
|
||||
&self.bedrock_edit_button,
|
||||
&self.bedrock_remove_button,
|
||||
appearance,
|
||||
));
|
||||
}
|
||||
if cfg!(unix) && FeatureFlag::AgentClientProtocol.is_enabled() {
|
||||
if *settings.acp_enabled.value() {
|
||||
column.add_child(self.render_builtin_provider_card(
|
||||
settings.acp_connection_name.value().as_str(),
|
||||
"Use a local session-oriented agent that owns its model and authentication.",
|
||||
*settings.acp_enabled.value(),
|
||||
self.acp_enabled_toggle.clone(),
|
||||
AISettingsPageAction::ToggleAcpEnabled,
|
||||
&self.acp_edit_button,
|
||||
&self.acp_remove_button,
|
||||
appearance,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if providers.is_empty() {
|
||||
column.add_child(render_ai_setting_description(
|
||||
|
||||
@@ -1167,9 +1167,6 @@ impl SettingsView {
|
||||
// Warp Drive page
|
||||
let warp_drive_page_handle =
|
||||
ctx.add_typed_action_view(warp_drive_page::WarpDriveSettingsPageView::new);
|
||||
ctx.subscribe_to_view(&warp_drive_page_handle, |me, _, event, ctx| {
|
||||
me.handle_warp_drive_page_event(event, ctx);
|
||||
});
|
||||
|
||||
let platform_page_handle = ctx.add_typed_action_view(platform_page::PlatformPageView::new);
|
||||
ctx.subscribe_to_view(&platform_page_handle, |me, _, event, ctx| {
|
||||
@@ -1738,18 +1735,6 @@ impl SettingsView {
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_warp_drive_page_event(
|
||||
&mut self,
|
||||
event: &warp_drive_page::WarpDriveSettingsPageEvent,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
match event {
|
||||
warp_drive_page::WarpDriveSettingsPageEvent::SignUp => {
|
||||
ctx.emit(SettingsViewEvent::SignupAnonymousUser)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_ai_page_event(&mut self, event: &AISettingsPageEvent, ctx: &mut ViewContext<Self>) {
|
||||
match event {
|
||||
AISettingsPageEvent::FocusModal => ctx.focus(&self.search_editor),
|
||||
|
||||
@@ -46,10 +46,51 @@ enum ProviderSetupStep {
|
||||
pub enum ProviderSetupProviderType {
|
||||
ChatGPTSubscription,
|
||||
OpenAICompatible,
|
||||
Anthropic,
|
||||
Gemini,
|
||||
VertexAI,
|
||||
Bedrock,
|
||||
Acp,
|
||||
}
|
||||
|
||||
const PROVIDER_TYPE_OPTIONS: &[(ProviderSetupProviderType, &str, &str)] = &[
|
||||
(
|
||||
ProviderSetupProviderType::ChatGPTSubscription,
|
||||
"ChatGPT subscription",
|
||||
"Use your ChatGPT Plus or Pro subscription with native OAuth.",
|
||||
),
|
||||
(
|
||||
ProviderSetupProviderType::OpenAICompatible,
|
||||
"OpenAI-compatible API",
|
||||
"Connect LiteLLM, Ollama, vLLM, or another compatible endpoint.",
|
||||
),
|
||||
(
|
||||
ProviderSetupProviderType::Anthropic,
|
||||
"Anthropic",
|
||||
"Connect directly to Anthropic's native Messages API with an API key.",
|
||||
),
|
||||
(
|
||||
ProviderSetupProviderType::Gemini,
|
||||
"Google Gemini",
|
||||
"Connect directly to Google's Gemini API with an API key.",
|
||||
),
|
||||
(
|
||||
ProviderSetupProviderType::VertexAI,
|
||||
"Google Vertex AI",
|
||||
"Use Google Cloud Application Default Credentials for Vertex-hosted Gemini models.",
|
||||
),
|
||||
(
|
||||
ProviderSetupProviderType::Bedrock,
|
||||
"AWS Bedrock",
|
||||
"Use the AWS Bedrock credentials and model configuration already managed by Galaxy.",
|
||||
),
|
||||
(
|
||||
ProviderSetupProviderType::Acp,
|
||||
"ACP agent runtime",
|
||||
"Use a session-oriented ACP agent that owns its model and authentication.",
|
||||
),
|
||||
];
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct BedrockProviderDraft {
|
||||
pub name: String,
|
||||
@@ -114,6 +155,8 @@ pub struct ProviderSetupModalBody {
|
||||
draft_name: String,
|
||||
draft_base_url: String,
|
||||
draft_api_key: Option<String>,
|
||||
draft_project_id: String,
|
||||
draft_location: String,
|
||||
draft_models: Vec<OpenAIModelConfig>,
|
||||
draft_bedrock: BedrockProviderDraft,
|
||||
draft_acp: AcpProviderDraft,
|
||||
@@ -122,6 +165,8 @@ pub struct ProviderSetupModalBody {
|
||||
name_editor: ViewHandle<EditorView>,
|
||||
base_url_editor: ViewHandle<EditorView>,
|
||||
api_key_editor: ViewHandle<EditorView>,
|
||||
project_id_editor: ViewHandle<EditorView>,
|
||||
location_editor: ViewHandle<EditorView>,
|
||||
bedrock_profile_editor: ViewHandle<EditorView>,
|
||||
bedrock_region_editor: ViewHandle<EditorView>,
|
||||
bedrock_refresh_command_editor: ViewHandle<EditorView>,
|
||||
@@ -134,6 +179,7 @@ pub struct ProviderSetupModalBody {
|
||||
bedrock_cross_region_toggle: SwitchStateHandle,
|
||||
bedrock_auto_login_toggle: SwitchStateHandle,
|
||||
model_switches: Vec<SwitchStateHandle>,
|
||||
provider_type_scroll_state: ClippedScrollStateHandle,
|
||||
models_scroll_state: ClippedScrollStateHandle,
|
||||
back_button: ViewHandle<ActionButton>,
|
||||
cancel_button: ViewHandle<ActionButton>,
|
||||
@@ -142,35 +188,28 @@ pub struct ProviderSetupModalBody {
|
||||
|
||||
impl ProviderSetupModalBody {
|
||||
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
|
||||
let provider_type_buttons = [
|
||||
(
|
||||
ProviderSetupProviderType::ChatGPTSubscription,
|
||||
"ChatGPT subscription",
|
||||
),
|
||||
(
|
||||
ProviderSetupProviderType::OpenAICompatible,
|
||||
"OpenAI-compatible API",
|
||||
),
|
||||
(ProviderSetupProviderType::Bedrock, "AWS Bedrock"),
|
||||
(ProviderSetupProviderType::Acp, "ACP agent runtime"),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(kind, label)| {
|
||||
ctx.add_typed_action_view(move |_| {
|
||||
ActionButton::new(label, NakedTheme)
|
||||
.with_full_width(true)
|
||||
.on_click(move |ctx| {
|
||||
ctx.dispatch_typed_action(ProviderSetupModalBodyAction::SelectProvider(
|
||||
kind,
|
||||
));
|
||||
})
|
||||
let provider_type_buttons = PROVIDER_TYPE_OPTIONS
|
||||
.iter()
|
||||
.map(|(kind, label, _)| {
|
||||
let kind = *kind;
|
||||
let label = *label;
|
||||
ctx.add_typed_action_view(move |_| {
|
||||
ActionButton::new(label, NakedTheme)
|
||||
.with_full_width(true)
|
||||
.on_click(move |ctx| {
|
||||
ctx.dispatch_typed_action(
|
||||
ProviderSetupModalBodyAction::SelectProvider(kind),
|
||||
);
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
.collect();
|
||||
|
||||
let name_editor = Self::create_editor("Connection name", false, ctx);
|
||||
let base_url_editor = Self::create_editor("https://api.example.com/v1", false, ctx);
|
||||
let api_key_editor = Self::create_editor("sk-... (optional)", true, ctx);
|
||||
let project_id_editor = Self::create_editor("my-google-cloud-project", false, ctx);
|
||||
let location_editor = Self::create_editor("global", false, ctx);
|
||||
let bedrock_profile_editor = Self::create_editor("default", false, ctx);
|
||||
let bedrock_region_editor = Self::create_editor("us-east-1", false, ctx);
|
||||
let bedrock_refresh_command_editor = Self::create_editor("aws sso login", false, ctx);
|
||||
@@ -218,6 +257,19 @@ impl ProviderSetupModalBody {
|
||||
ctx.notify();
|
||||
}
|
||||
});
|
||||
ctx.subscribe_to_view(&project_id_editor, |me, editor, event, ctx| {
|
||||
if matches!(event, EditorEvent::Edited(_)) {
|
||||
me.draft_project_id = editor.as_ref(ctx).buffer_text(ctx);
|
||||
me.update_next_button(ctx);
|
||||
ctx.notify();
|
||||
}
|
||||
});
|
||||
ctx.subscribe_to_view(&location_editor, |me, editor, event, ctx| {
|
||||
if matches!(event, EditorEvent::Edited(_)) {
|
||||
me.draft_location = editor.as_ref(ctx).buffer_text(ctx);
|
||||
ctx.notify();
|
||||
}
|
||||
});
|
||||
for (editor, update) in [
|
||||
(bedrock_profile_editor.clone(), 0),
|
||||
(bedrock_region_editor.clone(), 1),
|
||||
@@ -286,6 +338,8 @@ impl ProviderSetupModalBody {
|
||||
draft_name: String::new(),
|
||||
draft_base_url: String::new(),
|
||||
draft_api_key: None,
|
||||
draft_project_id: String::new(),
|
||||
draft_location: "global".to_string(),
|
||||
draft_models: Vec::new(),
|
||||
draft_bedrock: BedrockProviderDraft {
|
||||
name: String::new(),
|
||||
@@ -310,6 +364,8 @@ impl ProviderSetupModalBody {
|
||||
name_editor,
|
||||
base_url_editor,
|
||||
api_key_editor,
|
||||
project_id_editor,
|
||||
location_editor,
|
||||
bedrock_profile_editor,
|
||||
bedrock_region_editor,
|
||||
bedrock_refresh_command_editor,
|
||||
@@ -322,6 +378,7 @@ impl ProviderSetupModalBody {
|
||||
bedrock_cross_region_toggle: SwitchStateHandle::default(),
|
||||
bedrock_auto_login_toggle: SwitchStateHandle::default(),
|
||||
model_switches: Vec::new(),
|
||||
provider_type_scroll_state: ClippedScrollStateHandle::default(),
|
||||
models_scroll_state: ClippedScrollStateHandle::default(),
|
||||
back_button,
|
||||
cancel_button,
|
||||
@@ -363,6 +420,8 @@ impl ProviderSetupModalBody {
|
||||
self.draft_name.clear();
|
||||
self.draft_base_url.clear();
|
||||
self.draft_api_key = None;
|
||||
self.draft_project_id.clear();
|
||||
self.draft_location = "global".to_string();
|
||||
self.draft_models.clear();
|
||||
self.draft_bedrock = BedrockProviderDraft {
|
||||
name: String::new(),
|
||||
@@ -405,10 +464,15 @@ impl ProviderSetupModalBody {
|
||||
ProviderSetupProviderType::ChatGPTSubscription
|
||||
}
|
||||
OpenAIProviderKind::OpenAICompatible => ProviderSetupProviderType::OpenAICompatible,
|
||||
OpenAIProviderKind::Anthropic => ProviderSetupProviderType::Anthropic,
|
||||
OpenAIProviderKind::Gemini => ProviderSetupProviderType::Gemini,
|
||||
OpenAIProviderKind::VertexAI => ProviderSetupProviderType::VertexAI,
|
||||
};
|
||||
self.draft_name = provider.name;
|
||||
self.draft_base_url = provider.base_url;
|
||||
self.draft_api_key = provider.api_key;
|
||||
self.draft_project_id = provider.project_id.unwrap_or_default();
|
||||
self.draft_location = provider.location.unwrap_or_else(|| "global".to_string());
|
||||
self.draft_models = provider.models;
|
||||
self.discovery_state = DiscoveryState::Idle;
|
||||
self.sync_editors(ctx);
|
||||
@@ -483,6 +547,12 @@ impl ProviderSetupModalBody {
|
||||
self.api_key_editor.update(ctx, |editor, ctx| {
|
||||
editor.system_reset_buffer_text(self.draft_api_key.as_deref().unwrap_or_default(), ctx);
|
||||
});
|
||||
self.project_id_editor.update(ctx, |editor, ctx| {
|
||||
editor.system_reset_buffer_text(&self.draft_project_id, ctx);
|
||||
});
|
||||
self.location_editor.update(ctx, |editor, ctx| {
|
||||
editor.system_reset_buffer_text(&self.draft_location, ctx);
|
||||
});
|
||||
self.bedrock_profile_editor.update(ctx, |editor, ctx| {
|
||||
editor.system_reset_buffer_text(&self.draft_bedrock.profile, ctx);
|
||||
});
|
||||
@@ -513,15 +583,12 @@ impl ProviderSetupModalBody {
|
||||
}
|
||||
|
||||
fn sync_provider_type_buttons(&self, ctx: &mut ViewContext<Self>) {
|
||||
for (index, button) in self.provider_type_buttons.iter().enumerate() {
|
||||
let button_kind = match index {
|
||||
0 => ProviderSetupProviderType::ChatGPTSubscription,
|
||||
1 => ProviderSetupProviderType::OpenAICompatible,
|
||||
2 => ProviderSetupProviderType::Bedrock,
|
||||
_ => ProviderSetupProviderType::Acp,
|
||||
};
|
||||
for ((button_kind, _, _), button) in PROVIDER_TYPE_OPTIONS
|
||||
.iter()
|
||||
.zip(self.provider_type_buttons.iter())
|
||||
{
|
||||
button.update(ctx, |button, ctx| {
|
||||
button.set_active(button_kind == self.provider_type, ctx);
|
||||
button.set_active(*button_kind == self.provider_type, ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -555,6 +622,12 @@ impl ProviderSetupModalBody {
|
||||
ProviderSetupProviderType::OpenAICompatible => {
|
||||
self.draft_base_url.trim().is_empty()
|
||||
}
|
||||
ProviderSetupProviderType::Anthropic | ProviderSetupProviderType::Gemini => {
|
||||
self.draft_api_key
|
||||
.as_deref()
|
||||
.is_none_or(|key| key.trim().is_empty())
|
||||
}
|
||||
ProviderSetupProviderType::VertexAI => self.draft_project_id.trim().is_empty(),
|
||||
ProviderSetupProviderType::Acp => self.draft_acp.agent_id.trim().is_empty(),
|
||||
ProviderSetupProviderType::ChatGPTSubscription
|
||||
| ProviderSetupProviderType::Bedrock => false,
|
||||
@@ -571,7 +644,10 @@ impl ProviderSetupModalBody {
|
||||
),
|
||||
ProviderSetupStep::Models => match self.provider_type {
|
||||
ProviderSetupProviderType::OpenAICompatible
|
||||
| ProviderSetupProviderType::ChatGPTSubscription => (
|
||||
| ProviderSetupProviderType::ChatGPTSubscription
|
||||
| ProviderSetupProviderType::Anthropic
|
||||
| ProviderSetupProviderType::Gemini
|
||||
| ProviderSetupProviderType::VertexAI => (
|
||||
"Save",
|
||||
self.draft_name.trim().is_empty()
|
||||
|| !self.draft_models.iter().any(|model| model.enabled),
|
||||
@@ -601,19 +677,48 @@ impl ProviderSetupModalBody {
|
||||
ProviderSetupProviderType::OpenAICompatible
|
||||
| ProviderSetupProviderType::Bedrock
|
||||
| ProviderSetupProviderType::Acp => OpenAIProviderKind::OpenAICompatible,
|
||||
ProviderSetupProviderType::Anthropic => OpenAIProviderKind::Anthropic,
|
||||
ProviderSetupProviderType::Gemini => OpenAIProviderKind::Gemini,
|
||||
ProviderSetupProviderType::VertexAI => OpenAIProviderKind::VertexAI,
|
||||
},
|
||||
enabled: true,
|
||||
name: self.draft_name.trim().to_string(),
|
||||
base_url: if self.provider_type == ProviderSetupProviderType::ChatGPTSubscription {
|
||||
base_url: if matches!(
|
||||
self.provider_type,
|
||||
ProviderSetupProviderType::ChatGPTSubscription
|
||||
| ProviderSetupProviderType::Anthropic
|
||||
| ProviderSetupProviderType::Gemini
|
||||
| ProviderSetupProviderType::VertexAI
|
||||
) {
|
||||
String::new()
|
||||
} else {
|
||||
self.draft_base_url.trim().trim_end_matches('/').to_string()
|
||||
},
|
||||
api_key: self
|
||||
.draft_api_key
|
||||
.as_deref()
|
||||
.filter(|key| !key.trim().is_empty())
|
||||
.map(str::to_string),
|
||||
api_key: matches!(
|
||||
self.provider_type,
|
||||
ProviderSetupProviderType::OpenAICompatible
|
||||
| ProviderSetupProviderType::Anthropic
|
||||
| ProviderSetupProviderType::Gemini
|
||||
)
|
||||
.then(|| {
|
||||
self.draft_api_key
|
||||
.as_deref()
|
||||
.filter(|key| !key.trim().is_empty())
|
||||
.map(str::to_string)
|
||||
})
|
||||
.flatten(),
|
||||
project_id: matches!(self.provider_type, ProviderSetupProviderType::VertexAI)
|
||||
.then(|| self.draft_project_id.trim().to_string()),
|
||||
location: matches!(self.provider_type, ProviderSetupProviderType::VertexAI).then(
|
||||
|| {
|
||||
let location = self.draft_location.trim();
|
||||
if location.is_empty() {
|
||||
"global".to_string()
|
||||
} else {
|
||||
location.to_string()
|
||||
}
|
||||
},
|
||||
),
|
||||
models: self.draft_models.clone(),
|
||||
}
|
||||
}
|
||||
@@ -668,7 +773,10 @@ impl ProviderSetupModalBody {
|
||||
));
|
||||
return;
|
||||
}
|
||||
ProviderSetupProviderType::OpenAICompatible => {}
|
||||
ProviderSetupProviderType::OpenAICompatible
|
||||
| ProviderSetupProviderType::Anthropic
|
||||
| ProviderSetupProviderType::Gemini
|
||||
| ProviderSetupProviderType::VertexAI => {}
|
||||
}
|
||||
|
||||
let provider = self.draft_provider();
|
||||
@@ -776,51 +884,53 @@ impl ProviderSetupModalBody {
|
||||
.with_color(appearance.theme().nonactive_ui_text_color().into())
|
||||
.finish();
|
||||
|
||||
let cards = [
|
||||
(
|
||||
"ChatGPT subscription",
|
||||
"Use your ChatGPT Plus or Pro subscription with native OAuth.",
|
||||
),
|
||||
(
|
||||
"OpenAI-compatible API",
|
||||
"Connect LiteLLM, Ollama, vLLM, or another compatible endpoint.",
|
||||
),
|
||||
(
|
||||
"AWS Bedrock",
|
||||
"Use the AWS Bedrock credentials and model configuration already managed by Galaxy.",
|
||||
),
|
||||
(
|
||||
"ACP agent runtime",
|
||||
"Use a session-oriented ACP agent that owns its model and authentication.",
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, (label, description))| {
|
||||
let button = ChildView::new(&self.provider_type_buttons[index]).finish();
|
||||
Container::new(
|
||||
Flex::column()
|
||||
.with_spacing(8.)
|
||||
.with_child(button)
|
||||
.with_child(
|
||||
Text::new(description, appearance.ui_font_family(), INPUT_FONT_SIZE)
|
||||
.with_color(appearance.theme().nonactive_ui_text_color().into())
|
||||
.soft_wrap(true)
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_padding(Padding::uniform(12.))
|
||||
.with_border(Border::all(1.).with_border_fill(appearance.theme().outline()))
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.)))
|
||||
.finish()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let cards = PROVIDER_TYPE_OPTIONS
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, (_, _, description))| {
|
||||
let button = ChildView::new(&self.provider_type_buttons[index]).finish();
|
||||
Container::new(
|
||||
Flex::column()
|
||||
.with_spacing(8.)
|
||||
.with_child(button)
|
||||
.with_child(
|
||||
Text::new(*description, appearance.ui_font_family(), INPUT_FONT_SIZE)
|
||||
.with_color(appearance.theme().nonactive_ui_text_color().into())
|
||||
.soft_wrap(true)
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_padding(Padding::uniform(12.))
|
||||
.with_border(Border::all(1.).with_border_fill(appearance.theme().outline()))
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.)))
|
||||
.finish()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let list = Flex::column()
|
||||
.with_spacing(10.)
|
||||
.with_children(cards)
|
||||
.finish();
|
||||
let scrollable = ClippedScrollable::vertical(
|
||||
self.provider_type_scroll_state.clone(),
|
||||
list,
|
||||
ScrollbarWidth::Auto,
|
||||
appearance.theme().nonactive_ui_detail().into(),
|
||||
appearance.theme().active_ui_detail().into(),
|
||||
appearance.theme().surface_1().into(),
|
||||
)
|
||||
.with_overlayed_scrollbar()
|
||||
.finish();
|
||||
|
||||
Flex::column()
|
||||
.with_spacing(16.)
|
||||
.with_child(description)
|
||||
.with_children(cards)
|
||||
.with_child(
|
||||
ConstrainedBox::new(scrollable)
|
||||
.with_max_height(360.)
|
||||
.finish(),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
|
||||
@@ -989,6 +1099,50 @@ impl ProviderSetupModalBody {
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
ProviderSetupProviderType::Anthropic => {
|
||||
children.push(self.render_input(appearance, "API key", &self.api_key_editor));
|
||||
children.push(
|
||||
Text::new(
|
||||
"The key is stored locally and is never synced to the cloud. Models will be discovered from Anthropic after the connection test.",
|
||||
appearance.ui_font_family(),
|
||||
INPUT_FONT_SIZE,
|
||||
)
|
||||
.with_color(appearance.theme().nonactive_ui_text_color().into())
|
||||
.soft_wrap(true)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
ProviderSetupProviderType::Gemini => {
|
||||
children.push(self.render_input(appearance, "API key", &self.api_key_editor));
|
||||
children.push(
|
||||
Text::new(
|
||||
"The key is stored locally and is never synced to the cloud. Models will be discovered from Google's Gemini API after the connection test.",
|
||||
appearance.ui_font_family(),
|
||||
INPUT_FONT_SIZE,
|
||||
)
|
||||
.with_color(appearance.theme().nonactive_ui_text_color().into())
|
||||
.soft_wrap(true)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
ProviderSetupProviderType::VertexAI => {
|
||||
children.push(self.render_input(
|
||||
appearance,
|
||||
"Google Cloud project ID",
|
||||
&self.project_id_editor,
|
||||
));
|
||||
children.push(self.render_input(appearance, "Location", &self.location_editor));
|
||||
children.push(
|
||||
Text::new(
|
||||
"Vertex AI uses Google Application Default Credentials. Run `gcloud auth application-default login` before testing the connection.",
|
||||
appearance.ui_font_family(),
|
||||
INPUT_FONT_SIZE,
|
||||
)
|
||||
.with_color(appearance.theme().nonactive_ui_text_color().into())
|
||||
.soft_wrap(true)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
ProviderSetupProviderType::Bedrock => {
|
||||
children.push(Self::render_label(appearance, "Authentication method"));
|
||||
children.push(
|
||||
@@ -1422,7 +1576,10 @@ impl TypedActionView for ProviderSetupModalBody {
|
||||
}
|
||||
ProviderSetupStep::Models => match self.provider_type {
|
||||
ProviderSetupProviderType::OpenAICompatible
|
||||
| ProviderSetupProviderType::ChatGPTSubscription => {
|
||||
| ProviderSetupProviderType::ChatGPTSubscription
|
||||
| ProviderSetupProviderType::Anthropic
|
||||
| ProviderSetupProviderType::Gemini
|
||||
| ProviderSetupProviderType::VertexAI => {
|
||||
if self.draft_name.trim().is_empty()
|
||||
|| !self.draft_models.iter().any(|model| model.enabled)
|
||||
{
|
||||
@@ -1529,6 +1686,9 @@ fn provider_type_label(kind: ProviderSetupProviderType) -> &'static str {
|
||||
match kind {
|
||||
ProviderSetupProviderType::OpenAICompatible => "OpenAI-compatible API",
|
||||
ProviderSetupProviderType::ChatGPTSubscription => "ChatGPT subscription",
|
||||
ProviderSetupProviderType::Anthropic => "Anthropic",
|
||||
ProviderSetupProviderType::Gemini => "Google Gemini",
|
||||
ProviderSetupProviderType::VertexAI => "Google Vertex AI",
|
||||
ProviderSetupProviderType::Bedrock => "AWS Bedrock",
|
||||
ProviderSetupProviderType::Acp => "ACP agent runtime",
|
||||
}
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxy_core::report_if_error;
|
||||
use galaxy_core::settings::ToggleableSetting as _;
|
||||
use galaxyui::fonts::Weight;
|
||||
use galaxyui::keymap::ContextPredicate;
|
||||
use galaxyui::ui_components::button::ButtonVariant;
|
||||
use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
|
||||
use galaxyui::ui_components::components::UiComponent;
|
||||
use galaxyui::ui_components::switch::SwitchStateHandle;
|
||||
use galaxyui::{
|
||||
id, Action, AppContext, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
|
||||
};
|
||||
use warpui::elements::{
|
||||
Container, Element, Flex, MouseStateHandle, ParentElement, Shrinkable, Text,
|
||||
};
|
||||
use warpui::elements::{Element, MouseStateHandle};
|
||||
|
||||
use super::settings_page::{
|
||||
render_body_item, AdditionalInfo, MatchData, PageType, SettingsPageMeta,
|
||||
@@ -22,13 +18,11 @@ use super::{
|
||||
SettingsAction, SettingsSection, ToggleSettingActionPair, ToggleState,
|
||||
};
|
||||
use crate::appearance::Appearance;
|
||||
use crate::auth::AuthStateProvider;
|
||||
use crate::drive::settings::WarpDriveSettings;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum WarpDriveSettingsPageAction {
|
||||
ToggleShowWarpDrive,
|
||||
SignUp,
|
||||
OpenUrl(String),
|
||||
}
|
||||
|
||||
@@ -44,8 +38,8 @@ pub fn init_actions_from_parent_view<T: Action + Clone>(
|
||||
WarpDriveSettingsPageAction::ToggleShowWarpDrive,
|
||||
)),
|
||||
SettingActionPairContexts::new(
|
||||
context.clone() & !id!(flags::ENABLE_WARP_DRIVE) & !id!("IsAnonymousUser"),
|
||||
context.clone() & id!(flags::ENABLE_WARP_DRIVE) & !id!("IsAnonymousUser"),
|
||||
context.clone() & !id!(flags::ENABLE_WARP_DRIVE),
|
||||
context.clone() & id!(flags::ENABLE_WARP_DRIVE),
|
||||
),
|
||||
None,
|
||||
)
|
||||
@@ -54,10 +48,6 @@ pub fn init_actions_from_parent_view<T: Action + Clone>(
|
||||
);
|
||||
}
|
||||
|
||||
pub enum WarpDriveSettingsPageEvent {
|
||||
SignUp,
|
||||
}
|
||||
|
||||
pub struct WarpDriveSettingsPageView {
|
||||
page: PageType<Self>,
|
||||
}
|
||||
@@ -66,10 +56,7 @@ impl WarpDriveSettingsPageView {
|
||||
pub fn new(_ctx: &mut ViewContext<Self>) -> Self {
|
||||
Self {
|
||||
page: PageType::new_uncategorized(
|
||||
vec![
|
||||
Box::new(WarpDriveHeaderWidget::default()),
|
||||
Box::new(WarpDriveToggleWidget::default()),
|
||||
],
|
||||
vec![Box::new(WarpDriveToggleWidget::default())],
|
||||
None,
|
||||
),
|
||||
}
|
||||
@@ -77,7 +64,7 @@ impl WarpDriveSettingsPageView {
|
||||
}
|
||||
|
||||
impl Entity for WarpDriveSettingsPageView {
|
||||
type Event = WarpDriveSettingsPageEvent;
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl TypedActionView for WarpDriveSettingsPageView {
|
||||
@@ -91,9 +78,6 @@ impl TypedActionView for WarpDriveSettingsPageView {
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
WarpDriveSettingsPageAction::SignUp => {
|
||||
ctx.emit(WarpDriveSettingsPageEvent::SignUp);
|
||||
}
|
||||
WarpDriveSettingsPageAction::OpenUrl(url) => {
|
||||
ctx.open_url(url.as_str());
|
||||
}
|
||||
@@ -139,88 +123,6 @@ impl From<ViewHandle<WarpDriveSettingsPageView>> for SettingsPageViewHandle {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct WarpDriveHeaderWidget {
|
||||
sign_up_button: MouseStateHandle,
|
||||
}
|
||||
|
||||
impl SettingsWidget for WarpDriveHeaderWidget {
|
||||
type View = WarpDriveSettingsPageView;
|
||||
|
||||
fn search_terms(&self) -> &str {
|
||||
"warp drive sign up"
|
||||
}
|
||||
|
||||
fn should_render(&self, app: &AppContext) -> bool {
|
||||
FeatureFlag::SkipFirebaseAnonymousUser.is_enabled()
|
||||
&& AuthStateProvider::as_ref(app)
|
||||
.get()
|
||||
.is_anonymous_or_logged_out()
|
||||
}
|
||||
|
||||
fn render(
|
||||
&self,
|
||||
_view: &Self::View,
|
||||
appearance: &Appearance,
|
||||
_app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let ui_builder = appearance.ui_builder();
|
||||
|
||||
let message = Container::new(
|
||||
Text::new_inline(
|
||||
"To use Galaxy Drive, please create an account.".to_string(),
|
||||
appearance.ui_font_family(),
|
||||
14.,
|
||||
)
|
||||
.with_color(
|
||||
appearance
|
||||
.theme()
|
||||
.sub_text_color(appearance.theme().surface_2())
|
||||
.into_solid(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_right(16.)
|
||||
.finish();
|
||||
|
||||
let button = Container::new(
|
||||
ui_builder
|
||||
.button(ButtonVariant::Accent, self.sign_up_button.clone())
|
||||
.with_style(UiComponentStyles {
|
||||
font_size: Some(14.),
|
||||
font_weight: Some(Weight::Semibold),
|
||||
border_radius: Some(galaxyui::elements::CornerRadius::with_all(
|
||||
galaxyui::elements::Radius::Pixels(4.),
|
||||
)),
|
||||
padding: Some(Coords {
|
||||
top: 8.,
|
||||
bottom: 8.,
|
||||
left: 24.,
|
||||
right: 24.,
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
.with_text_label("Sign up".to_owned())
|
||||
.build()
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(WarpDriveSettingsPageAction::SignUp);
|
||||
})
|
||||
.finish(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
Container::new(
|
||||
Flex::row()
|
||||
.with_cross_axis_alignment(galaxyui::elements::CrossAxisAlignment::Center)
|
||||
.with_child(Shrinkable::new(1., message).finish())
|
||||
.with_child(button)
|
||||
.finish(),
|
||||
)
|
||||
.with_padding_bottom(15.)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct WarpDriveToggleWidget {
|
||||
switch_state: SwitchStateHandle,
|
||||
@@ -241,10 +143,6 @@ impl SettingsWidget for WarpDriveToggleWidget {
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let settings = WarpDriveSettings::as_ref(app);
|
||||
let is_anonymous_or_logged_out = FeatureFlag::SkipFirebaseAnonymousUser.is_enabled()
|
||||
&& AuthStateProvider::as_ref(app)
|
||||
.get()
|
||||
.is_anonymous_or_logged_out();
|
||||
|
||||
render_body_item::<WarpDriveSettingsPageAction>(
|
||||
"Galaxy Drive".into(),
|
||||
@@ -257,24 +155,15 @@ impl SettingsWidget for WarpDriveToggleWidget {
|
||||
tooltip_override_text: None,
|
||||
}),
|
||||
LocalOnlyIconState::Hidden,
|
||||
if is_anonymous_or_logged_out {
|
||||
ToggleState::Disabled
|
||||
} else {
|
||||
ToggleState::Enabled
|
||||
},
|
||||
ToggleState::Enabled,
|
||||
appearance,
|
||||
appearance
|
||||
.ui_builder()
|
||||
.switch(self.switch_state.clone())
|
||||
.check(*settings.enable_warp_drive && !is_anonymous_or_logged_out)
|
||||
.with_disabled(is_anonymous_or_logged_out)
|
||||
.check(*settings.enable_warp_drive)
|
||||
.build()
|
||||
.on_click(move |ctx, _, _| {
|
||||
if !is_anonymous_or_logged_out {
|
||||
ctx.dispatch_typed_action(
|
||||
WarpDriveSettingsPageAction::ToggleShowWarpDrive,
|
||||
);
|
||||
}
|
||||
ctx.dispatch_typed_action(WarpDriveSettingsPageAction::ToggleShowWarpDrive);
|
||||
})
|
||||
.finish(),
|
||||
Some("Galaxy Drive is a workspace in your terminal where you can save Workflows, Notebooks, Prompts, and Environment Variables for personal use or to share with a team.".into()),
|
||||
|
||||
Reference in New Issue
Block a user