Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
use settings::{
|
||||
macros::define_settings_group, RespectUserSyncSetting, SupportedPlatforms, SyncToCloud,
|
||||
};
|
||||
use warpui::accessibility::AccessibilityVerbosity;
|
||||
|
||||
define_settings_group!(AccessibilitySettings, settings: [
|
||||
a11y_verbosity: AccessibilityVerbosityState {
|
||||
type: AccessibilityVerbosity,
|
||||
default: AccessibilityVerbosity::default(),
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
storage_key: "AccessibilityVerbosity",
|
||||
toml_path: "accessibility.accessibility_verbosity",
|
||||
description: "The verbosity level for screen reader announcements.",
|
||||
}
|
||||
]);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,748 @@
|
||||
use super::*;
|
||||
use crate::{
|
||||
ai::request_usage_model::{RequestLimitInfo, RequestLimitRefreshDuration},
|
||||
test_util::settings::initialize_settings_for_tests,
|
||||
};
|
||||
use chrono::Utc;
|
||||
use warp_graphql::scalars::time::ServerTimestamp;
|
||||
use warpui::{App, SingletonEntity};
|
||||
|
||||
fn create_test_request_limit_info(
|
||||
limit: usize,
|
||||
used: usize,
|
||||
next_refresh: DateTime<Utc>,
|
||||
is_unlimited: bool,
|
||||
refresh_duration: RequestLimitRefreshDuration,
|
||||
) -> RequestLimitInfo {
|
||||
RequestLimitInfo {
|
||||
limit,
|
||||
num_requests_used_since_refresh: used,
|
||||
next_refresh_time: ServerTimestamp::new(next_refresh),
|
||||
is_unlimited,
|
||||
request_limit_refresh_duration: refresh_duration,
|
||||
is_unlimited_voice: false,
|
||||
voice_request_limit: 0,
|
||||
voice_requests_used_since_last_refresh: 0,
|
||||
is_unlimited_codebase_indices: false,
|
||||
max_codebase_indices: 0,
|
||||
max_files_per_repo: 5000,
|
||||
embedding_generation_batch_size: 100,
|
||||
}
|
||||
}
|
||||
|
||||
// FocusedTerminalInfo Tests
|
||||
|
||||
#[test]
|
||||
fn test_update_both_values_changed() {
|
||||
App::test((), |mut app| async move {
|
||||
// Create FocusedTerminalInfo with default values (false, false)
|
||||
let model_handle = app.add_model(|_| FocusedTerminalInfo::default());
|
||||
|
||||
// Setup event tracking
|
||||
let (sender, receiver) = async_channel::unbounded();
|
||||
let model_handle_clone = model_handle.clone();
|
||||
model_handle.update(&mut app, move |_, ctx| {
|
||||
let sender = sender.clone();
|
||||
ctx.subscribe_to_model(
|
||||
&model_handle_clone,
|
||||
move |_, event: &FocusedTerminalInfoEvent, _| match event {
|
||||
FocusedTerminalInfoEvent::TerminalInfoUpdated => {
|
||||
let _ = sender.try_send(());
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// Update both values to (true, false)
|
||||
model_handle.update(&mut app, |model, ctx| {
|
||||
model.update(true, false, ctx);
|
||||
});
|
||||
|
||||
// Verify model state
|
||||
model_handle.read(&app, |model, _| {
|
||||
assert!(model.contains_any_remote_blocks());
|
||||
assert!(!model.contains_any_restored_remote_blocks());
|
||||
});
|
||||
|
||||
// Verify event was emitted exactly once
|
||||
let mut count = 0;
|
||||
while receiver.try_recv().is_ok() {
|
||||
count += 1;
|
||||
}
|
||||
assert_eq!(count, 1);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_additional_value_changed() {
|
||||
App::test((), |mut app| async move {
|
||||
// Create FocusedTerminalInfo with default values (false, false)
|
||||
let model_handle = app.add_model(|_| FocusedTerminalInfo::default());
|
||||
|
||||
// Setup event tracking
|
||||
let (sender, receiver) = async_channel::unbounded();
|
||||
let model_handle_clone = model_handle.clone();
|
||||
model_handle.update(&mut app, move |_, ctx| {
|
||||
let sender = sender.clone();
|
||||
ctx.subscribe_to_model(
|
||||
&model_handle_clone,
|
||||
move |_, event: &FocusedTerminalInfoEvent, _| match event {
|
||||
FocusedTerminalInfoEvent::TerminalInfoUpdated => {
|
||||
let _ = sender.try_send(());
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// First update to (true, false)
|
||||
model_handle.update(&mut app, |model, ctx| {
|
||||
model.update(true, false, ctx);
|
||||
});
|
||||
|
||||
// Clear events by draining the channel
|
||||
while receiver.try_recv().is_ok() {}
|
||||
|
||||
// Now update to (true, true) - only changing restored blocks
|
||||
model_handle.update(&mut app, |model, ctx| {
|
||||
model.update(true, true, ctx);
|
||||
});
|
||||
|
||||
// Verify model state
|
||||
model_handle.read(&app, |model, _| {
|
||||
assert!(model.contains_any_remote_blocks());
|
||||
assert!(model.contains_any_restored_remote_blocks());
|
||||
});
|
||||
|
||||
// Verify event was emitted exactly once
|
||||
let mut count = 0;
|
||||
while receiver.try_recv().is_ok() {
|
||||
count += 1;
|
||||
}
|
||||
assert_eq!(count, 1);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_no_change() {
|
||||
App::test((), |mut app| async move {
|
||||
// Create FocusedTerminalInfo with default values (false, false)
|
||||
let model_handle = app.add_model(|_| FocusedTerminalInfo::default());
|
||||
|
||||
// Setup event tracking
|
||||
let (sender, receiver) = async_channel::unbounded();
|
||||
let model_handle_clone = model_handle.clone();
|
||||
model_handle.update(&mut app, move |_, ctx| {
|
||||
let sender = sender.clone();
|
||||
ctx.subscribe_to_model(
|
||||
&model_handle_clone,
|
||||
move |_, event: &FocusedTerminalInfoEvent, _| match event {
|
||||
FocusedTerminalInfoEvent::TerminalInfoUpdated => {
|
||||
let _ = sender.try_send(());
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// First update to (true, true)
|
||||
model_handle.update(&mut app, |model, ctx| {
|
||||
model.update(true, true, ctx);
|
||||
});
|
||||
|
||||
// Clear events by draining the channel
|
||||
while receiver.try_recv().is_ok() {}
|
||||
|
||||
// Update with same values (true, true)
|
||||
model_handle.update(&mut app, |model, ctx| {
|
||||
model.update(true, true, ctx);
|
||||
});
|
||||
|
||||
// Verify model state remains the same
|
||||
model_handle.read(&app, |model, _| {
|
||||
assert!(model.contains_any_remote_blocks());
|
||||
assert!(model.contains_any_restored_remote_blocks());
|
||||
});
|
||||
|
||||
// Verify no event was emitted
|
||||
let mut count = 0;
|
||||
while receiver.try_recv().is_ok() {
|
||||
count += 1;
|
||||
}
|
||||
assert_eq!(count, 0);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_only_remote_toggles() {
|
||||
App::test((), |mut app| async move {
|
||||
// Create FocusedTerminalInfo with default values (false, false)
|
||||
let model_handle = app.add_model(|_| FocusedTerminalInfo::default());
|
||||
|
||||
// Setup event tracking
|
||||
let (sender, receiver) = async_channel::unbounded();
|
||||
let model_handle_clone = model_handle.clone();
|
||||
model_handle.update(&mut app, move |_, ctx| {
|
||||
let sender = sender.clone();
|
||||
ctx.subscribe_to_model(
|
||||
&model_handle_clone,
|
||||
move |_, event: &FocusedTerminalInfoEvent, _| match event {
|
||||
FocusedTerminalInfoEvent::TerminalInfoUpdated => {
|
||||
let _ = sender.try_send(());
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// First update to (true, true)
|
||||
model_handle.update(&mut app, |model, ctx| {
|
||||
model.update(true, true, ctx);
|
||||
});
|
||||
|
||||
// Clear events by draining the channel
|
||||
while receiver.try_recv().is_ok() {}
|
||||
|
||||
// Update with (false, true) - only remote blocks changes
|
||||
model_handle.update(&mut app, |model, ctx| {
|
||||
model.update(false, true, ctx);
|
||||
});
|
||||
|
||||
// Verify model state
|
||||
model_handle.read(&app, |model, _| {
|
||||
assert!(!model.contains_any_remote_blocks());
|
||||
assert!(model.contains_any_restored_remote_blocks());
|
||||
});
|
||||
|
||||
// Verify event was emitted exactly once
|
||||
let mut count = 0;
|
||||
while receiver.try_recv().is_ok() {
|
||||
count += 1;
|
||||
}
|
||||
assert_eq!(count, 1);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_only_restored_toggles() {
|
||||
App::test((), |mut app| async move {
|
||||
// Create FocusedTerminalInfo with default values (false, false)
|
||||
let model_handle = app.add_model(|_| FocusedTerminalInfo::default());
|
||||
|
||||
// Setup event tracking
|
||||
let (sender, receiver) = async_channel::unbounded();
|
||||
let model_handle_clone = model_handle.clone();
|
||||
model_handle.update(&mut app, move |_, ctx| {
|
||||
let sender = sender.clone();
|
||||
ctx.subscribe_to_model(
|
||||
&model_handle_clone,
|
||||
move |_, event: &FocusedTerminalInfoEvent, _| match event {
|
||||
FocusedTerminalInfoEvent::TerminalInfoUpdated => {
|
||||
let _ = sender.try_send(());
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// First update to (true, true)
|
||||
model_handle.update(&mut app, |model, ctx| {
|
||||
model.update(true, true, ctx);
|
||||
});
|
||||
|
||||
// Clear events by draining the channel
|
||||
while receiver.try_recv().is_ok() {}
|
||||
|
||||
// Update with (true, false) - only restored blocks changes
|
||||
model_handle.update(&mut app, |model, ctx| {
|
||||
model.update(true, false, ctx);
|
||||
});
|
||||
|
||||
// Verify model state
|
||||
model_handle.read(&app, |model, _| {
|
||||
assert!(model.contains_any_remote_blocks());
|
||||
assert!(!model.contains_any_restored_remote_blocks());
|
||||
});
|
||||
|
||||
// Verify event was emitted exactly once
|
||||
let mut count = 0;
|
||||
while receiver.try_recv().is_ok() {
|
||||
count += 1;
|
||||
}
|
||||
assert_eq!(count, 1);
|
||||
});
|
||||
}
|
||||
|
||||
// ToolbarCommandMap Tests
|
||||
|
||||
#[test]
|
||||
fn test_toolbar_command_map_deserialize_from_map() {
|
||||
let json = serde_json::json!({
|
||||
"^claude": "Claude",
|
||||
"^gemini": "Gemini",
|
||||
"^codex": ""
|
||||
});
|
||||
let map: ToolbarCommandMap = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(map.0.len(), 3);
|
||||
assert_eq!(map.0["^claude"], "Claude");
|
||||
assert_eq!(map.0["^gemini"], "Gemini");
|
||||
assert_eq!(map.0["^codex"], "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_toolbar_command_map_deserialize_from_legacy_vec() {
|
||||
let json = serde_json::json!(["^claude", "^gemini", "^custom"]);
|
||||
let map: ToolbarCommandMap = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(map.0.len(), 3);
|
||||
// Legacy vec format should assign empty agent values.
|
||||
for (_, agent) in map.0.iter() {
|
||||
assert_eq!(agent, "");
|
||||
}
|
||||
let keys: Vec<_> = map.0.keys().collect();
|
||||
assert_eq!(keys, vec!["^claude", "^gemini", "^custom"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_toolbar_command_map_from_file_value_map_format() {
|
||||
use settings_value::SettingsValue;
|
||||
|
||||
let value = serde_json::json!({
|
||||
"^claude": "Claude",
|
||||
"^amp": "Amp"
|
||||
});
|
||||
let map = ToolbarCommandMap::from_file_value(&value).unwrap();
|
||||
assert_eq!(map.0.len(), 2);
|
||||
assert_eq!(map.0["^claude"], "Claude");
|
||||
assert_eq!(map.0["^amp"], "Amp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_toolbar_command_map_from_file_value_legacy_array() {
|
||||
use settings_value::SettingsValue;
|
||||
|
||||
// Patterns are intentionally non-alphabetical to verify insertion order is preserved.
|
||||
let value = serde_json::json!(["^zebra", "^alpha", "^middle"]);
|
||||
let map = ToolbarCommandMap::from_file_value(&value).unwrap();
|
||||
assert_eq!(map.0.len(), 3);
|
||||
assert_eq!(map.0["^zebra"], "");
|
||||
assert_eq!(map.0["^alpha"], "");
|
||||
assert_eq!(map.0["^middle"], "");
|
||||
let keys: Vec<_> = map.0.keys().collect();
|
||||
assert_eq!(keys, vec!["^zebra", "^alpha", "^middle"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_toolbar_command_map_from_file_value_invalid() {
|
||||
use settings_value::SettingsValue;
|
||||
|
||||
let value = serde_json::json!(42);
|
||||
assert!(ToolbarCommandMap::from_file_value(&value).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_toolbar_command_map_roundtrip() {
|
||||
use settings_value::SettingsValue;
|
||||
|
||||
let mut inner = IndexMap::new();
|
||||
inner.insert("^claude".to_string(), "Claude".to_string());
|
||||
inner.insert("^custom".to_string(), String::new());
|
||||
let original = ToolbarCommandMap::new(inner);
|
||||
|
||||
let file_value = original.to_file_value();
|
||||
let restored = ToolbarCommandMap::from_file_value(&file_value).unwrap();
|
||||
assert_eq!(original, restored);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_toolbar_command_map_matched_agent() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_settings_for_tests(&mut app);
|
||||
|
||||
let mut map = IndexMap::new();
|
||||
map.insert("^claude".to_string(), "Claude".to_string());
|
||||
map.insert("^gemini".to_string(), "Gemini".to_string());
|
||||
map.insert("^custom-tool".to_string(), String::new());
|
||||
|
||||
AISettings::handle(&app).update(&mut app, |settings, ctx| {
|
||||
report_if_error!(settings
|
||||
.cli_agent_footer_enabled_commands
|
||||
.set_value(ToolbarCommandMap::new(map), ctx));
|
||||
});
|
||||
|
||||
app.read(|ctx| {
|
||||
let agent = CompiledCommandsForCodingAgentToolbar::matched_agent(ctx, "claude chat");
|
||||
assert_eq!(agent, Some(CLIAgent::Claude));
|
||||
|
||||
let agent = CompiledCommandsForCodingAgentToolbar::matched_agent(ctx, "gemini ask");
|
||||
assert_eq!(agent, Some(CLIAgent::Gemini));
|
||||
|
||||
let agent =
|
||||
CompiledCommandsForCodingAgentToolbar::matched_agent(ctx, "custom-tool --flag");
|
||||
assert_eq!(agent, Some(CLIAgent::Unknown));
|
||||
|
||||
let agent =
|
||||
CompiledCommandsForCodingAgentToolbar::matched_agent(ctx, "unmatched-command");
|
||||
assert_eq!(agent, None);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_display_quota_reset_banner_with_empty_history() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_settings_for_tests(&mut app);
|
||||
|
||||
AISettings::handle(&app).read(&app, |settings, _ctx| {
|
||||
// With empty history, banner should not be displayed
|
||||
assert!(!settings.should_display_quota_reset_banner());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_display_quota_reset_banner_with_quota_exceeded_not_dismissed() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_settings_for_tests(&mut app);
|
||||
|
||||
// Set up a history with a previous cycle that had quota exceeded and banner not dismissed
|
||||
let now = Utc::now();
|
||||
let previous_end_date = now - chrono::Duration::days(15);
|
||||
let current_end_date = now + chrono::Duration::days(15);
|
||||
|
||||
let previous_cycle = CycleInfo {
|
||||
end_date: previous_end_date,
|
||||
was_quota_exceeded: true,
|
||||
banner_state: BannerState { dismissed: false },
|
||||
};
|
||||
|
||||
let current_cycle = CycleInfo {
|
||||
end_date: current_end_date,
|
||||
was_quota_exceeded: false,
|
||||
banner_state: BannerState::default(),
|
||||
};
|
||||
|
||||
let cycle_history = vec![previous_cycle, current_cycle];
|
||||
|
||||
AISettings::handle(&app).update(&mut app, |settings, ctx| {
|
||||
settings
|
||||
.ai_request_quota_info
|
||||
.set_value(AIRequestQuotaInfo { cycle_history }, ctx)
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
AISettings::handle(&app).read(&app, |settings, _ctx| {
|
||||
// Banner should be displayed when the previous cycle had quota exceeded and banner not dismissed
|
||||
assert!(settings.should_display_quota_reset_banner());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_display_quota_reset_banner_with_quota_exceeded_dismissed() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_settings_for_tests(&mut app);
|
||||
|
||||
// Set up a history with a previous cycle that had quota exceeded but banner was dismissed
|
||||
let now = Utc::now();
|
||||
let previous_end_date = now - chrono::Duration::days(15);
|
||||
let current_end_date = now + chrono::Duration::days(15);
|
||||
|
||||
let previous_cycle = CycleInfo {
|
||||
end_date: previous_end_date,
|
||||
was_quota_exceeded: true,
|
||||
banner_state: BannerState { dismissed: true },
|
||||
};
|
||||
|
||||
let current_cycle = CycleInfo {
|
||||
end_date: current_end_date,
|
||||
was_quota_exceeded: false,
|
||||
banner_state: BannerState::default(),
|
||||
};
|
||||
|
||||
let cycle_history = vec![previous_cycle, current_cycle];
|
||||
|
||||
AISettings::handle(&app).update(&mut app, |settings, ctx| {
|
||||
settings
|
||||
.ai_request_quota_info
|
||||
.set_value(AIRequestQuotaInfo { cycle_history }, ctx)
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
AISettings::handle(&app).read(&app, |settings, _ctx| {
|
||||
// Banner should not be displayed when the previous cycle had quota exceeded but banner was dismissed
|
||||
assert!(!settings.should_display_quota_reset_banner());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_display_quota_reset_banner_with_quota_not_exceeded() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_settings_for_tests(&mut app);
|
||||
|
||||
// Set up a history with a previous cycle that did not have quota exceeded
|
||||
let now = Utc::now();
|
||||
let previous_end_date = now - chrono::Duration::days(15);
|
||||
let current_end_date = now + chrono::Duration::days(15);
|
||||
|
||||
let previous_cycle = CycleInfo {
|
||||
end_date: previous_end_date,
|
||||
was_quota_exceeded: false,
|
||||
banner_state: BannerState::default(),
|
||||
};
|
||||
|
||||
let current_cycle = CycleInfo {
|
||||
end_date: current_end_date,
|
||||
was_quota_exceeded: false,
|
||||
banner_state: BannerState::default(),
|
||||
};
|
||||
|
||||
let cycle_history = vec![previous_cycle, current_cycle];
|
||||
|
||||
AISettings::handle(&app).update(&mut app, |settings, ctx| {
|
||||
settings
|
||||
.ai_request_quota_info
|
||||
.set_value(AIRequestQuotaInfo { cycle_history }, ctx)
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
AISettings::handle(&app).read(&app, |settings, _ctx| {
|
||||
// Banner should not be displayed when the previous cycle did not have quota exceeded
|
||||
assert!(!settings.should_display_quota_reset_banner());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_display_quota_reset_banner_with_only_one_cycle() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_settings_for_tests(&mut app);
|
||||
|
||||
// Set up a history with only one cycle
|
||||
let now = Utc::now();
|
||||
let current_end_date = now + chrono::Duration::days(15);
|
||||
|
||||
let current_cycle = CycleInfo {
|
||||
end_date: current_end_date,
|
||||
was_quota_exceeded: true, // Even if quota is exceeded
|
||||
banner_state: BannerState::default(),
|
||||
};
|
||||
|
||||
let cycle_history = vec![current_cycle];
|
||||
|
||||
AISettings::handle(&app).update(&mut app, |settings, ctx| {
|
||||
settings
|
||||
.ai_request_quota_info
|
||||
.set_value(AIRequestQuotaInfo { cycle_history }, ctx)
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
AISettings::handle(&app).read(&app, |settings, _ctx| {
|
||||
// Banner should not be displayed when there's only one cycle, even if quota is exceeded
|
||||
assert!(!settings.should_display_quota_reset_banner());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_quota_info_create_new_cycle_when_none_exists() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_settings_for_tests(&mut app);
|
||||
|
||||
let now = Utc::now();
|
||||
let next_refresh = now + chrono::Duration::days(30);
|
||||
|
||||
// Create a request limit info with quota not exceeded
|
||||
let request_limit_info = create_test_request_limit_info(
|
||||
100, // limit
|
||||
50, // used
|
||||
next_refresh,
|
||||
false, // not unlimited
|
||||
RequestLimitRefreshDuration::Monthly,
|
||||
);
|
||||
|
||||
AISettings::handle(&app).update(&mut app, |settings, ctx| {
|
||||
// Ensure we start with empty history
|
||||
settings
|
||||
.ai_request_quota_info
|
||||
.set_value(
|
||||
AIRequestQuotaInfo {
|
||||
cycle_history: vec![],
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Update quota info
|
||||
settings.update_quota_info(&request_limit_info, ctx);
|
||||
});
|
||||
|
||||
AISettings::handle(&app).read(&app, |settings, _ctx| {
|
||||
// Verify a new cycle was created
|
||||
let cycle_history = &settings.ai_request_quota_info.cycle_history;
|
||||
assert_eq!(cycle_history.len(), 1);
|
||||
|
||||
let cycle = &cycle_history[0];
|
||||
assert_eq!(cycle.end_date, next_refresh);
|
||||
assert!(!cycle.was_quota_exceeded);
|
||||
assert!(!cycle.banner_state.dismissed);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_quota_info_update_existing_cycle() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_settings_for_tests(&mut app);
|
||||
|
||||
let now = Utc::now();
|
||||
let cycle_end_date = now + chrono::Duration::days(30);
|
||||
|
||||
// Set up an existing cycle
|
||||
let existing_cycle = CycleInfo {
|
||||
end_date: cycle_end_date,
|
||||
was_quota_exceeded: false,
|
||||
banner_state: BannerState::default(),
|
||||
};
|
||||
|
||||
AISettings::handle(&app).update(&mut app, |settings, ctx| {
|
||||
settings
|
||||
.ai_request_quota_info
|
||||
.set_value(
|
||||
AIRequestQuotaInfo {
|
||||
cycle_history: vec![existing_cycle],
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
// Create a request limit info with updated usage
|
||||
let request_limit_info = create_test_request_limit_info(
|
||||
100, // limit
|
||||
75, // used (increased)
|
||||
cycle_end_date,
|
||||
false, // not unlimited
|
||||
RequestLimitRefreshDuration::Monthly,
|
||||
);
|
||||
|
||||
AISettings::handle(&app).update(&mut app, |settings, ctx| {
|
||||
// Update quota info
|
||||
settings.update_quota_info(&request_limit_info, ctx);
|
||||
});
|
||||
|
||||
AISettings::handle(&app).read(&app, |settings, _ctx| {
|
||||
// Verify the cycle was updated
|
||||
let cycle_history = &settings.ai_request_quota_info.cycle_history;
|
||||
assert_eq!(cycle_history.len(), 1);
|
||||
|
||||
let cycle = &cycle_history[0];
|
||||
assert_eq!(cycle.end_date, cycle_end_date);
|
||||
assert!(!cycle.was_quota_exceeded);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_quota_info_quota_exceeded() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_settings_for_tests(&mut app);
|
||||
|
||||
let now = Utc::now();
|
||||
let next_refresh = now + chrono::Duration::days(30);
|
||||
|
||||
// Create a request limit info with quota exceeded
|
||||
let request_limit_info = create_test_request_limit_info(
|
||||
100, // limit
|
||||
100, // used (equal to limit, should be marked as exceeded)
|
||||
next_refresh,
|
||||
false, // not unlimited
|
||||
RequestLimitRefreshDuration::Monthly,
|
||||
);
|
||||
|
||||
AISettings::handle(&app).update(&mut app, |settings, ctx| {
|
||||
// Update quota info
|
||||
settings.update_quota_info(&request_limit_info, ctx);
|
||||
});
|
||||
|
||||
AISettings::handle(&app).read(&app, |settings, _ctx| {
|
||||
// Verify quota exceeded is set correctly
|
||||
let cycle_history = &settings.ai_request_quota_info.cycle_history;
|
||||
let cycle = &cycle_history[0];
|
||||
assert!(cycle.was_quota_exceeded);
|
||||
});
|
||||
|
||||
// Test with unlimited requests (should never be exceeded)
|
||||
let unlimited_request_limit_info = create_test_request_limit_info(
|
||||
100, // limit
|
||||
200, // used (exceeds limit)
|
||||
next_refresh,
|
||||
true, // unlimited
|
||||
RequestLimitRefreshDuration::Monthly,
|
||||
);
|
||||
|
||||
AISettings::handle(&app).update(&mut app, |settings, ctx| {
|
||||
// Update quota info
|
||||
settings.update_quota_info(&unlimited_request_limit_info, ctx);
|
||||
});
|
||||
|
||||
AISettings::handle(&app).read(&app, |settings, _ctx| {
|
||||
// Verify quota exceeded is not set for unlimited plan
|
||||
let cycle_history = &settings.ai_request_quota_info.cycle_history;
|
||||
let cycle = &cycle_history[0];
|
||||
assert!(!cycle.was_quota_exceeded);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mark_quota_banner_as_dismissed() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_settings_for_tests(&mut app);
|
||||
|
||||
let now = Utc::now();
|
||||
|
||||
// Create test cycles: two expired cycles and one future cycle
|
||||
let expired_cycle_1 = CycleInfo {
|
||||
end_date: now - chrono::Duration::days(30), // 30 days ago
|
||||
was_quota_exceeded: true,
|
||||
banner_state: BannerState { dismissed: false },
|
||||
};
|
||||
|
||||
let expired_cycle_2 = CycleInfo {
|
||||
end_date: now - chrono::Duration::days(15), // 15 days ago
|
||||
was_quota_exceeded: true,
|
||||
banner_state: BannerState { dismissed: false },
|
||||
};
|
||||
|
||||
let future_cycle = CycleInfo {
|
||||
end_date: now + chrono::Duration::days(15), // 15 days in future
|
||||
was_quota_exceeded: false,
|
||||
banner_state: BannerState { dismissed: false },
|
||||
};
|
||||
|
||||
let cycle_history = vec![expired_cycle_1, expired_cycle_2, future_cycle];
|
||||
|
||||
// Set up initial state
|
||||
AISettings::handle(&app).update(&mut app, |settings, ctx| {
|
||||
settings
|
||||
.ai_request_quota_info
|
||||
.set_value(AIRequestQuotaInfo { cycle_history }, ctx)
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
// Mark expired cycles as dismissed
|
||||
AISettings::handle(&app).update(&mut app, |settings, ctx| {
|
||||
settings.mark_quota_banner_as_dismissed(ctx);
|
||||
});
|
||||
|
||||
// Verify the results
|
||||
AISettings::handle(&app).read(&app, |settings, _ctx| {
|
||||
let cycle_history = &settings.ai_request_quota_info.cycle_history;
|
||||
assert_eq!(cycle_history.len(), 3);
|
||||
|
||||
// First cycle (oldest expired) should be dismissed
|
||||
assert!(cycle_history[0].banner_state.dismissed);
|
||||
// Second cycle (more recent expired) should be dismissed
|
||||
assert!(cycle_history[1].banner_state.dismissed);
|
||||
// Future cycle should not be dismissed
|
||||
assert!(!cycle_history[2].banner_state.dismissed);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
use settings::{
|
||||
macros::define_settings_group, RespectUserSyncSetting, SupportedPlatforms, SyncToCloud,
|
||||
};
|
||||
|
||||
define_settings_group!(AliasExpansionSettings, settings: [
|
||||
alias_expansion_enabled: AliasExpansionEnabled {
|
||||
type: bool,
|
||||
default: false,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "terminal.input.alias_expansion_enabled",
|
||||
description: "Whether shell alias expansion is enabled in the input.",
|
||||
},
|
||||
]);
|
||||
@@ -0,0 +1,136 @@
|
||||
use enum_iterator::Sequence;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use warp_core::{
|
||||
channel::{Channel, ChannelState},
|
||||
settings::{macros::define_settings_group, SupportedPlatforms, SyncToCloud},
|
||||
};
|
||||
|
||||
/// The app icon to use (mac-only).
|
||||
///
|
||||
/// IMPORTANT NOTE: If you add a new icon, you will need to update the logic in WarpDockTilePlugin.m
|
||||
/// to read the new icon and also add the icon to app/DockTilePlugin/Resources.
|
||||
#[derive(
|
||||
Default,
|
||||
Debug,
|
||||
Clone,
|
||||
Copy,
|
||||
PartialEq,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
Sequence,
|
||||
schemars::JsonSchema,
|
||||
settings_value::SettingsValue,
|
||||
)]
|
||||
#[schemars(
|
||||
description = "The app icon displayed in the dock.",
|
||||
rename_all = "snake_case"
|
||||
)]
|
||||
pub enum AppIcon {
|
||||
/// Current default: White glyph on blue/black gradient blackground, set in Dec 2024.
|
||||
#[default]
|
||||
#[schemars(description = "Default")]
|
||||
Default,
|
||||
#[schemars(description = "Aurora")]
|
||||
Aurora,
|
||||
#[schemars(description = "Classic 1")]
|
||||
Classic1,
|
||||
#[schemars(description = "Classic 2")]
|
||||
Classic2,
|
||||
#[schemars(description = "Classic 3")]
|
||||
Classic3,
|
||||
#[schemars(description = "Comets")]
|
||||
Comets,
|
||||
/// Cow icon, for Code on Warp launch.
|
||||
#[schemars(description = "Cow")]
|
||||
Cow,
|
||||
#[schemars(description = "Glass Sky")]
|
||||
GlassSky,
|
||||
#[schemars(description = "Glitch")]
|
||||
Glitch,
|
||||
/// White glyph on black background with blue/green glow on the side, set in Oct 2024 brand refresh.
|
||||
#[schemars(description = "Glow")]
|
||||
Glow,
|
||||
#[schemars(description = "Holographic")]
|
||||
Holographic,
|
||||
#[schemars(description = "Mono")]
|
||||
Mono,
|
||||
#[schemars(description = "Neon")]
|
||||
Neon,
|
||||
/// Blue/green glyph on black background.
|
||||
#[schemars(description = "Original")]
|
||||
Original,
|
||||
#[schemars(description = "Starburst")]
|
||||
Starburst,
|
||||
#[schemars(description = "Sticker")]
|
||||
Sticker,
|
||||
/// Previous default icon with solid blue background.
|
||||
#[schemars(description = "Warp 1")]
|
||||
WarpOne,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AppIcon {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let value = match &self {
|
||||
AppIcon::Default => "Default",
|
||||
AppIcon::Aurora => "Aurora",
|
||||
AppIcon::Classic1 => "Classic 1",
|
||||
AppIcon::Classic2 => "Classic 2",
|
||||
AppIcon::Classic3 => "Classic 3",
|
||||
AppIcon::Comets => "Comets",
|
||||
AppIcon::GlassSky => "Glass Sky",
|
||||
AppIcon::Glitch => "Glitch",
|
||||
AppIcon::Cow => "Cow",
|
||||
AppIcon::Glow => "Glow",
|
||||
AppIcon::Holographic => "Holographic",
|
||||
AppIcon::Mono => "Mono",
|
||||
AppIcon::Neon => "Neon",
|
||||
AppIcon::Original => "Original",
|
||||
AppIcon::Starburst => "Starburst",
|
||||
AppIcon::Sticker => "Sticker",
|
||||
AppIcon::WarpOne => "Warp 1",
|
||||
};
|
||||
write!(f, "{value}")
|
||||
}
|
||||
}
|
||||
|
||||
impl AppIconSettings {
|
||||
pub fn get_base_icon_file_name(icon: AppIcon) -> &'static str {
|
||||
match icon {
|
||||
AppIcon::Aurora => "aurora",
|
||||
AppIcon::Default => match ChannelState::channel() {
|
||||
Channel::Dev => "dev",
|
||||
Channel::Preview => "preview",
|
||||
Channel::Local => "local",
|
||||
_ => "warp_2",
|
||||
},
|
||||
AppIcon::Classic1 => "classic_1",
|
||||
AppIcon::Classic2 => "classic_2",
|
||||
AppIcon::Classic3 => "classic_3",
|
||||
AppIcon::Comets => "comets",
|
||||
AppIcon::GlassSky => "glass_sky",
|
||||
AppIcon::Glitch => "glitch",
|
||||
AppIcon::Cow => "cow",
|
||||
AppIcon::Glow => "glow",
|
||||
AppIcon::Holographic => "holographic",
|
||||
AppIcon::Mono => "mono",
|
||||
AppIcon::Neon => "neon",
|
||||
AppIcon::Original => "original",
|
||||
AppIcon::Starburst => "starburst",
|
||||
AppIcon::Sticker => "sticker",
|
||||
AppIcon::WarpOne => "blue",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
define_settings_group!(AppIconSettings, settings: [
|
||||
app_icon: AppIconState {
|
||||
type: AppIcon,
|
||||
default: AppIcon::Default,
|
||||
supported_platforms: SupportedPlatforms::MAC,
|
||||
sync_to_cloud: SyncToCloud::Never,
|
||||
private: false,
|
||||
storage_key: "AppIcon",
|
||||
toml_path: "appearance.icon.app_icon",
|
||||
description: "The app icon displayed in the dock.",
|
||||
},
|
||||
]);
|
||||
@@ -0,0 +1,36 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use settings::{macros::define_settings_group, SupportedPlatforms, SyncToCloud};
|
||||
|
||||
#[derive(
|
||||
Clone,
|
||||
Copy,
|
||||
Debug,
|
||||
Default,
|
||||
Eq,
|
||||
PartialEq,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
schemars::JsonSchema,
|
||||
settings_value::SettingsValue,
|
||||
)]
|
||||
#[schemars(
|
||||
description = "Whether the desktop app installation has been detected.",
|
||||
rename_all = "snake_case"
|
||||
)]
|
||||
pub enum UserAppInstallStatus {
|
||||
#[default]
|
||||
NotDetected,
|
||||
Detected,
|
||||
}
|
||||
|
||||
define_settings_group!(UserAppInstallDetectionSettings, settings: [
|
||||
user_app_installation_detected: UserAppInstallationDetected {
|
||||
type: UserAppInstallStatus,
|
||||
default: UserAppInstallStatus::default(),
|
||||
supported_platforms: SupportedPlatforms::WEB,
|
||||
sync_to_cloud: SyncToCloud::Never,
|
||||
private: true,
|
||||
storage_key: "UserAppInstallStatus",
|
||||
}
|
||||
]);
|
||||
@@ -0,0 +1,35 @@
|
||||
use settings::{
|
||||
macros::define_settings_group, RespectUserSyncSetting, SupportedPlatforms, SyncToCloud,
|
||||
};
|
||||
|
||||
// Settings for visibility of non-user command blocks like the bootstrap block
|
||||
// and in-band command blocks.
|
||||
define_settings_group!(BlockVisibilitySettings, settings: [
|
||||
should_show_bootstrap_block: ShouldShowBootstrapBlock {
|
||||
type: bool,
|
||||
default: false,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "appearance.blocks.should_show_bootstrap_block",
|
||||
description: "Whether the bootstrap block is visible in the terminal.",
|
||||
},
|
||||
should_show_in_band_command_blocks: ShouldShowInBandCommandBlocks {
|
||||
type: bool,
|
||||
default: false,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "appearance.blocks.should_show_in_band_command_blocks",
|
||||
description: "Whether in-band command blocks are visible in the terminal.",
|
||||
},
|
||||
should_show_ssh_block: ShouldShowSSHBlock {
|
||||
type: bool,
|
||||
default: false,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "appearance.blocks.should_show_ssh_block",
|
||||
description: "Whether the SSH connection block is visible in the terminal.",
|
||||
}
|
||||
]);
|
||||
@@ -0,0 +1,15 @@
|
||||
use settings::{
|
||||
macros::define_settings_group, RespectUserSyncSetting, SupportedPlatforms, SyncToCloud,
|
||||
};
|
||||
|
||||
define_settings_group!(ChangelogSettings, settings: [
|
||||
show_changelog_after_update: ShowChangelogAfterUpdate {
|
||||
type: bool,
|
||||
default: true,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "general.show_changelog_after_update",
|
||||
description: "Whether the changelog is shown after an update.",
|
||||
},
|
||||
]);
|
||||
@@ -0,0 +1,198 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{
|
||||
cloud_object::{
|
||||
model::{
|
||||
generic_string_model::{GenericStringModel, GenericStringObjectId, StringModel},
|
||||
json_model::{JsonModel, JsonSerializer},
|
||||
},
|
||||
GenericCloudObject, GenericStringObjectFormat, GenericStringObjectUniqueKey,
|
||||
JsonObjectType, Revision, ServerCloudObject, UniquePer,
|
||||
},
|
||||
server::sync_queue::QueueItem,
|
||||
};
|
||||
|
||||
use settings::{
|
||||
macros::define_settings_group, RespectUserSyncSetting, SupportedPlatforms, SyncToCloud,
|
||||
};
|
||||
define_settings_group!(CloudPreferencesSettings, settings: [
|
||||
settings_sync_enabled: IsSettingsSyncEnabled {
|
||||
type: bool,
|
||||
default: false,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::No),
|
||||
private: false,
|
||||
toml_path: "account.is_settings_sync_enabled",
|
||||
description: "Whether settings are synced across devices via the cloud.",
|
||||
},
|
||||
]);
|
||||
|
||||
pub type CloudPreference = GenericCloudObject<GenericStringObjectId, CloudPreferenceModel>;
|
||||
pub type CloudPreferenceModel = GenericStringModel<Preference, JsonSerializer>;
|
||||
|
||||
/// Defines the platform that a preference was set on.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub enum Platform {
|
||||
Mac,
|
||||
Linux,
|
||||
Windows,
|
||||
Web,
|
||||
|
||||
/// This implies the preference applies on all supported platforms
|
||||
Global,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Platform {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Mac => write!(f, "Mac"),
|
||||
Self::Linux => write!(f, "Linux"),
|
||||
Self::Windows => write!(f, "Windows"),
|
||||
Self::Web => write!(f, "Web"),
|
||||
Self::Global => write!(f, "Global"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Platform {
|
||||
pub fn applies_to_current_platform(&self) -> bool {
|
||||
*self == Platform::current_platform() || *self == Platform::Global
|
||||
}
|
||||
}
|
||||
|
||||
impl Platform {
|
||||
pub fn current_platform() -> Self {
|
||||
if cfg!(all(not(target_family = "wasm"), target_os = "macos")) {
|
||||
return Self::Mac;
|
||||
}
|
||||
|
||||
if cfg!(all(not(target_family = "wasm"), target_os = "linux")) {
|
||||
return Self::Linux;
|
||||
}
|
||||
|
||||
if cfg!(all(not(target_family = "wasm"), target_os = "windows")) {
|
||||
return Self::Windows;
|
||||
}
|
||||
if cfg!(target_family = "wasm") {
|
||||
return Self::Web;
|
||||
}
|
||||
panic!("Unsupported platform");
|
||||
}
|
||||
}
|
||||
|
||||
/// Defines the data model for a cloud synced user preference.
|
||||
///
|
||||
/// The expected usage is that each storage key is modeled as its own cloud preference object.
|
||||
/// This allows users to edit individual cloud preferences with less fear of an offline
|
||||
/// collision (e.g. if I change one preference on one machine and then update another while
|
||||
/// offline on another machine, modeling them individually allows for both changes to be applied).
|
||||
///
|
||||
/// Note that I considered adding a concept of "preference group" as a higher level namespace
|
||||
/// for preferences (in case users want to create groups of them), but decided to hold off on
|
||||
/// this until we actually support that feature.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Preference {
|
||||
/// The storage key (unique identifier for this preference).
|
||||
pub storage_key: String,
|
||||
|
||||
/// The value of the preference, which can be any JSON value.
|
||||
pub value: Value,
|
||||
|
||||
/// The platform that this preference was set on.
|
||||
/// If the preference is global, this will be set to Platform::Global.
|
||||
pub platform: Platform,
|
||||
}
|
||||
|
||||
impl Preference {
|
||||
/// Creates a new preference object with the given storage key and value and the appropriate
|
||||
/// platform key for the given syncing mode.
|
||||
/// Used when creating a new preference the first time. For preferences synced from the
|
||||
/// cloud they will desererialize directly from JSON.
|
||||
pub fn new(storage_key: String, value: &str, syncing_mode: SyncToCloud) -> Result<Self> {
|
||||
let platform = match syncing_mode {
|
||||
SyncToCloud::PerPlatform(_) => Platform::current_platform(),
|
||||
SyncToCloud::Globally(_) => Platform::Global,
|
||||
SyncToCloud::Never => Err(anyhow!(
|
||||
"Cannot create a preference with SyncToCloud::Never"
|
||||
))?,
|
||||
};
|
||||
match serde_json::from_str(value) {
|
||||
Ok(value) => Ok(Self {
|
||||
storage_key,
|
||||
value,
|
||||
platform,
|
||||
}),
|
||||
Err(err) => Err(anyhow!("Failed to parse preference value {}", err)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Defines a based model for syncing cloud preferences.
|
||||
impl StringModel for Preference {
|
||||
type CloudObjectType = CloudPreference;
|
||||
|
||||
fn model_type_name(&self) -> &'static str {
|
||||
"Preference"
|
||||
}
|
||||
|
||||
fn should_enforce_revisions() -> bool {
|
||||
// Last write wins for cloud prefs
|
||||
false
|
||||
}
|
||||
|
||||
fn should_show_activity_toasts() -> bool {
|
||||
// No update toasts for cloud prefs
|
||||
false
|
||||
}
|
||||
|
||||
fn warn_if_unsaved_at_quit() -> bool {
|
||||
// Don't block quitting on unsaved cloud prefs changes
|
||||
false
|
||||
}
|
||||
|
||||
fn new_from_server_update(&self, server_cloud_object: &ServerCloudObject) -> Option<Self> {
|
||||
if let ServerCloudObject::Preference(server_preference) = server_cloud_object {
|
||||
return Some(server_preference.model.clone().string_model);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn model_format() -> GenericStringObjectFormat {
|
||||
GenericStringObjectFormat::Json(Self::json_object_type())
|
||||
}
|
||||
|
||||
fn display_name(&self) -> String {
|
||||
self.model_type_name().to_owned()
|
||||
}
|
||||
|
||||
fn update_object_queue_item(
|
||||
&self,
|
||||
revision_ts: Option<Revision>,
|
||||
object: &CloudPreference,
|
||||
) -> QueueItem {
|
||||
QueueItem::UpdateCloudPreferences {
|
||||
model: object.model().clone().into(),
|
||||
id: object.id,
|
||||
revision: revision_ts.or_else(|| object.metadata.revision.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn should_clear_on_unique_key_conflict(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn uniqueness_key(&self) -> Option<GenericStringObjectUniqueKey> {
|
||||
Some(GenericStringObjectUniqueKey {
|
||||
key: format!("{}_{}", self.platform, self.storage_key),
|
||||
unique_per: UniquePer::User,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonModel for Preference {
|
||||
fn json_object_type() -> JsonObjectType {
|
||||
JsonObjectType::Preference
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,969 @@
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
path::PathBuf,
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use lazy_static::lazy_static;
|
||||
use settings::{Setting as _, SyncToCloud};
|
||||
use std::time::Duration;
|
||||
use warp_core::settings::ChangeEventReason;
|
||||
use warp_core::user_preferences::GetUserPreferences;
|
||||
use warpui::r#async::Timer;
|
||||
use warpui::{Entity, ModelContext, SingletonEntity};
|
||||
use warpui_extras::user_preferences::toml_backed::TomlBackedUserPreferences;
|
||||
|
||||
use crate::{
|
||||
auth::auth_state::AuthState,
|
||||
cloud_object::{
|
||||
model::{
|
||||
generic_string_model::GenericStringObjectId, json_model::JsonSerializer,
|
||||
persistence::CloudModel,
|
||||
},
|
||||
CloudObjectEventEntrypoint, GenericStringObjectFormat, JsonObjectType,
|
||||
},
|
||||
debounce::debounce,
|
||||
drive::CloudObjectTypeAndId,
|
||||
report_if_error,
|
||||
server::{
|
||||
cloud_objects::update_manager::{
|
||||
GenericStringObjectInput, InitiatedBy, UpdateManager, UpdateManagerEvent,
|
||||
},
|
||||
ids::{ClientId, SyncId},
|
||||
sync_queue::{SyncQueue, SyncQueueEvent},
|
||||
},
|
||||
settings::{
|
||||
cloud_preferences::{CloudPreference, CloudPreferenceModel, Platform, Preference},
|
||||
manager::SettingsManager,
|
||||
},
|
||||
workspaces::user_workspaces::UserWorkspaces,
|
||||
};
|
||||
|
||||
use warp_core::execution_mode::AppExecutionMode;
|
||||
|
||||
use super::{
|
||||
cloud_preferences::{CloudPreferencesSettings, CloudPreferencesSettingsChangedEvent},
|
||||
manager::SettingsEvent,
|
||||
PrivacySettings,
|
||||
};
|
||||
|
||||
/// Provides client ids for creating cloud preferences.
|
||||
/// We define this as a trait so tests can track what client ids are created and use
|
||||
/// them for mocking server responses.
|
||||
pub trait ClientIdProvider {
|
||||
fn next_client_id(&self) -> ClientId;
|
||||
}
|
||||
|
||||
struct DefaultClientIdProvider;
|
||||
impl ClientIdProvider for DefaultClientIdProvider {
|
||||
fn next_client_id(&self) -> ClientId {
|
||||
ClientId::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Key used to persist the hash of the settings file content as of the
|
||||
/// last successful cloud sync reconciliation. Used on next startup to
|
||||
/// detect whether the user made local changes (via file edit or offline
|
||||
/// UI change) that cloud sync doesn't know about yet.
|
||||
pub(super) const SETTINGS_FILE_LAST_SYNCED_HASH_KEY: &str = "SettingsFileLastSyncedHash";
|
||||
|
||||
/// Constructs the cloud preferences syncer, computing the
|
||||
/// `force_local_wins_on_startup` flag by comparing the current settings
|
||||
/// file hash against the last-synced hash stored in private preferences.
|
||||
///
|
||||
/// This is the only entry point used to construct the syncer at app
|
||||
/// startup; production code in `lib.rs` and end-to-end tests both call
|
||||
/// it so they exercise the same code path.
|
||||
pub fn initialize_cloud_preferences_syncer(
|
||||
toml_file_path: PathBuf,
|
||||
startup_toml_parse_error: Option<&str>,
|
||||
ctx: &mut ModelContext<CloudPreferencesSyncer>,
|
||||
) -> CloudPreferencesSyncer {
|
||||
let current_hash = TomlBackedUserPreferences::file_content_hash(&toml_file_path);
|
||||
let stored_hash = ctx
|
||||
.private_user_preferences()
|
||||
.read_value(SETTINGS_FILE_LAST_SYNCED_HASH_KEY)
|
||||
.unwrap_or_default();
|
||||
|
||||
let file_has_unsynced_changes = match (current_hash, stored_hash) {
|
||||
// File present, stored hash present: trust the comparison.
|
||||
(Some(current), Some(stored)) => current != stored,
|
||||
// File present, no stored hash (first launch, fresh install, or
|
||||
// the stored hash was cleared): cloud wins, consistent with
|
||||
// today's behavior.
|
||||
(Some(_), None) => false,
|
||||
// File missing/empty, stored hash present (user deleted or
|
||||
// emptied the file): cloud wins. If we treated this as "local
|
||||
// differs" we'd upload defaults and wipe the user's cloud
|
||||
// settings — exactly what they likely don't want.
|
||||
(None, Some(_)) => false,
|
||||
// File missing/empty, no stored hash (fresh install with no
|
||||
// file yet): cloud wins.
|
||||
(None, None) => false,
|
||||
};
|
||||
|
||||
// Broken-file guard: when the file can't be parsed, there are no
|
||||
// meaningful local values to preserve. Cloud sync restores settings
|
||||
// in memory while flush suppression protects the broken file on
|
||||
// disk.
|
||||
let force_local_wins_on_startup =
|
||||
file_has_unsynced_changes && startup_toml_parse_error.is_none();
|
||||
|
||||
CloudPreferencesSyncer::new(force_local_wins_on_startup, toml_file_path, ctx)
|
||||
}
|
||||
|
||||
/// Handles syncing CloudPreferences (the Warp Drive objects) and local Settings models that
|
||||
/// have been created using the define_settings_group macro.
|
||||
pub struct CloudPreferencesSyncer {
|
||||
// A channel used for debouncing local settings updates so that we don't spam the
|
||||
// server with requests. Most important for settings that continuously update
|
||||
// like ones that are driven by sliders.
|
||||
#[allow(dead_code)]
|
||||
update_tx: async_channel::Sender<()>,
|
||||
|
||||
// Local prefs awaiting syncing to the cloud after a debounce period.
|
||||
dirty_local_prefs: HashSet<String>,
|
||||
|
||||
// Provides the next ClientId to use in creating cloud preferences.
|
||||
client_id_provider: Arc<dyn ClientIdProvider>,
|
||||
|
||||
has_completed_initial_load: bool,
|
||||
|
||||
/// When `true`, the first `handle_initial_load` will force local
|
||||
/// values to be uploaded to cloud rather than accepting cloud
|
||||
/// values — equivalent to `ForceCloudToMatchLocal::Yes`. Only
|
||||
/// consulted on the first initial load; subsequent `sync()` calls
|
||||
/// use their own flag.
|
||||
force_local_wins_on_startup: bool,
|
||||
|
||||
/// Path to the user's `settings.toml` file, used by
|
||||
/// `update_stored_settings_hash` to compute the hash persisted
|
||||
/// after every successful cloud sync reconciliation.
|
||||
toml_file_path: PathBuf,
|
||||
}
|
||||
|
||||
/// Event fired by the CloudPreferencesSyncer when a cloud preference has changed.
|
||||
#[derive(Debug)]
|
||||
pub enum CloudPreferencesSyncerEvent {
|
||||
/// Emitted when the local preferences are updated to match values from cloud upon initial load.
|
||||
InitialLoadCompleted,
|
||||
|
||||
/// Event variant indicating there's a new value for the preference with
|
||||
/// a specific storage key
|
||||
Updated { key: String, value: String },
|
||||
}
|
||||
|
||||
/// Whether to force the cloud to match the local settings or not.
|
||||
/// Used to force a resync of the cloud state to the current local state when a
|
||||
/// user manually re-enables settings sync.
|
||||
#[derive(Debug)]
|
||||
pub enum ForceCloudToMatchLocal {
|
||||
Yes,
|
||||
No,
|
||||
}
|
||||
|
||||
struct PreferenceToCreate {
|
||||
value: String,
|
||||
syncing_mode: SyncToCloud,
|
||||
}
|
||||
|
||||
lazy_static! {
|
||||
static ref LEGACY_CLOUD_SETTINGS_STORAGE_KEYS: Vec<&'static str> = vec![
|
||||
super::privacy::TELEMETRY_ENABLED_DEFAULTS_KEY,
|
||||
super::privacy::CRASH_REPORTING_ENABLED_DEFAULTS_KEY,
|
||||
super::privacy::CLOUD_CONVERSATION_STORAGE_ENABLED_DEFAULTS_KEY,
|
||||
];
|
||||
}
|
||||
|
||||
const PREFERENCES_DEBOUNCE_PERIOD: Duration = Duration::from_millis(500);
|
||||
|
||||
impl CloudPreferencesSyncer {
|
||||
// Retry preferences every five minutes until they are successfully synced.
|
||||
// Only enabled for users in the warp drive preferences experiment.
|
||||
const RETRY_POLL: Duration = Duration::from_secs(60 * 5);
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn new_for_test(
|
||||
ctx: &mut ModelContext<Self>,
|
||||
client_id_provider: Arc<dyn ClientIdProvider>,
|
||||
) -> Self {
|
||||
// Existing tests do not exercise the hash-update path; the
|
||||
// default empty path causes `file_content_hash` to return
|
||||
// `None` and `update_stored_settings_hash` becomes a no-op.
|
||||
// End-to-end tests that care about the hash path construct
|
||||
// the syncer via `initialize_cloud_preferences_syncer`.
|
||||
Self::new_internal(ctx, client_id_provider, PathBuf::new())
|
||||
}
|
||||
|
||||
pub fn new(
|
||||
force_local_wins_on_startup: bool,
|
||||
toml_file_path: PathBuf,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> Self {
|
||||
let mut me = Self::new_internal(ctx, Arc::new(DefaultClientIdProvider), toml_file_path);
|
||||
me.force_local_wins_on_startup = force_local_wins_on_startup;
|
||||
me.retry_failed_settings(ctx);
|
||||
me
|
||||
}
|
||||
|
||||
fn new_internal(
|
||||
ctx: &mut ModelContext<Self>,
|
||||
client_id_provider: Arc<dyn ClientIdProvider>,
|
||||
toml_file_path: PathBuf,
|
||||
) -> Self {
|
||||
// Set up event syncing in both directions (local -> cloud and cloud -> local).
|
||||
// We only apply cloud->local updates AFTER the initial load has been processed by
|
||||
// handle_initial_load. This prevents the CloudPreferencesUpdated event (which fires
|
||||
// synchronously in on_changed_objects_fetched) from overwriting local settings
|
||||
// before handle_initial_load has a chance to determine sync direction.
|
||||
ctx.subscribe_to_model(&UpdateManager::handle(ctx), |syncer, event, ctx| {
|
||||
if let UpdateManagerEvent::CloudPreferencesUpdated { updated } = event {
|
||||
// Defer cloud→local updates until `handle_initial_load`
|
||||
// has determined the correct sync direction. The
|
||||
// `CloudPreferencesUpdated` event fires synchronously
|
||||
// during `on_changed_objects_fetched` and would
|
||||
// overwrite local settings before `handle_initial_load`
|
||||
// gets a chance to decide whether local or cloud wins.
|
||||
if !syncer.has_completed_initial_load {
|
||||
return;
|
||||
}
|
||||
for preference in updated {
|
||||
syncer.maybe_sync_cloud_pref_to_local(&preference.storage_key, ctx);
|
||||
}
|
||||
}
|
||||
});
|
||||
let (update_tx, update_rx) = async_channel::unbounded();
|
||||
ctx.spawn_stream_local(
|
||||
debounce(PREFERENCES_DEBOUNCE_PERIOD, update_rx),
|
||||
|me, _, ctx| {
|
||||
let prefs_to_sync = me.dirty_local_prefs.drain().collect();
|
||||
me.maybe_sync_local_prefs_to_cloud(prefs_to_sync, ctx);
|
||||
},
|
||||
|_, _| {},
|
||||
);
|
||||
ctx.subscribe_to_model(
|
||||
&SettingsManager::handle(ctx),
|
||||
|me, event, ctx| match event {
|
||||
SettingsEvent::LocalPreferencesUpdated { storage_key, .. } => {
|
||||
me.handle_local_preference_updated(storage_key, ctx);
|
||||
}
|
||||
},
|
||||
);
|
||||
// Update the stored settings file hash whenever a preference is
|
||||
// successfully created or updated on the server. This ensures the
|
||||
// hash only moves forward when the cloud has actually accepted
|
||||
// local changes — if the upload fails (e.g. offline), the hash
|
||||
// stays stale and the next startup will correctly detect
|
||||
// divergence.
|
||||
ctx.subscribe_to_model(&SyncQueue::handle(ctx), Self::handle_sync_queue_event);
|
||||
ctx.subscribe_to_model(
|
||||
&CloudPreferencesSettings::handle(ctx),
|
||||
|me, event, ctx| match event {
|
||||
CloudPreferencesSettingsChangedEvent::IsSettingsSyncEnabled {
|
||||
change_event_reason,
|
||||
} => {
|
||||
let force_cloud_to_match_local = match change_event_reason {
|
||||
ChangeEventReason::CloudSync => ForceCloudToMatchLocal::No,
|
||||
ChangeEventReason::LocalChange => ForceCloudToMatchLocal::Yes,
|
||||
ChangeEventReason::Clear => {
|
||||
log::info!(
|
||||
"Not resyncing cloud preferences because the setting was cleared \
|
||||
(typically on logout)"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
log::info!(
|
||||
"Settings sync enabled setting changed. Resyncing cloud preferences. Force \
|
||||
cloud to match local: {force_cloud_to_match_local:?}"
|
||||
);
|
||||
// Always resync from the local client when the setting changes,
|
||||
// but only force cloud to match this client's local settings if the change in the setting
|
||||
// was initiated in this client.
|
||||
me.sync(force_cloud_to_match_local, ctx);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
Self {
|
||||
update_tx,
|
||||
dirty_local_prefs: HashSet::new(),
|
||||
client_id_provider,
|
||||
has_completed_initial_load: false,
|
||||
force_local_wins_on_startup: false,
|
||||
toml_file_path,
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles SyncQueue success events by updating the stored
|
||||
/// settings file hash when a cloud preference is successfully
|
||||
/// created or updated on the server.
|
||||
fn handle_sync_queue_event(&mut self, event: &SyncQueueEvent, ctx: &mut ModelContext<Self>) {
|
||||
let server_id = match event {
|
||||
SyncQueueEvent::ObjectCreationSuccessful {
|
||||
server_creation_info,
|
||||
..
|
||||
} => Some(server_creation_info.server_id_and_type.id),
|
||||
SyncQueueEvent::ObjectUpdateSuccessful { server_id, .. } => Some(*server_id),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(server_id) = server_id {
|
||||
// Check whether this object is a cloud preference.
|
||||
// GenericStringObject is a superset that also includes
|
||||
// env var collections, workflow enums, MCP servers, etc.
|
||||
// Only preference changes should update the stored hash.
|
||||
let sync_id = SyncId::ServerId(server_id);
|
||||
let is_preference = CloudModel::as_ref(ctx)
|
||||
.get_all_cloud_preferences_by_storage_key()
|
||||
.values()
|
||||
.any(|pref| pref.id == sync_id);
|
||||
if is_preference {
|
||||
self.update_stored_settings_hash(ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the current settings file hash from disk and persists it
|
||||
/// as the last-synced hash in private preferences. Called at every
|
||||
/// sync reconciliation point so that on the next startup, the
|
||||
/// stored hash accurately reflects what the cloud last saw.
|
||||
///
|
||||
/// This is a no-op when the file is missing, empty, or unreadable
|
||||
/// (`file_content_hash` returns `None`).
|
||||
fn update_stored_settings_hash(&self, ctx: &mut ModelContext<Self>) {
|
||||
let Some(hash) = TomlBackedUserPreferences::file_content_hash(&self.toml_file_path) else {
|
||||
return;
|
||||
};
|
||||
if let Err(err) = ctx
|
||||
.private_user_preferences()
|
||||
.write_value(SETTINGS_FILE_LAST_SYNCED_HASH_KEY, hash)
|
||||
{
|
||||
log::warn!("Failed to persist settings file hash after sync: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
fn handle_local_preference_updated(&mut self, storage_key: &str, _: &mut ModelContext<Self>) {
|
||||
self.dirty_local_prefs.insert(storage_key.to_string());
|
||||
let _ = self.update_tx.try_send(());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn handle_local_preference_updated(&mut self, storage_key: &str, ctx: &mut ModelContext<Self>) {
|
||||
// Don't debounce in tests - they have enough async stuff going
|
||||
self.maybe_sync_local_prefs_to_cloud(vec![storage_key.to_string()], ctx);
|
||||
}
|
||||
|
||||
/// This method recursively calls itself after a delay. Call it once and only once to start the
|
||||
/// loop. It ensures failed preferences are retried until they are successfully synced.
|
||||
fn retry_failed_settings(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
ctx.spawn(
|
||||
async {
|
||||
Timer::after(Self::RETRY_POLL).await;
|
||||
},
|
||||
|me, _, ctx| {
|
||||
let ids_to_retry = CloudModel::handle(ctx).update(ctx, |cloud_model, _ctx| {
|
||||
cloud_model
|
||||
.cloud_objects()
|
||||
.filter_map(move |object| {
|
||||
if !object.metadata().is_errored() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let settings_object: Option<&CloudPreference> = object.into();
|
||||
settings_object.map(|object| object.id)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
if !ids_to_retry.is_empty() {
|
||||
log::info!(
|
||||
"Retrying {} failed preference objects...",
|
||||
ids_to_retry.len()
|
||||
);
|
||||
}
|
||||
for sync_id in ids_to_retry {
|
||||
log::debug!("Retrying failed preference object with sync_id {sync_id:?}");
|
||||
UpdateManager::handle(ctx).update(ctx, |update_manager, ctx| {
|
||||
update_manager.resync_object(
|
||||
&CloudObjectTypeAndId::GenericStringObject {
|
||||
object_type: GenericStringObjectFormat::Json(
|
||||
JsonObjectType::Preference,
|
||||
),
|
||||
id: sync_id,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
}
|
||||
me.retry_failed_settings(ctx);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Handler for when the user has been fetched. Potentially kicks off a sync.
|
||||
pub fn handle_user_fetched(
|
||||
&mut self,
|
||||
auth_state: Arc<AuthState>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let is_onboarded = auth_state.is_onboarded();
|
||||
|
||||
// Reset the initial load flag so that we re-evaluate sync direction
|
||||
// based on the new user's fresh cloud data rather than stale data from
|
||||
// a previous session (e.g. anonymous user's cloud prefs).
|
||||
self.has_completed_initial_load = false;
|
||||
|
||||
// The startup hash-based override was computed for the app launch
|
||||
// and consumed on the first initial load. Clear it so it doesn't
|
||||
// re-trigger for the new user session.
|
||||
self.force_local_wins_on_startup = false;
|
||||
|
||||
if is_onboarded == Some(false) {
|
||||
log::info!("Opting first-time user into cloud preferences");
|
||||
CloudPreferencesSettings::handle(ctx).update(ctx, |settings, ctx| {
|
||||
report_if_error!(settings.settings_sync_enabled.set_value(true, ctx));
|
||||
});
|
||||
} else {
|
||||
log::info!("Not opting existing user into cloud preferences");
|
||||
}
|
||||
|
||||
// Always trigger a sync explicitly. For not-yet-onboarded users the
|
||||
// set_value call above may be a no-op if settings_sync_enabled was
|
||||
// already true (e.g. from a prior anonymous session), so no change
|
||||
// event would fire. We need the sync to happen regardless so that
|
||||
// handle_initial_load can examine the *new* user's cloud state and
|
||||
// decide whether to preserve local settings (brand-new user, no cloud
|
||||
// prefs) or apply cloud settings (existing user with cloud prefs).
|
||||
self.sync(ForceCloudToMatchLocal::No, ctx);
|
||||
}
|
||||
|
||||
/// Performs a settings sync. Checks internally to confirm that the correct settings are
|
||||
/// synced based on whether the user has opted in to settings sync.
|
||||
///
|
||||
/// Specifically, this call spawns a future waiting for cloud preferences to load and then
|
||||
/// 1) If no cloud preferences exist yet, creates and stores them from the local prefs.
|
||||
/// 2) If cloud prefs do exist, they are merged into the local preferences, with the cloud
|
||||
/// value overwriting any local values for the same keys. This is only true if force_cloud_to_match_local
|
||||
/// is false. If force_cloud_to_match_local is true, then the local values will overwrite the cloud value.
|
||||
/// This is the behavior we want when a user is enabling settings sync manually in the UI -
|
||||
/// we should disregard any potentially stale cloud values and overwrite them with the current
|
||||
/// local settings.
|
||||
pub fn sync(
|
||||
&self,
|
||||
force_cloud_to_match_local: ForceCloudToMatchLocal,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let update_manager = UpdateManager::as_ref(ctx);
|
||||
|
||||
// We wait for the cloud objects to load because we need to know if there are any cloud preferences
|
||||
// to sync.
|
||||
ctx.spawn(update_manager.initial_load_complete(), move |me, _, ctx| {
|
||||
me.handle_initial_load(force_cloud_to_match_local, ctx);
|
||||
});
|
||||
|
||||
PrivacySettings::handle(ctx).update(ctx, |privacy_settings, ctx| {
|
||||
// Note that this also blocks on update_manager.initial_load_complete()
|
||||
privacy_settings.maybe_sync_with_warp_drive_prefs(ctx);
|
||||
});
|
||||
}
|
||||
|
||||
/// Fixes https://linear.app/warpdotdev/issue/CLD-2629/duplicate-prefs-for-users
|
||||
fn ensure_no_duplicate_cloud_prefs(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
log::info!("Ensuring no duplicate cloud prefs");
|
||||
let ids_to_delete = CloudModel::handle(ctx).update(ctx, |cloud_model, ctx| {
|
||||
let cloud_prefs = cloud_model
|
||||
.get_all_objects_of_type::<GenericStringObjectId, CloudPreferenceModel>();
|
||||
|
||||
// First group all the cloud prefs by storage key.
|
||||
let mut prefs_by_storage_key = HashMap::new();
|
||||
for pref in cloud_prefs {
|
||||
let storage_key = pref.model().string_model.storage_key.clone();
|
||||
prefs_by_storage_key
|
||||
.entry(storage_key)
|
||||
.or_insert_with(Vec::new)
|
||||
.push(pref);
|
||||
}
|
||||
|
||||
// Then, for any storage key that has multiple prefs (which is an error introduced in the linked issue
|
||||
// mentioned in the function comment), we delete all but whichever one is set as the current pref value on this client.
|
||||
// If none of the prefs have the current value, we delete all of them, and the local value will end
|
||||
// up being the value of the preference when we sync.
|
||||
let pref_ids = prefs_by_storage_key
|
||||
.iter()
|
||||
.filter_map(|(storage_key, prefs)| {
|
||||
if prefs.len() == 1 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let current_value = SettingsManager::as_ref(ctx)
|
||||
.read_local_setting_value(storage_key, ctx)
|
||||
.unwrap_or_default()
|
||||
.unwrap_or_default();
|
||||
|
||||
let current_pref_id = prefs
|
||||
.iter()
|
||||
.find(|pref| {
|
||||
let pref_value = pref.model().string_model.value.to_string();
|
||||
pref_value == current_value
|
||||
})
|
||||
.map(|pref| pref.id);
|
||||
log::debug!(
|
||||
"Cleaning up duplicate prefs for storage key {storage_key} and current pref value: {current_value:?}"
|
||||
);
|
||||
let pref_ids = prefs.iter().filter_map(|pref| {
|
||||
let should_delete = current_pref_id != Some(pref.id);
|
||||
if should_delete {
|
||||
log::debug!(
|
||||
"Deleting duplicate pref with id {} for storage key {} with value {}",
|
||||
pref.id,
|
||||
storage_key,
|
||||
pref.model().string_model.value
|
||||
);
|
||||
Some(pref.id)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}).collect::<Vec<_>>();
|
||||
Some(pref_ids)
|
||||
}) .collect::<Vec<_>>();
|
||||
pref_ids.iter().flatten().cloned().collect::<Vec<_>>()
|
||||
});
|
||||
|
||||
for pref_id in ids_to_delete {
|
||||
UpdateManager::handle(ctx).update(ctx, |update_manager, ctx| {
|
||||
update_manager.delete_object_with_initiated_by(
|
||||
CloudObjectTypeAndId::GenericStringObject {
|
||||
object_type: GenericStringObjectFormat::Json(JsonObjectType::Preference),
|
||||
id: pref_id,
|
||||
},
|
||||
InitiatedBy::System,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_initial_load(
|
||||
&mut self,
|
||||
force_cloud_to_match_local: ForceCloudToMatchLocal,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
self.ensure_no_duplicate_cloud_prefs(ctx);
|
||||
|
||||
// First-load override: if the startup hash check detected
|
||||
// local changes that cloud sync doesn't know about, force
|
||||
// local to win on this one call. The `!has_completed_initial_load`
|
||||
// guard makes this a genuine one-time override — robust
|
||||
// against any future code path that calls `sync()` again.
|
||||
let force_cloud_to_match_local =
|
||||
if !self.has_completed_initial_load && self.force_local_wins_on_startup {
|
||||
log::info!(
|
||||
"Local has unsynced changes on startup; forcing cloud to match local \
|
||||
on initial load"
|
||||
);
|
||||
ForceCloudToMatchLocal::Yes
|
||||
} else {
|
||||
force_cloud_to_match_local
|
||||
};
|
||||
|
||||
log::info!(
|
||||
"Initial load complete, syncing cloud preferences. \
|
||||
Force cloud to match local: {force_cloud_to_match_local:?}"
|
||||
);
|
||||
|
||||
// These are the preferences that the cloud model currently knows about (i.e. the
|
||||
// preferences that have been synced to the cloud)
|
||||
let prefs_in_cloud_model = CloudModel::as_ref(ctx)
|
||||
.get_all_cloud_preferences_by_storage_key()
|
||||
.keys()
|
||||
.cloned()
|
||||
.collect::<HashSet<_>>();
|
||||
|
||||
// Keys to sync is a list of all of the preferences that *should* be "cloud synced"
|
||||
// We identify these preferences by their storage key (which is the unique key they are
|
||||
// stored under in the local preferences store)
|
||||
//
|
||||
// Sort so that settings with `RespectUserSyncSetting::No` (i.e.
|
||||
// settings that sync regardless of the user's sync-enabled
|
||||
// toggle) are processed first. This ensures that
|
||||
// `IsSettingsSyncEnabled` is restored from cloud before other
|
||||
// settings check `settings_sync_enabled`. Without this,
|
||||
// HashMap iteration order could cause the sync-enabled flag to
|
||||
// still be at its default (false) when other settings are
|
||||
// processed, silently skipping them.
|
||||
let settings_manager = SettingsManager::as_ref(ctx);
|
||||
let mut keys_to_sync = settings_manager
|
||||
.all_storage_keys()
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
keys_to_sync.sort_by_key(|key| {
|
||||
if settings_manager.sync_regardless_of_users_syncing_setting(key) {
|
||||
0
|
||||
} else {
|
||||
1
|
||||
}
|
||||
});
|
||||
|
||||
let mut keys_to_sync_to_cloud = Vec::new();
|
||||
for storage_key in keys_to_sync {
|
||||
if prefs_in_cloud_model.contains(&storage_key)
|
||||
&& matches!(force_cloud_to_match_local, ForceCloudToMatchLocal::No)
|
||||
{
|
||||
// Update local pref to match cloud pref unless we are doing a forced preferences sync.
|
||||
self.maybe_sync_cloud_pref_to_local(&storage_key, ctx)
|
||||
} else if !LEGACY_CLOUD_SETTINGS_STORAGE_KEYS.contains(&storage_key.as_str()) {
|
||||
// For all settings except legacy cloud-synced settings, we sync them immediately to warp drive on
|
||||
// initial load.
|
||||
keys_to_sync_to_cloud.push(storage_key);
|
||||
} else {
|
||||
// This is one of the two legacy settings stored in the user_settings table and
|
||||
// it has not yet been saved to warp drive. In this case we want to wait for
|
||||
// these settings to load from the server, and then sync them to warp drive.
|
||||
// The logic for this is in privacy.rs.
|
||||
log::info!(
|
||||
"Waiting to sync legacy cloud preference with storage key {storage_key} until it is explicitly set"
|
||||
);
|
||||
}
|
||||
}
|
||||
// Create a new cloud setting with the local value.
|
||||
self.maybe_sync_local_prefs_to_cloud(keys_to_sync_to_cloud, ctx);
|
||||
|
||||
if !self.has_completed_initial_load {
|
||||
self.has_completed_initial_load = true;
|
||||
ctx.emit(CloudPreferencesSyncerEvent::InitialLoadCompleted);
|
||||
}
|
||||
|
||||
// Reconciliation is complete (or a no-op). Persist the current
|
||||
// file hash so the next startup can detect further divergence.
|
||||
self.update_stored_settings_hash(ctx);
|
||||
}
|
||||
|
||||
/// Syncs the local preferences with the given storage keys to the cloud.
|
||||
/// For each storage key, if there is an existing cloud preference for that key, it updates it.
|
||||
/// Otherwise, it creates a new one. All creations happen in a single bulk request.
|
||||
pub(crate) fn maybe_sync_local_prefs_to_cloud(
|
||||
&mut self,
|
||||
keys_to_sync: Vec<String>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
if !AppExecutionMode::as_ref(ctx).can_sync_preferences() {
|
||||
// Early exit if the app can't sync preferences.
|
||||
return;
|
||||
}
|
||||
|
||||
let mut cloud_prefs_to_create = HashMap::new();
|
||||
let cloud_prefs_by_storage_key = CloudModel::as_ref(ctx)
|
||||
.get_all_cloud_preferences_by_storage_key()
|
||||
.iter()
|
||||
.map(|(storage_key, cloud_pref)| (storage_key.clone(), (*cloud_pref).clone()))
|
||||
.collect::<HashMap<_, _>>();
|
||||
let settings_sync_enabled = *CloudPreferencesSettings::as_ref(ctx)
|
||||
.settings_sync_enabled
|
||||
.value();
|
||||
for storage_key in &keys_to_sync {
|
||||
let settings_manager = SettingsManager::as_ref(ctx);
|
||||
if !settings_sync_enabled
|
||||
&& !settings_manager.sync_regardless_of_users_syncing_setting(storage_key)
|
||||
{
|
||||
// Skip syncing if settings sync is disabled and this particular cloud pref is not always synced.
|
||||
log::debug!("Not syncing cloud preference with storage key {storage_key} because settings sync is disabled for it");
|
||||
continue;
|
||||
}
|
||||
|
||||
let syncing_mode = settings_manager
|
||||
.cloud_syncing_mode_for_storage_key(storage_key)
|
||||
.unwrap_or(SyncToCloud::Never);
|
||||
if syncing_mode == SyncToCloud::Never {
|
||||
// Skip non-cloud-synced prefs
|
||||
continue;
|
||||
}
|
||||
|
||||
let is_current_value_syncable = settings_manager
|
||||
.is_current_value_syncable(storage_key, ctx)
|
||||
.unwrap_or(false);
|
||||
if !is_current_value_syncable {
|
||||
// Don't sync this preference if the current value is not syncable.
|
||||
log::debug!("Not syncing cloud preference with storage key {storage_key} because the current value is not syncable");
|
||||
continue;
|
||||
}
|
||||
|
||||
let Ok(Some(local_value)) = settings_manager.read_local_setting_value(storage_key, ctx)
|
||||
else {
|
||||
log::debug!(
|
||||
"No local value set for preference with storage key {storage_key}. Skipping cloud sync."
|
||||
);
|
||||
continue;
|
||||
};
|
||||
|
||||
let Some(supported_platforms) =
|
||||
SettingsManager::as_ref(ctx).supported_platforms_for_storage_key(storage_key)
|
||||
else {
|
||||
log::warn!(
|
||||
"No supported platforms found for preference with storage key {storage_key}. Skipping cloud sync."
|
||||
);
|
||||
continue;
|
||||
};
|
||||
|
||||
if !supported_platforms.matches_current_platform() {
|
||||
log::debug!(
|
||||
"Preference with storage key {storage_key} is not supported on the current platform. Skipping cloud sync."
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(cloud_pref) = cloud_prefs_by_storage_key.get(storage_key) {
|
||||
self.maybe_update_cloud_pref_to_match_local(
|
||||
storage_key,
|
||||
syncing_mode,
|
||||
cloud_pref,
|
||||
&local_value,
|
||||
ctx,
|
||||
);
|
||||
} else {
|
||||
cloud_prefs_to_create.insert(
|
||||
storage_key.clone(),
|
||||
PreferenceToCreate {
|
||||
value: local_value,
|
||||
syncing_mode,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
self.bulk_create_cloud_prefs_from_local(cloud_prefs_to_create, ctx);
|
||||
}
|
||||
|
||||
fn maybe_update_cloud_pref_to_match_local(
|
||||
&self,
|
||||
storage_key: &str,
|
||||
syncing_mode: SyncToCloud,
|
||||
cloud_pref: &CloudPreference,
|
||||
local_value: &str,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
// Preference has already been synced to the cloud, so update it to the new value if it's different
|
||||
// than the current value.
|
||||
let cloud_value = &cloud_pref.model().string_model.value.to_string();
|
||||
let settings_manager = SettingsManager::as_ref(ctx);
|
||||
|
||||
let local_and_cloud_values_are_equal =
|
||||
match settings_manager.are_equal_settings(storage_key, local_value, cloud_value) {
|
||||
Ok(equal) => equal,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Error {e} comparing local value {local_value} for cloud preference with \
|
||||
storage key {storage_key}",
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let model_revision_and_id = if local_and_cloud_values_are_equal {
|
||||
None
|
||||
} else {
|
||||
// Create a new instance of the cloud model with the new preference.
|
||||
let mut model = cloud_pref.model().clone();
|
||||
match Preference::new(storage_key.to_owned(), local_value, syncing_mode) {
|
||||
Ok(updated_pref) => model.string_model = updated_pref,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Error updating cloud preference with storage key {storage_key} from \
|
||||
local value {local_value}: {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
let revision = CloudModel::as_ref(ctx)
|
||||
.current_revision(&cloud_pref.id)
|
||||
.cloned();
|
||||
Some((model, revision, cloud_pref.id))
|
||||
};
|
||||
|
||||
if let Some((model, revision, id)) = model_revision_and_id {
|
||||
// Save the update.
|
||||
UpdateManager::handle(ctx).update(ctx, move |update_manager, ctx| {
|
||||
log::info!(
|
||||
"Updating cloud preference with storage key {storage_key} to value \
|
||||
{local_value}"
|
||||
);
|
||||
update_manager.update_object(model, id, revision, ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn bulk_create_cloud_prefs_from_local(
|
||||
&self,
|
||||
cloud_prefs_to_create: HashMap<String, PreferenceToCreate>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let inputs = cloud_prefs_to_create
|
||||
.into_iter()
|
||||
.filter_map(|(storage_key, preference_to_create)| {
|
||||
// Create a new instance of the cloud model with the new preference.
|
||||
match Preference::new(
|
||||
storage_key.to_owned(),
|
||||
&preference_to_create.value,
|
||||
preference_to_create.syncing_mode,
|
||||
) {
|
||||
Ok(new_pref) => Some(GenericStringObjectInput::<Preference, JsonSerializer> {
|
||||
id: self.client_id_provider.next_client_id(),
|
||||
model: CloudPreferenceModel::new(new_pref),
|
||||
initial_folder_id: None,
|
||||
entrypoint: CloudObjectEventEntrypoint::Unknown,
|
||||
}),
|
||||
Err(e) => {
|
||||
log::warn!("Error {e} creating cloud preference with {storage_key}");
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let Some(personal_drive) = UserWorkspaces::as_ref(ctx).personal_drive(ctx) else {
|
||||
log::warn!("Unable to create cloud preferences due to unset personal drive");
|
||||
return;
|
||||
};
|
||||
|
||||
// Preferences don't yet exist in the cloud, so create them.
|
||||
// Note that there is a potential race condition here with the same storage key being created
|
||||
// on different clients at the same time. The server handles this and will only accept the first
|
||||
// create request for each storage key.
|
||||
if !inputs.is_empty() {
|
||||
log::debug!(
|
||||
"Bulk creating {} generic string objects with storage keys {:?}",
|
||||
inputs.len(),
|
||||
inputs
|
||||
.iter()
|
||||
.map(|input| input.model.string_model.storage_key.clone())
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
UpdateManager::handle(ctx).update(ctx, move |update_manager, ctx| {
|
||||
update_manager.bulk_create_generic_string_objects(personal_drive, inputs, ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Syncs the given cloud pref to local, if cloud syncing is enabled for the pref on this client.
|
||||
// Returns early if the pref with the given storage key isn't actually synced to the cloud.
|
||||
fn maybe_sync_cloud_pref_to_local(&self, storage_key: &str, ctx: &mut ModelContext<Self>) {
|
||||
let Some(model) = CloudModel::as_ref(ctx)
|
||||
.get_all_cloud_preferences_by_storage_key()
|
||||
.get(storage_key)
|
||||
.filter(|object| !object.metadata.pending_changes_statuses.pending_delete)
|
||||
.map(|cloud_pref| cloud_pref.model().clone())
|
||||
else {
|
||||
// No cloud pref to sync
|
||||
return;
|
||||
};
|
||||
let settings_sync_enabled = *CloudPreferencesSettings::as_ref(ctx).settings_sync_enabled;
|
||||
SettingsManager::handle(ctx).update(ctx, |manager, ctx| {
|
||||
let always_sync = manager.sync_regardless_of_users_syncing_setting(storage_key);
|
||||
if !settings_sync_enabled && !always_sync {
|
||||
// Early exit in the case where settings sync is disabled, unless this particular cloud pref is always synced.
|
||||
return;
|
||||
}
|
||||
|
||||
let syncing_mode = manager
|
||||
.cloud_syncing_mode_for_storage_key(storage_key)
|
||||
.unwrap_or(SyncToCloud::Never);
|
||||
if syncing_mode == SyncToCloud::Never {
|
||||
// Early exit if this isn't a cloud synced key. Could happen if the current client
|
||||
// is on a different build from another one which happened to sync this key. We
|
||||
// always honor the syncability setting on the current client.
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(supported_platforms) =
|
||||
manager.supported_platforms_for_storage_key(storage_key)
|
||||
else {
|
||||
log::warn!(
|
||||
"No supported platforms for storage key {storage_key}. Not updating local \
|
||||
pref to match cloud pref"
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
if !supported_platforms.matches_current_platform() {
|
||||
log::debug!(
|
||||
"Preference with storage key {storage_key} is not supported on the current \
|
||||
platform. Not updating local pref to match cloud pref"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let platform = model.string_model.platform;
|
||||
if matches!(syncing_mode, SyncToCloud::PerPlatform(_))
|
||||
&& !platform.applies_to_current_platform()
|
||||
{
|
||||
log::debug!(
|
||||
"Not applying platform-specific preference for {platform:?} with storage key \
|
||||
{storage_key} on current platform {:?}",
|
||||
Platform::current_platform()
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let is_current_value_syncable = manager
|
||||
.is_current_value_syncable(storage_key, ctx)
|
||||
.unwrap_or(false);
|
||||
if !is_current_value_syncable {
|
||||
log::info!(
|
||||
"Not syncing cloud preference with storage key {storage_key} to local because \
|
||||
the current value is not syncable and we don't want to overwrite it with a \
|
||||
cloud value"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let value = &model.string_model.value;
|
||||
let value_str = value.to_string();
|
||||
|
||||
// Get current local value to compare if it's changing
|
||||
let current_value = manager
|
||||
.read_local_setting_value(storage_key, ctx)
|
||||
.ok()
|
||||
.flatten();
|
||||
let is_changing = current_value
|
||||
.as_ref()
|
||||
.and_then(|current| {
|
||||
manager
|
||||
.are_equal_settings(storage_key, current, &value_str)
|
||||
.ok()
|
||||
})
|
||||
.map(|are_equal| !are_equal)
|
||||
.unwrap_or(true); // If we can't determine, assume it's changing
|
||||
|
||||
if is_changing {
|
||||
log::info!(
|
||||
"Updating local preference with storage key {storage_key} and value {value} to \
|
||||
match cloud preference"
|
||||
);
|
||||
|
||||
if let Err(e) = manager.update_setting_with_storage_key(
|
||||
storage_key,
|
||||
value.to_string(),
|
||||
true, /* from_cloud_sync */
|
||||
ctx,
|
||||
) {
|
||||
log::warn!(
|
||||
"Error updating setting with storage key {storage_key} while merging cloud \
|
||||
prefs to local: {e}"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
log::debug!(
|
||||
"Local preference with storage key {storage_key} already matches cloud value \
|
||||
{value}"
|
||||
);
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for CloudPreferencesSyncer {
|
||||
type Event = CloudPreferencesSyncerEvent;
|
||||
}
|
||||
|
||||
/// Mark CloudPreferencesSyncer as global application state.
|
||||
impl SingletonEntity for CloudPreferencesSyncer {}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "cloud_preferences_syncer_tests.rs"]
|
||||
mod tests;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,63 @@
|
||||
use settings::{
|
||||
macros::define_settings_group, RespectUserSyncSetting, SupportedPlatforms, SyncToCloud,
|
||||
};
|
||||
|
||||
define_settings_group!(CodeSettings, settings: [
|
||||
code_as_default_editor: CodeAsDefaultEditor {
|
||||
type: bool,
|
||||
default: false,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Never,
|
||||
private: false,
|
||||
toml_path: "code.editor.use_warp_as_default_editor",
|
||||
description: "Whether Warp is used as the default code editor.",
|
||||
}
|
||||
codebase_context_enabled: CodebaseContextEnabled {
|
||||
type: bool,
|
||||
default: true,
|
||||
supported_platforms: SupportedPlatforms::DESKTOP,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
storage_key: "AgentModeCodebaseContext",
|
||||
toml_path: "code.indexing.agent_mode_codebase_context",
|
||||
description: "Whether codebase context is provided to the AI agent.",
|
||||
},
|
||||
auto_indexing_enabled: AutoIndexingEnabled {
|
||||
type: bool,
|
||||
default: false,
|
||||
supported_platforms: SupportedPlatforms::DESKTOP,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
storage_key: "AgentModeCodebaseContextAutoIndexing",
|
||||
toml_path: "code.indexing.agent_mode_codebase_context_auto_indexing",
|
||||
description: "Whether automatic codebase indexing is enabled.",
|
||||
},
|
||||
// Whether or not the user has manually dismissed the code toolbelt new feature popup.
|
||||
dismissed_code_toolbelt_new_feature_popup: DismissedCodeToolbeltNewFeaturePopup {
|
||||
type: bool,
|
||||
default: false,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: true,
|
||||
},
|
||||
// Controls whether the project explorer / file tree appears in the tools panel.
|
||||
show_project_explorer: ShowProjectExplorer {
|
||||
type: bool,
|
||||
default: true,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "code.editor.show_project_explorer",
|
||||
description: "Whether the project explorer is shown in the tools panel.",
|
||||
},
|
||||
// Controls whether global file search appears in the tools panel.
|
||||
show_global_search: ShowGlobalSearch {
|
||||
type: bool,
|
||||
default: true,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "code.editor.show_global_search",
|
||||
description: "Whether global file search is shown in the tools panel.",
|
||||
},
|
||||
]);
|
||||
@@ -0,0 +1,67 @@
|
||||
use settings::{macros::define_settings_group, Setting, SupportedPlatforms, SyncToCloud};
|
||||
|
||||
// Debug mode settings.
|
||||
//
|
||||
// If "shell debug mode" is enabled, the `WARP_SHELL_DEBUG_MODE` environment variable is
|
||||
// set in subsequently spawned terminal sessions.
|
||||
//
|
||||
// If `are_in_band_generators_for_all_sessions_enabled` is `true`, then all new sessions employ
|
||||
// in-band generators for powering completions and syntax highlighting.
|
||||
//
|
||||
// If `are_in_band_generators_disabled` is `true`, then in-band generators are _never_ used to
|
||||
// power completions/syntax highlighting in _any_ new session. For sessions that have no
|
||||
// alternative completions method (e.g. remote non-SSH subshells), completions and syntax
|
||||
// highlighting are broken. This setting takes precedence over
|
||||
// `are_in_band_generators_for_all_sessions_enabled`. This is only offered as a setting as a sort
|
||||
// of 'kill-switch' for in-band generators, should a particularly bad bug appear during or shortly
|
||||
// after launch.
|
||||
//
|
||||
// The recording mode setting can be turned on to start by using the "recording_mode" feature
|
||||
// and can subsequently be turned on and off via the "Toggle Recording Mode" App->Debug mac menu.
|
||||
define_settings_group!(DebugSettings, settings: [
|
||||
is_shell_debug_mode_enabled: IsShellDebugModeEnabled {
|
||||
type: bool,
|
||||
default: false,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Never,
|
||||
private: true,
|
||||
},
|
||||
are_in_band_generators_for_all_sessions_enabled: AreInBandGeneratorsForAllSessionsEnabled {
|
||||
type: bool,
|
||||
default: false,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Never,
|
||||
private: true,
|
||||
},
|
||||
force_disable_in_band_generators: ForceDisableInBandGenerators {
|
||||
type: bool,
|
||||
default: false,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Never,
|
||||
private: true,
|
||||
storage_key: "DisableInBandCommands",
|
||||
},
|
||||
recording_mode: RecordingModeEnabled {
|
||||
type: bool,
|
||||
default: cfg!(feature = "recording_mode"),
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Never,
|
||||
private: true,
|
||||
},
|
||||
show_memory_stats: ShowMemoryStats {
|
||||
type: bool,
|
||||
default: true,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Never,
|
||||
private: true,
|
||||
}
|
||||
]);
|
||||
|
||||
impl DebugSettings {
|
||||
pub fn should_show_memory_stats(&self) -> bool {
|
||||
// We only want to show memory stats in dogfood and not in tests.
|
||||
*self.show_memory_stats.value()
|
||||
&& warp_core::channel::ChannelState::enable_debug_features()
|
||||
&& !cfg!(test)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
use std::fmt::{Display, Formatter};
|
||||
|
||||
use enum_iterator::{all, Sequence};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use settings::{
|
||||
macros::define_settings_group, RespectUserSyncSetting, Setting as _, SupportedPlatforms,
|
||||
SyncToCloud,
|
||||
};
|
||||
use warpui::ModelContext;
|
||||
|
||||
#[derive(
|
||||
Clone,
|
||||
Copy,
|
||||
Debug,
|
||||
Default,
|
||||
Eq,
|
||||
PartialEq,
|
||||
Deserialize,
|
||||
Serialize,
|
||||
schemars::JsonSchema,
|
||||
settings_value::SettingsValue,
|
||||
)]
|
||||
#[schemars(description = "Whether the cursor blinks.", rename_all = "snake_case")]
|
||||
pub enum CursorBlink {
|
||||
#[default]
|
||||
Enabled,
|
||||
Disabled,
|
||||
}
|
||||
|
||||
impl CursorBlink {
|
||||
pub fn other_value(&self) -> Self {
|
||||
match self {
|
||||
Self::Enabled => Self::Disabled,
|
||||
Self::Disabled => Self::Enabled,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Clone,
|
||||
Copy,
|
||||
Debug,
|
||||
Default,
|
||||
Eq,
|
||||
PartialEq,
|
||||
Deserialize,
|
||||
Serialize,
|
||||
Sequence,
|
||||
schemars::JsonSchema,
|
||||
settings_value::SettingsValue,
|
||||
)]
|
||||
#[schemars(description = "Visual style of the cursor.", rename_all = "snake_case")]
|
||||
pub enum CursorDisplayType {
|
||||
#[default]
|
||||
Bar,
|
||||
Block,
|
||||
Underline,
|
||||
}
|
||||
|
||||
impl CursorDisplayType {
|
||||
pub fn nth(index: usize) -> Option<Self> {
|
||||
all::<Self>().nth(index)
|
||||
}
|
||||
|
||||
pub fn to_index(&self) -> usize {
|
||||
all::<Self>()
|
||||
.position(|v| v == *self)
|
||||
.expect("Cursor display type not found in Sequence!")
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for CursorDisplayType {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
let value = match &self {
|
||||
CursorDisplayType::Bar => "Bar",
|
||||
CursorDisplayType::Block => "Block",
|
||||
CursorDisplayType::Underline => "Underline",
|
||||
};
|
||||
write!(f, "{value}")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Default, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub enum TabBehavior {
|
||||
#[default]
|
||||
Completions,
|
||||
Autosuggestions,
|
||||
UserDefined,
|
||||
}
|
||||
|
||||
impl TabBehavior {
|
||||
pub fn dropdown_item_label(&self) -> &'static str {
|
||||
match self {
|
||||
TabBehavior::Completions => "Open completions menu",
|
||||
TabBehavior::Autosuggestions => "Accept autosuggestion",
|
||||
TabBehavior::UserDefined => "User defined",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// This enum is used to enforce options in the dropdown for selecting a separator with the Warp prompt.
|
||||
/// Note that these separators are added at the END of the Warp prompt (used in the case of same line prompt).
|
||||
#[derive(
|
||||
Clone,
|
||||
Copy,
|
||||
Debug,
|
||||
Default,
|
||||
Eq,
|
||||
PartialEq,
|
||||
Deserialize,
|
||||
Serialize,
|
||||
schemars::JsonSchema,
|
||||
settings_value::SettingsValue,
|
||||
)]
|
||||
#[schemars(
|
||||
description = "Trailing separator character displayed at the end of the prompt.",
|
||||
rename_all = "snake_case"
|
||||
)]
|
||||
pub enum WarpPromptSeparator {
|
||||
/// No separator for the prompt.
|
||||
#[default]
|
||||
None,
|
||||
/// "%" separator for the prompt. Note this is the default separator used in zsh traditionally.
|
||||
PercentSign,
|
||||
/// "$" separator for the prompt. Note this is the default separator used in bash traditionally.
|
||||
DollarSign,
|
||||
/// ">" separator for the prompt. Note this is the default separator used in fish traditionally.
|
||||
ChevronSymbol,
|
||||
}
|
||||
|
||||
impl WarpPromptSeparator {
|
||||
pub fn dropdown_item_label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::None => "None",
|
||||
Self::PercentSign => "%",
|
||||
Self::DollarSign => "$",
|
||||
Self::ChevronSymbol => ">",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn renderable_string(&self) -> Option<&'static str> {
|
||||
match self {
|
||||
Self::None => None,
|
||||
Self::PercentSign => Some("%"),
|
||||
Self::DollarSign => Some("$"),
|
||||
Self::ChevronSymbol => Some(">"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
define_settings_group!(AppEditorSettings, settings: [
|
||||
cursor_blink: CursorBlinkEnabled {
|
||||
type: CursorBlink,
|
||||
default: CursorBlink::default(),
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
storage_key: "CursorBlink",
|
||||
toml_path: "appearance.cursor.cursor_blink",
|
||||
description: "Whether the cursor blinks.",
|
||||
},
|
||||
cursor_display_type: CursorDisplayState {
|
||||
type: CursorDisplayType,
|
||||
default: CursorDisplayType::default(),
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
storage_key: "CursorDisplayType",
|
||||
toml_path: "appearance.cursor.cursor_display_type",
|
||||
description: "The visual style of the cursor.",
|
||||
},
|
||||
vim_mode: VimModeEnabled {
|
||||
type: bool,
|
||||
default: false,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "text_editing.vim_mode_enabled",
|
||||
description: "Whether Vim keybindings are enabled.",
|
||||
},
|
||||
vim_unnamed_system_clipboard: VimUnnamedSystemClipboard {
|
||||
type: bool,
|
||||
default: false,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "text_editing.vim_unnamed_system_clipboard",
|
||||
description: "Whether the Vim unnamed register uses the system clipboard.",
|
||||
},
|
||||
vim_status_bar: VimStatusBar {
|
||||
type: bool,
|
||||
default: true,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "text_editing.vim_status_bar",
|
||||
description: "Whether the Vim status bar is displayed.",
|
||||
},
|
||||
autocomplete_symbols: AutocompleteSymbols {
|
||||
type: bool,
|
||||
default: true,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "text_editing.autocomplete_symbols",
|
||||
description: "Whether matching symbols like brackets and quotes are auto-completed.",
|
||||
},
|
||||
enable_autosuggestions: EnableAutosuggestions {
|
||||
type: bool,
|
||||
default: true,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
storage_key: "Autosuggestions",
|
||||
toml_path: "terminal.input.autosuggestions.enabled",
|
||||
description: "Whether command autosuggestions are shown.",
|
||||
},
|
||||
autosuggestion_keybinding_hint: AutosuggestionKeybindingHint {
|
||||
type: bool,
|
||||
default: true,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "terminal.input.autosuggestions.keybinding_hint",
|
||||
description: "Whether autosuggestion keybinding hints are displayed.",
|
||||
},
|
||||
show_autosuggestion_ignore_button: ShowAutosuggestionIgnoreButton {
|
||||
type: bool,
|
||||
default: false,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "terminal.input.autosuggestions.show_ignore_button",
|
||||
description: "Whether the ignore button is shown for autosuggestions.",
|
||||
},
|
||||
]);
|
||||
|
||||
impl AppEditorSettings {
|
||||
pub fn toggle_cursor_blink(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
self.cursor_blink
|
||||
.set_value(self.cursor_blink.other_value(), ctx)
|
||||
.expect("failed to serialize CursorBlinkEnabled");
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
pub fn vim_mode_enabled(&self) -> bool {
|
||||
*self.vim_mode.value()
|
||||
}
|
||||
|
||||
pub fn cursor_blink_enabled(&self) -> bool {
|
||||
*self.cursor_blink.value() == CursorBlink::Enabled
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
use crate::banner::BannerState;
|
||||
use settings::{
|
||||
macros::define_settings_group, RespectUserSyncSetting, SupportedPlatforms, SyncToCloud,
|
||||
};
|
||||
|
||||
// This isn't exactly a setting, but rather a record of a
|
||||
// user action that should be persisted the same way we would a setting.
|
||||
//
|
||||
// When a Linux user chooses Emacs bindings,
|
||||
// we want to remember that they did so.
|
||||
// That way, we skip displaying it in the future
|
||||
// and prevent it from becoming an annoyance.
|
||||
define_settings_group!(EmacsBindingsSettings, settings: [
|
||||
emacs_bindings_banner_state: EmacsBindingsBannerState {
|
||||
type: BannerState,
|
||||
default: BannerState::NotDismissed,
|
||||
supported_platforms: SupportedPlatforms::LINUX,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: true,
|
||||
},
|
||||
]);
|
||||
@@ -0,0 +1,142 @@
|
||||
use warp_core::ui::builder::MIN_FONT_SIZE;
|
||||
use warpui::{fonts::Weight, rendering::ThinStrokes, AppContext, SingletonEntity};
|
||||
|
||||
use settings::{
|
||||
macros::define_settings_group, RespectUserSyncSetting, Setting, SupportedPlatforms, SyncToCloud,
|
||||
};
|
||||
use warpui::elements::DEFAULT_UI_LINE_HEIGHT_RATIO;
|
||||
|
||||
use super::EnforceMinimumContrast as EnforceMinimumContrastEnum;
|
||||
|
||||
pub const DEFAULT_MONOSPACE_FONT_NAME: &str = "Hack";
|
||||
pub const DEFAULT_MONOSPACE_FONT_SIZE: f32 = 13.0;
|
||||
pub const DEFAULT_MONOSPACE_FONT_WEIGHT: Weight = Weight::Normal;
|
||||
|
||||
define_settings_group!(FontSettings,
|
||||
settings: [
|
||||
monospace_font_name: MonospaceFontName {
|
||||
type: String,
|
||||
default: DEFAULT_MONOSPACE_FONT_NAME.to_string(),
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Never,
|
||||
private: false,
|
||||
storage_key: "FontName",
|
||||
toml_path: "appearance.text.font_name",
|
||||
description: "The monospace font used in the terminal.",
|
||||
},
|
||||
monospace_font_size: MonospaceFontSize {
|
||||
type: f32,
|
||||
default: DEFAULT_MONOSPACE_FONT_SIZE,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Never,
|
||||
private: false,
|
||||
storage_key: "FontSize",
|
||||
toml_path: "appearance.text.font_size",
|
||||
description: "The size of the monospace font in the terminal.",
|
||||
},
|
||||
monospace_font_weight: MonospaceFontWeight {
|
||||
type: Weight,
|
||||
default: DEFAULT_MONOSPACE_FONT_WEIGHT,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Never,
|
||||
private: false,
|
||||
storage_key: "FontWeight",
|
||||
toml_path: "appearance.text.font_weight",
|
||||
description: "The weight of the monospace font in the terminal.",
|
||||
},
|
||||
line_height_ratio: LineHeightRatio {
|
||||
type: f32,
|
||||
default: DEFAULT_UI_LINE_HEIGHT_RATIO,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Never,
|
||||
private: false,
|
||||
toml_path: "appearance.text.line_height_ratio",
|
||||
description: "The line height ratio for terminal text.",
|
||||
},
|
||||
ai_font_name: AIFontName {
|
||||
type: String,
|
||||
default: DEFAULT_MONOSPACE_FONT_NAME.to_string(),
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Never,
|
||||
private: false,
|
||||
storage_key: "AIFontName",
|
||||
toml_path: "appearance.text.ai_font_name",
|
||||
description: "The font used for AI-generated content.",
|
||||
},
|
||||
match_ai_font_to_terminal_font: MatchAIFontToTerminalFont {
|
||||
type: bool,
|
||||
default: false,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Never,
|
||||
private: false,
|
||||
storage_key: "MatchAIFont",
|
||||
toml_path: "appearance.text.match_ai_font",
|
||||
description: "Whether the AI font automatically matches the terminal font.",
|
||||
},
|
||||
notebook_font_size: NotebookFontSize {
|
||||
type: f32,
|
||||
default: 14.0,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Never,
|
||||
private: false,
|
||||
toml_path: "appearance.text.notebook_font_size",
|
||||
description: "The font size used in notebooks.",
|
||||
},
|
||||
match_notebook_to_monospace_font_size: MatchNotebookToMonospaceFontSize {
|
||||
type: bool,
|
||||
default: true,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "appearance.text.match_notebook_to_monospace_font_size",
|
||||
description: "Whether the notebook font size matches the terminal font size.",
|
||||
},
|
||||
enforce_minimum_contrast: EnforceMinimumContrast {
|
||||
type: EnforceMinimumContrastEnum,
|
||||
default: EnforceMinimumContrastEnum::default(),
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "appearance.text.enforce_minimum_contrast",
|
||||
description: "Whether to enforce minimum contrast for text readability.",
|
||||
},
|
||||
use_thin_strokes: UseThinStrokes {
|
||||
type: ThinStrokes,
|
||||
default: ThinStrokes::default(),
|
||||
supported_platforms: SupportedPlatforms::MAC,
|
||||
sync_to_cloud: SyncToCloud::Never,
|
||||
private: false,
|
||||
toml_path: "appearance.text.use_thin_strokes",
|
||||
description: "Whether to use thin font strokes on macOS.",
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
const MAX_NOTEBOOK_FONT_SIZE: f32 = 25.0;
|
||||
const NOTEBOOK_FONT_SIZE_INCREMENT: f32 = 1.0;
|
||||
|
||||
pub fn increase_notebook_font_size(ctx: &mut AppContext) -> anyhow::Result<()> {
|
||||
adjust_notebook_font_size(NOTEBOOK_FONT_SIZE_INCREMENT, ctx)
|
||||
}
|
||||
|
||||
pub fn decrease_notebook_font_size(ctx: &mut AppContext) -> anyhow::Result<()> {
|
||||
adjust_notebook_font_size(-NOTEBOOK_FONT_SIZE_INCREMENT, ctx)
|
||||
}
|
||||
|
||||
fn adjust_notebook_font_size(delta: f32, ctx: &mut AppContext) -> anyhow::Result<()> {
|
||||
let current_size = derived_notebook_font_size(FontSettings::as_ref(ctx));
|
||||
let new_font_size = (current_size + delta).clamp(MIN_FONT_SIZE, MAX_NOTEBOOK_FONT_SIZE);
|
||||
FontSettings::handle(ctx).update(ctx, |font_settings, ctx| {
|
||||
font_settings
|
||||
.notebook_font_size
|
||||
.set_value(new_font_size, ctx)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn derived_notebook_font_size(font_settings: &FontSettings) -> f32 {
|
||||
if *font_settings.match_notebook_to_monospace_font_size {
|
||||
*font_settings.monospace_font_size
|
||||
} else {
|
||||
*font_settings.notebook_font_size
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
use settings::{macros::define_settings_group, SupportedPlatforms, SyncToCloud};
|
||||
use warpui::platform::GraphicsBackend;
|
||||
|
||||
define_settings_group!(GPUSettings, settings: [
|
||||
prefer_low_power_gpu: PreferLowPowerGPU {
|
||||
type: bool,
|
||||
// Opt for the low power (integrated) GPU on Windows / Linux since discrete GPUs tend to be
|
||||
// more unstable.
|
||||
default: cfg!(any(target_os = "linux", windows)),
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Never,
|
||||
private: false,
|
||||
toml_path: "system.prefer_low_power_gpu",
|
||||
description: "Whether to prefer the integrated (low-power) GPU.",
|
||||
},
|
||||
preferred_backend: PreferredGraphicsBackend {
|
||||
type: Option<GraphicsBackend>,
|
||||
default: None,
|
||||
supported_platforms: SupportedPlatforms::WINDOWS,
|
||||
sync_to_cloud: SyncToCloud::Never,
|
||||
private: false,
|
||||
toml_path: "system.preferred_graphics_backend",
|
||||
description: "The preferred graphics backend on Windows.",
|
||||
},
|
||||
]);
|
||||
@@ -0,0 +1,466 @@
|
||||
use crate::settings::import::config::ThemeError;
|
||||
use async_recursion::async_recursion;
|
||||
use async_trait::async_trait;
|
||||
use serde::Deserialize;
|
||||
use std::{env, io::ErrorKind, path::PathBuf};
|
||||
use warp_core::ui::{
|
||||
color::hex_color::coloru_from_hex_string,
|
||||
theme::{AnsiColor, AnsiColors, TerminalColors, WarpTheme},
|
||||
};
|
||||
use warpui::fonts::FontInfo;
|
||||
|
||||
use super::config::{
|
||||
calculate_accent_color, Config, ConfigError, ImportableSetting, ParseableConfig, SettingType,
|
||||
ThemeType,
|
||||
};
|
||||
use pathfinder_color::ColorU;
|
||||
|
||||
type AlacrittyColor = String;
|
||||
const CONFIG_DEPTH_LIMIT: u8 = 5;
|
||||
|
||||
// Constants for Alacritty's defaults: see the "Colors" section
|
||||
// of https://alacritty.org/config-alacritty.html
|
||||
pub const DEFAULT_ALACRITTY_FOREGROUND: ColorU = ColorU {
|
||||
r: 0xd8,
|
||||
g: 0xd8,
|
||||
b: 0xd8,
|
||||
a: 255,
|
||||
};
|
||||
|
||||
pub const DEFAULT_ALACRITTY_BACKGROUND: ColorU = ColorU {
|
||||
r: 0x18,
|
||||
g: 0x18,
|
||||
b: 0x18,
|
||||
a: 255,
|
||||
};
|
||||
|
||||
pub const DEFAULT_ALACRITTY_BRIGHT_COLORS: AnsiColors = AnsiColors {
|
||||
black: AnsiColor {
|
||||
r: 0x6b,
|
||||
g: 0x6b,
|
||||
b: 0x6b,
|
||||
},
|
||||
red: AnsiColor {
|
||||
r: 0xc5,
|
||||
g: 0x55,
|
||||
b: 0x55,
|
||||
},
|
||||
green: AnsiColor {
|
||||
r: 0xaa,
|
||||
g: 0xc4,
|
||||
b: 0x74,
|
||||
},
|
||||
yellow: AnsiColor {
|
||||
r: 0xfe,
|
||||
g: 0xca,
|
||||
b: 0x88,
|
||||
},
|
||||
blue: AnsiColor {
|
||||
r: 0x82,
|
||||
g: 0xb8,
|
||||
b: 0xc8,
|
||||
},
|
||||
magenta: AnsiColor {
|
||||
r: 0xc2,
|
||||
g: 0x8c,
|
||||
b: 0xb8,
|
||||
},
|
||||
cyan: AnsiColor {
|
||||
r: 0x93,
|
||||
g: 0xd3,
|
||||
b: 0xc3,
|
||||
},
|
||||
white: AnsiColor {
|
||||
r: 0xf8,
|
||||
g: 0xf8,
|
||||
b: 0xf8,
|
||||
},
|
||||
};
|
||||
|
||||
pub const DEFAULT_ALACRITTY_NORMAL_COLORS: AnsiColors = AnsiColors {
|
||||
black: AnsiColor {
|
||||
r: 0x18,
|
||||
g: 0x18,
|
||||
b: 0x18,
|
||||
},
|
||||
red: AnsiColor {
|
||||
r: 0xac,
|
||||
g: 0x42,
|
||||
b: 0x42,
|
||||
},
|
||||
green: AnsiColor {
|
||||
r: 0x90,
|
||||
g: 0xa9,
|
||||
b: 0x59,
|
||||
},
|
||||
yellow: AnsiColor {
|
||||
r: 0xf4,
|
||||
g: 0xbf,
|
||||
b: 0x75,
|
||||
},
|
||||
blue: AnsiColor {
|
||||
r: 0x6a,
|
||||
g: 0x9f,
|
||||
b: 0xb5,
|
||||
},
|
||||
magenta: AnsiColor {
|
||||
r: 0xaa,
|
||||
g: 0x75,
|
||||
b: 0x9f,
|
||||
},
|
||||
cyan: AnsiColor {
|
||||
r: 0x75,
|
||||
g: 0xb5,
|
||||
b: 0xaa,
|
||||
},
|
||||
white: AnsiColor {
|
||||
r: 0xd8,
|
||||
g: 0xd8,
|
||||
b: 0xd8,
|
||||
},
|
||||
};
|
||||
|
||||
/// `RecursivelyParseable` is a trait that bundles recursive functions over the different
|
||||
/// data structures in our representation of Alacritty's config.
|
||||
trait RecursivelyParseable {
|
||||
/// Fills in any None fields from `self` with values from `other`.
|
||||
fn merge_left(self, other: Self) -> Self;
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Deserialize, PartialEq)]
|
||||
pub struct AlacrittyTheme {
|
||||
primary: Option<PrimaryAlacrittyColors>,
|
||||
normal: Option<AlacrittyColors>,
|
||||
bright: Option<AlacrittyColors>,
|
||||
cursor: Option<AlacrittyCursorColor>,
|
||||
}
|
||||
|
||||
/// Since Alacritty's config stores the cursor color in colors.cursor.cursor, we use this struct
|
||||
/// to match Alacritty's keys exactly.
|
||||
#[derive(Clone, Default, Deserialize, PartialEq)]
|
||||
pub struct AlacrittyCursorColor {
|
||||
cursor: Option<AlacrittyColor>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Deserialize, PartialEq)]
|
||||
pub struct PrimaryAlacrittyColors {
|
||||
foreground: Option<AlacrittyColor>,
|
||||
background: Option<AlacrittyColor>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Deserialize, PartialEq)]
|
||||
pub struct AlacrittyColors {
|
||||
black: Option<AlacrittyColor>,
|
||||
red: Option<AlacrittyColor>,
|
||||
green: Option<AlacrittyColor>,
|
||||
yellow: Option<AlacrittyColor>,
|
||||
blue: Option<AlacrittyColor>,
|
||||
magenta: Option<AlacrittyColor>,
|
||||
cyan: Option<AlacrittyColor>,
|
||||
white: Option<AlacrittyColor>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Deserialize, PartialEq)]
|
||||
pub struct AlacrittyConfig {
|
||||
colors: Option<AlacrittyTheme>,
|
||||
import: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
impl RecursivelyParseable for AlacrittyConfig {
|
||||
fn merge_left(mut self, other: Self) -> Self {
|
||||
self.colors = self
|
||||
.colors
|
||||
.map(|inner| inner.merge_left(other.colors.clone().unwrap_or_default()))
|
||||
.or(other.colors);
|
||||
|
||||
// Concatenate import lists if we can. Otherwise, take the other import list.
|
||||
if let Some(import) = self.import {
|
||||
self.import = Some(
|
||||
import
|
||||
.into_iter()
|
||||
.chain(other.import.unwrap_or_default())
|
||||
.collect(),
|
||||
);
|
||||
} else {
|
||||
self.import = other.import;
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RecursivelyParseable for AlacrittyColors {
|
||||
fn merge_left(mut self, other: Self) -> Self {
|
||||
self.black = self.black.or(other.black);
|
||||
self.red = self.red.or(other.red);
|
||||
self.green = self.green.or(other.green);
|
||||
self.yellow = self.yellow.or(other.yellow);
|
||||
self.blue = self.blue.or(other.blue);
|
||||
self.magenta = self.magenta.or(other.magenta);
|
||||
self.cyan = self.cyan.or(other.cyan);
|
||||
self.white = self.white.or(other.white);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RecursivelyParseable for PrimaryAlacrittyColors {
|
||||
fn merge_left(mut self, other: Self) -> Self {
|
||||
self.foreground = self.foreground.or(other.foreground);
|
||||
self.background = self.background.or(other.background);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RecursivelyParseable for AlacrittyTheme {
|
||||
fn merge_left(mut self, other: Self) -> Self {
|
||||
self.primary = self
|
||||
.primary
|
||||
.map(|inner| inner.merge_left(other.primary.clone().unwrap_or_default()))
|
||||
.or(other.primary);
|
||||
|
||||
self.normal = self
|
||||
.normal
|
||||
.map(|inner| inner.merge_left(other.normal.clone().unwrap_or_default()))
|
||||
.or(other.normal);
|
||||
|
||||
self.bright = self
|
||||
.bright
|
||||
.map(|inner| inner.merge_left(other.bright.clone().unwrap_or_default()))
|
||||
.or(other.bright);
|
||||
|
||||
self.cursor = self
|
||||
.cursor
|
||||
.map(|inner| inner.merge_left(other.cursor.clone().unwrap_or_default()))
|
||||
.or(other.cursor);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RecursivelyParseable for AlacrittyCursorColor {
|
||||
fn merge_left(mut self, other: Self) -> Self {
|
||||
self.cursor = self.cursor.or(other.cursor);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a hex string into an AnsiColor, returning an error only if
|
||||
/// the hex string is present but malformatted.
|
||||
fn parse_alacritty_color(
|
||||
color_string: Option<AlacrittyColor>,
|
||||
) -> Result<Option<AnsiColor>, ThemeError> {
|
||||
let Some(color) = color_string else {
|
||||
return Ok(None);
|
||||
};
|
||||
if color.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
match coloru_from_hex_string(color.as_str()) {
|
||||
Ok(color) => Ok(Some(color.into())),
|
||||
Err(e) => Err(ThemeError::HexColorError(e)),
|
||||
}
|
||||
}
|
||||
|
||||
impl AlacrittyTheme {
|
||||
fn parse(self) -> Result<ThemeType, ThemeError> {
|
||||
let primary = self.primary.unwrap_or_default();
|
||||
let foreground = parse_alacritty_color(primary.foreground)?
|
||||
.unwrap_or(DEFAULT_ALACRITTY_FOREGROUND.into());
|
||||
let background = parse_alacritty_color(primary.background)?
|
||||
.unwrap_or(DEFAULT_ALACRITTY_BACKGROUND.into());
|
||||
let cursor_color =
|
||||
parse_alacritty_color(self.cursor.unwrap_or_default().cursor)?.unwrap_or(foreground);
|
||||
|
||||
// Create TerminalColors from the config, filling in any missing values from Alacritty's default
|
||||
// normal and bright colors.
|
||||
let terminal_colors = TerminalColors {
|
||||
normal: self
|
||||
.normal
|
||||
.unwrap_or_default()
|
||||
.into_ansi_with_default(DEFAULT_ALACRITTY_NORMAL_COLORS)?,
|
||||
bright: self
|
||||
.bright
|
||||
.unwrap_or_default()
|
||||
.into_ansi_with_default(DEFAULT_ALACRITTY_BRIGHT_COLORS)?,
|
||||
};
|
||||
|
||||
if foreground == DEFAULT_ALACRITTY_FOREGROUND.into()
|
||||
|| background == DEFAULT_ALACRITTY_BACKGROUND.into()
|
||||
{
|
||||
Err(ThemeError::MissingValueError)
|
||||
} else {
|
||||
let bright = terminal_colors.bright;
|
||||
let accent = calculate_accent_color(background, foreground, cursor_color, bright);
|
||||
Ok(ThemeType::Single(WarpTheme::new(
|
||||
background.into(),
|
||||
foreground.into(),
|
||||
accent.into(),
|
||||
Some(cursor_color.into()),
|
||||
None,
|
||||
terminal_colors,
|
||||
None,
|
||||
Some(String::from("Imported Alacritty Theme")),
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AlacrittyColors {
|
||||
/// Returns terminal colors with Warp's default colors substituted in for any
|
||||
/// missing terminal colors.
|
||||
fn into_ansi_with_default(self, default: AnsiColors) -> Result<AnsiColors, ThemeError> {
|
||||
Ok(AnsiColors {
|
||||
black: parse_alacritty_color(self.black)?.unwrap_or(default.black),
|
||||
red: parse_alacritty_color(self.red)?.unwrap_or(default.red),
|
||||
green: parse_alacritty_color(self.green)?.unwrap_or(default.green),
|
||||
yellow: parse_alacritty_color(self.yellow)?.unwrap_or(default.yellow),
|
||||
blue: parse_alacritty_color(self.blue)?.unwrap_or(default.blue),
|
||||
magenta: parse_alacritty_color(self.magenta)?.unwrap_or(default.magenta),
|
||||
cyan: parse_alacritty_color(self.cyan)?.unwrap_or(default.cyan),
|
||||
white: parse_alacritty_color(self.white)?.unwrap_or(default.white),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ParseableConfig for AlacrittyConfig {
|
||||
fn parse(self, _font_info: &[FontInfo]) -> Config {
|
||||
Config {
|
||||
theme: ImportableSetting::new(self.parse_theme(), SettingType::Theme),
|
||||
terminal_name: "Alacritty".to_string(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
async fn from_file(path: PathBuf) -> Result<Vec<Self>, ConfigError> {
|
||||
Self::from_file_bounded_depth(path, 0).await
|
||||
}
|
||||
|
||||
fn default_paths() -> Vec<std::path::PathBuf> {
|
||||
// We follow Alacritty's strategy described here: https://github.com/alacritty/alacritty?tab=readme-ov-file#configuration.
|
||||
// Since alacritty uses the `xdg` crate to read config files, we include paths in XDG_CONFIG_DIRS.
|
||||
|
||||
// If we are on Windows, search the only path: %APPDATA%\alacritty\alacritty.toml.
|
||||
if cfg!(windows) {
|
||||
return dirs::config_dir()
|
||||
.map(|path| vec![path.join("alacritty").join("alacritty.toml")])
|
||||
.unwrap_or_default();
|
||||
}
|
||||
|
||||
let mut file_paths = vec![];
|
||||
let mut second_file_paths = vec![];
|
||||
|
||||
let xdg_config_dirs = env::var("XDG_CONFIG_DIRS")
|
||||
.ok()
|
||||
.filter(|val| !val.is_empty())
|
||||
.or_else(|| Some("/usr/local/share/:/usr/share/".to_string()));
|
||||
|
||||
// Add to file_paths:
|
||||
// - $XDG_CONFIG_HOME/alacritty/alacritty.toml
|
||||
// Add to second_file_paths:
|
||||
// - $XDG_CONFIG_HOME/alacritty.toml
|
||||
if let Some(xdg_config_home) = dirs::config_dir() {
|
||||
file_paths.push(xdg_config_home.join("alacritty").join("alacritty.toml"));
|
||||
second_file_paths.push(xdg_config_home.join("alacritty.toml"));
|
||||
}
|
||||
|
||||
// Add to file_paths:
|
||||
// - $XDG_CONFIG_DIRS/alacritty/alacritty.toml
|
||||
// Add to second_file_paths:
|
||||
// - $XDG_CONFIG_DIRS/alacritty.toml
|
||||
if let Some(xdg_config_dirs) = xdg_config_dirs.clone() {
|
||||
for dir in xdg_config_dirs.split(':') {
|
||||
if !dir.is_empty() {
|
||||
file_paths.push(PathBuf::from(dir).join("alacritty").join("alacritty.toml"));
|
||||
second_file_paths.push(PathBuf::from(dir).join("alacritty.toml"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add second_file_paths to the end of file_paths to maintain the correct order.
|
||||
file_paths.extend(second_file_paths);
|
||||
|
||||
// As a backup, check
|
||||
// - $HOME/.config/alacritty/alacritty.toml
|
||||
// - $HOME/.alacritty.toml
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
file_paths.push(
|
||||
home.join(".config")
|
||||
.join("alacritty")
|
||||
.join("alacritty.toml"),
|
||||
);
|
||||
file_paths.push(home.join(".alacritty.toml"));
|
||||
}
|
||||
|
||||
file_paths
|
||||
}
|
||||
|
||||
fn remove_default_values(self) -> Self {
|
||||
// The default Alacritty config is an empty file,
|
||||
// so we don't need to remove anything.
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl AlacrittyConfig {
|
||||
/// Reads Alacritty configs asynchronously and folds in any imported configs up to a given depth.
|
||||
#[async_recursion]
|
||||
async fn from_file_bounded_depth(path: PathBuf, depth: u8) -> Result<Vec<Self>, ConfigError> {
|
||||
// Since Alacritty only reads configs up to depth 5,
|
||||
// return an empty config if we are at depth 5.
|
||||
if depth >= CONFIG_DEPTH_LIMIT {
|
||||
log::warn!(
|
||||
"Maximum configuration depth reached while parsing Alacritty configuration at {path:?}"
|
||||
);
|
||||
return Ok(vec![AlacrittyConfig::default()]);
|
||||
}
|
||||
let contents = match async_fs::read_to_string(path.clone()).await {
|
||||
Ok(string) => string,
|
||||
Err(e) => {
|
||||
if e.kind() == ErrorKind::NotFound {
|
||||
return Err(ConfigError::FileNotFoundError);
|
||||
} else {
|
||||
return Err(ConfigError::FileIOError(e));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let Ok(mut out) = toml::from_str::<AlacrittyConfig>(contents.as_str()) else {
|
||||
return Err(ConfigError::MalformattedFileError(path));
|
||||
};
|
||||
// Alacritty prioritizes settings set in the current config.
|
||||
// If a setting is not set in the highest-level config, it then looks to the imported configs.
|
||||
// It reads each imported config in order, effectively prioritizing the last config listed.
|
||||
// It does support tilde as the home directory, but not environment variables or relative paths.
|
||||
|
||||
// Start with a config with None in all fields.
|
||||
let mut imported_config: AlacrittyConfig = Default::default();
|
||||
if let Some(ref imports) = out.import {
|
||||
// Reverse the iterator, so that the last configs listed get priority.
|
||||
for file_path in imports.iter().rev() {
|
||||
// Merge left, which replaces None values in the first argument
|
||||
// with values from the second argument.
|
||||
imported_config = imported_config.merge_left(
|
||||
Self::from_file_bounded_depth(
|
||||
PathBuf::from(shellexpand::tilde(file_path).into_owned().to_string()),
|
||||
depth + 1,
|
||||
)
|
||||
.await?
|
||||
.pop()
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
}
|
||||
}
|
||||
out = out.merge_left(imported_config);
|
||||
Ok(vec![out])
|
||||
}
|
||||
|
||||
fn parse_theme(self) -> Result<ThemeType, ThemeError> {
|
||||
match self.colors {
|
||||
Some(colors) => Ok(colors.parse()?),
|
||||
None => Err(ThemeError::MissingValueError),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "alacritty_parser_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,475 @@
|
||||
use async_io::block_on;
|
||||
use virtual_fs::{Stub, VirtualFS};
|
||||
use warp_core::ui::{color::hex_color::coloru_from_hex_string, theme::AnsiColor};
|
||||
|
||||
use crate::settings::import::config::{ParseableConfig, ThemeType};
|
||||
|
||||
use super::{
|
||||
AlacrittyColors, AlacrittyConfig, AlacrittyTheme, PrimaryAlacrittyColors, RecursivelyParseable,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn test_parse_cobalt2() {
|
||||
let cobalt2_config = "# From the famous Cobalt2 sublime theme
|
||||
# Source https//github.com/wesbos/cobalt2/tree/master/Cobalt2
|
||||
|
||||
# Default colors
|
||||
[colors.primary]
|
||||
background = '#122637'
|
||||
foreground = '#ffffff'
|
||||
|
||||
[colors.cursor]
|
||||
text = '#122637'
|
||||
cursor = '#f0cb09'
|
||||
|
||||
# Normal colors
|
||||
[colors.normal]
|
||||
black = '#000000'
|
||||
red = '#ff0000'
|
||||
green = '#37dd21'
|
||||
yellow = '#fee409'
|
||||
blue = '#1460d2'
|
||||
magenta = '#ff005d'
|
||||
cyan = '#00bbbb'
|
||||
white = '#bbbbbb'
|
||||
|
||||
# Bright colors
|
||||
[colors.bright]
|
||||
black = '#545454'
|
||||
red = '#f40d17'
|
||||
green = '#3bcf1d'
|
||||
yellow = '#ecc809'
|
||||
blue = '#5555ff'
|
||||
magenta = '#ff55ff'
|
||||
cyan = '#6ae3f9'
|
||||
white = '#ffffff'";
|
||||
let config: AlacrittyConfig =
|
||||
toml::from_str(cobalt2_config).expect("Should be able to parse toml!");
|
||||
let ThemeType::Single(theme) = config
|
||||
.colors
|
||||
.expect("Should have read colors!")
|
||||
.parse()
|
||||
.expect("Theme should have read!")
|
||||
else {
|
||||
panic!("Should not have a dark and light theme for Alacritty!")
|
||||
};
|
||||
|
||||
// Check that the three primary colors are the same.
|
||||
assert_eq!(
|
||||
theme.accent().into_solid(),
|
||||
coloru_from_hex_string("#f0cb09").expect("Should be able to parse a color!")
|
||||
);
|
||||
assert_eq!(
|
||||
theme.background().into_solid(),
|
||||
coloru_from_hex_string("#122637").expect("Should be able to parse a color!")
|
||||
);
|
||||
assert_eq!(
|
||||
theme.foreground().into_solid(),
|
||||
coloru_from_hex_string("#ffffff").expect("Should be able to parse a color!")
|
||||
);
|
||||
|
||||
// Check some terminal colors.
|
||||
assert_eq!(
|
||||
theme.terminal_colors().bright.yellow,
|
||||
AnsiColor {
|
||||
r: 0xec,
|
||||
g: 0xc8,
|
||||
b: 0x09
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
theme.terminal_colors().bright.cyan,
|
||||
AnsiColor {
|
||||
r: 0x6a,
|
||||
g: 0xe3,
|
||||
b: 0xf9
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
theme.terminal_colors().normal.yellow,
|
||||
AnsiColor {
|
||||
r: 0xfe,
|
||||
g: 0xe4,
|
||||
b: 0x09
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
theme.terminal_colors().normal.cyan,
|
||||
AnsiColor {
|
||||
r: 0x00,
|
||||
g: 0xbb,
|
||||
b: 0xbb
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_cobalt2_missing_color() {
|
||||
let cobalt2_config = "# From the famous Cobalt2 sublime theme
|
||||
# Source https//github.com/wesbos/cobalt2/tree/master/Cobalt2
|
||||
|
||||
# Default colors
|
||||
[colors.primary]
|
||||
background = '#122637'
|
||||
foreground = '#ffffff'
|
||||
|
||||
[colors.cursor]
|
||||
text = '#122637'
|
||||
cursor = '#f0cb09'
|
||||
|
||||
# Normal colors
|
||||
[colors.normal]
|
||||
red = '#ff0000'
|
||||
green = '#37dd21'
|
||||
yellow = '#fee409'
|
||||
blue = '#1460d2'
|
||||
magenta = '#ff005d'
|
||||
cyan = '#00bbbb'
|
||||
white = '#bbbbbb'
|
||||
|
||||
# Bright colors
|
||||
[colors.bright]
|
||||
black = '#545454'
|
||||
red = '#f40d17'
|
||||
green = '#3bcf1d'
|
||||
yellow = '#ecc809'
|
||||
blue = '#5555ff'
|
||||
magenta = '#ff55ff'
|
||||
cyan = '#6ae3f9'
|
||||
white = '#ffffff'";
|
||||
let config: AlacrittyConfig =
|
||||
toml::from_str(cobalt2_config).expect("Should be able to parse toml!");
|
||||
let ThemeType::Single(theme) = config
|
||||
.colors
|
||||
.expect("Should have read colors!")
|
||||
.parse()
|
||||
.expect("Theme should have read!")
|
||||
else {
|
||||
panic!("Should not have a dark and light theme for Alacritty!")
|
||||
};
|
||||
|
||||
// Check that the three primary colors are the same.
|
||||
assert_eq!(
|
||||
theme.accent().into_solid(),
|
||||
coloru_from_hex_string("#f0cb09").expect("Should be able to parse a color!")
|
||||
);
|
||||
assert_eq!(
|
||||
theme.background().into_solid(),
|
||||
coloru_from_hex_string("#122637").expect("Should be able to parse a color!")
|
||||
);
|
||||
assert_eq!(
|
||||
theme.foreground().into_solid(),
|
||||
coloru_from_hex_string("#ffffff").expect("Should be able to parse a color!")
|
||||
);
|
||||
|
||||
// Check some terminal colors.
|
||||
assert_eq!(
|
||||
theme.terminal_colors().bright.yellow,
|
||||
AnsiColor {
|
||||
r: 0xec,
|
||||
g: 0xc8,
|
||||
b: 0x09
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
theme.terminal_colors().bright.cyan,
|
||||
AnsiColor {
|
||||
r: 0x6a,
|
||||
g: 0xe3,
|
||||
b: 0xf9
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
theme.terminal_colors().normal.yellow,
|
||||
AnsiColor {
|
||||
r: 0xfe,
|
||||
g: 0xe4,
|
||||
b: 0x09
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
theme.terminal_colors().normal.cyan,
|
||||
AnsiColor {
|
||||
r: 0x00,
|
||||
g: 0xbb,
|
||||
b: 0xbb
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
theme.terminal_colors().normal.black,
|
||||
AnsiColor {
|
||||
r: 0x18,
|
||||
g: 0x18,
|
||||
b: 0x18,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_cobalt2_missing_section() {
|
||||
let cobalt2_config = "# From the famous Cobalt2 sublime theme
|
||||
# Source https//github.com/wesbos/cobalt2/tree/master/Cobalt2
|
||||
|
||||
# Default colors
|
||||
[colors.primary]
|
||||
background = '#122637'
|
||||
foreground = '#ffffff'
|
||||
|
||||
[colors.cursor]
|
||||
text = '#122637'
|
||||
cursor = '#f0cb09'";
|
||||
let config: AlacrittyConfig =
|
||||
toml::from_str(cobalt2_config).expect("Should be able to parse toml!");
|
||||
let ThemeType::Single(theme) = config
|
||||
.colors
|
||||
.expect("Should have read colors!")
|
||||
.parse()
|
||||
.expect("Theme should have read!")
|
||||
else {
|
||||
panic!("Should not have a dark and light theme for Alacritty!")
|
||||
};
|
||||
|
||||
// Check that the three primary colors are the same.
|
||||
assert_eq!(
|
||||
theme.accent().into_solid(),
|
||||
coloru_from_hex_string("#f0cb09").expect("Should be able to parse a color!")
|
||||
);
|
||||
assert_eq!(
|
||||
theme.background().into_solid(),
|
||||
coloru_from_hex_string("#122637").expect("Should be able to parse a color!")
|
||||
);
|
||||
assert_eq!(
|
||||
theme.foreground().into_solid(),
|
||||
coloru_from_hex_string("#ffffff").expect("Should be able to parse a color!")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_cobalt2_bad_terminal_color() {
|
||||
let cobalt2_config = "# From the famous Cobalt2 sublime theme
|
||||
# Source https//github.com/wesbos/cobalt2/tree/master/Cobalt2
|
||||
|
||||
# Default colors
|
||||
[colors.primary]
|
||||
background = '#122637'
|
||||
foreground = '#ffffff'
|
||||
|
||||
[colors.cursor]
|
||||
text = '#122637'
|
||||
cursor = '#f0cb09'
|
||||
|
||||
# Normal colors
|
||||
[colors.normal]
|
||||
red = '#ff000'
|
||||
green = '#37dd21'
|
||||
yellow = '#fee409'
|
||||
blue = '#1460d2'
|
||||
magenta = '#ff005d'
|
||||
cyan = '#00bbbb'
|
||||
white = '#bbbbbb'
|
||||
|
||||
# Bright colors
|
||||
[colors.bright]
|
||||
black = '#545454'
|
||||
red = '#f40d17'
|
||||
green = '#3bcf1d'
|
||||
yellow = '#ecc809'
|
||||
blue = '#5555ff'
|
||||
magenta = '#ff55ff'
|
||||
cyan = '#6ae3f9'
|
||||
white = '#ffffff'";
|
||||
let config: AlacrittyConfig =
|
||||
toml::from_str(cobalt2_config).expect("Should be able to parse toml!");
|
||||
let _ = config
|
||||
.colors
|
||||
.expect("Should have read colors!")
|
||||
.parse()
|
||||
.expect_err("Theme should not have read!");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_cobalt2_no_colors() {
|
||||
let cobalt2_config = "# From the famous Cobalt2 sublime theme
|
||||
# Source https//github.com/wesbos/cobalt2/tree/master/Cobalt2
|
||||
|
||||
# Default colors
|
||||
[colors.primary]
|
||||
background = ''
|
||||
foreground = ''
|
||||
|
||||
[colors.cursor]
|
||||
text = ''
|
||||
cursor = ''";
|
||||
let config: AlacrittyConfig =
|
||||
toml::from_str(cobalt2_config).expect("Should be able to parse toml!");
|
||||
let _ = config
|
||||
.colors
|
||||
.expect("Should have read colors!")
|
||||
.parse()
|
||||
.expect_err("Theme should not have read!");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merge_left() {
|
||||
let mut colors = AlacrittyTheme {
|
||||
primary: Some(PrimaryAlacrittyColors {
|
||||
foreground: Some("#ffffff".to_string()),
|
||||
background: Some("#000000".to_string()),
|
||||
}),
|
||||
normal: Some(AlacrittyColors {
|
||||
black: Some("#000000".to_string()),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let second_colors = AlacrittyTheme {
|
||||
primary: Some(PrimaryAlacrittyColors {
|
||||
foreground: None,
|
||||
background: Some("#ff0000".to_string()),
|
||||
}),
|
||||
normal: Some(AlacrittyColors {
|
||||
red: Some("#ff1111".to_string()),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
colors = colors.merge_left(second_colors);
|
||||
let primary = colors
|
||||
.primary
|
||||
.clone()
|
||||
.expect("Primary colors should be present");
|
||||
let normal = colors
|
||||
.normal
|
||||
.clone()
|
||||
.expect("Normal colors should be present");
|
||||
|
||||
// Check all four combinations of present and absent in the inner struct.
|
||||
assert_eq!(primary.foreground, Some("#ffffff".to_string()));
|
||||
assert_eq!(primary.background, Some("#000000".to_string()));
|
||||
assert_eq!(normal.black, Some("#000000".to_string()));
|
||||
assert_eq!(normal.red, Some("#ff1111".to_string()));
|
||||
assert_eq!(normal.white, None);
|
||||
|
||||
// Check that None values are preserved.
|
||||
assert!(colors.bright.is_none());
|
||||
}
|
||||
|
||||
/// This is a unit test that tests reading from the default config location with one import.
|
||||
#[test]
|
||||
fn test_parse_cobalt2_from_import() {
|
||||
VirtualFS::test("test_parse_cobalt2_from_import", |dirs, mut sandbox| {
|
||||
sandbox.mkdir("config");
|
||||
sandbox.with_files(vec![
|
||||
Stub::FileWithContent(
|
||||
"config/config.toml",
|
||||
format!(
|
||||
"
|
||||
import = [{:?}]
|
||||
",
|
||||
dirs.tests().join("config").join("Cobalt2.toml")
|
||||
)
|
||||
.as_str(),
|
||||
),
|
||||
Stub::FileWithContent(
|
||||
"config/Cobalt2.toml",
|
||||
"# From the famous Cobalt2 sublime theme
|
||||
# Source https//github.com/wesbos/cobalt2/tree/master/Cobalt2
|
||||
|
||||
# Default colors
|
||||
[colors.primary]
|
||||
background = '#122637'
|
||||
foreground = '#ffffff'
|
||||
|
||||
[colors.cursor]
|
||||
text = '#122637'
|
||||
cursor = '#f0cb09'
|
||||
|
||||
# Normal colors
|
||||
[colors.normal]
|
||||
black = '#000000'
|
||||
red = '#ff0000'
|
||||
green = '#37dd21'
|
||||
yellow = '#fee409'
|
||||
blue = '#1460d2'
|
||||
magenta = '#ff005d'
|
||||
cyan = '#00bbbb'
|
||||
white = '#bbbbbb'
|
||||
|
||||
# Bright colors
|
||||
[colors.bright]
|
||||
black = '#545454'
|
||||
red = '#f40d17'
|
||||
green = '#3bcf1d'
|
||||
yellow = '#ecc809'
|
||||
blue = '#5555ff'
|
||||
magenta = '#ff55ff'
|
||||
cyan = '#6ae3f9'
|
||||
white = '#ffffff'",
|
||||
),
|
||||
]);
|
||||
let config: AlacrittyConfig = block_on(AlacrittyConfig::from_file(
|
||||
dirs.tests().join("config").join("config.toml"),
|
||||
))
|
||||
.expect("Should be able to read file!")
|
||||
.pop()
|
||||
.expect("Should have returned at least one config!");
|
||||
let ThemeType::Single(theme) = config
|
||||
.colors
|
||||
.expect("Should have read colors!")
|
||||
.parse()
|
||||
.expect("Theme should have read!")
|
||||
else {
|
||||
panic!("Should not have a dark and light theme for Alacritty!")
|
||||
};
|
||||
|
||||
// Check that the three primary colors are the same.
|
||||
assert_eq!(
|
||||
theme.accent().into_solid(),
|
||||
coloru_from_hex_string("#f0cb09").expect("Should be able to parse a color!")
|
||||
);
|
||||
assert_eq!(
|
||||
theme.background().into_solid(),
|
||||
coloru_from_hex_string("#122637").expect("Should be able to parse a color!")
|
||||
);
|
||||
assert_eq!(
|
||||
theme.foreground().into_solid(),
|
||||
coloru_from_hex_string("#ffffff").expect("Should be able to parse a color!")
|
||||
);
|
||||
|
||||
// Check some terminal colors.
|
||||
assert_eq!(
|
||||
theme.terminal_colors().bright.yellow,
|
||||
AnsiColor {
|
||||
r: 0xec,
|
||||
g: 0xc8,
|
||||
b: 0x09
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
theme.terminal_colors().bright.cyan,
|
||||
AnsiColor {
|
||||
r: 0x6a,
|
||||
g: 0xe3,
|
||||
b: 0xf9
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
theme.terminal_colors().normal.yellow,
|
||||
AnsiColor {
|
||||
r: 0xfe,
|
||||
g: 0xe4,
|
||||
b: 0x09
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
theme.terminal_colors().normal.cyan,
|
||||
AnsiColor {
|
||||
r: 0x00,
|
||||
g: 0xbb,
|
||||
b: 0xbb
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
use std::{path::PathBuf, sync::Arc};
|
||||
|
||||
use pathfinder_color::ColorU;
|
||||
use serde::Serialize;
|
||||
use strum_macros::EnumIter;
|
||||
use warp_core::ui::{
|
||||
color::hex_color::HexColorError as UiHexColorError,
|
||||
theme::{AnsiColors, WarpTheme},
|
||||
};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use thiserror::Error;
|
||||
use warpui::{fonts::FontInfo, keymap::Keystroke, DisplayIdx};
|
||||
|
||||
use crate::{
|
||||
interval_timer::IntervalTimer,
|
||||
root_view::QuakeModePinPosition,
|
||||
settings::ExtraMetaKeys,
|
||||
terminal::session_settings::{StartupShell, WorkingDirectoryConfig},
|
||||
themes::theme_creator::pick_accent_color_from_options,
|
||||
};
|
||||
#[cfg(feature = "local_fs")]
|
||||
use crate::{themes::theme_creator_body::ThemeCreatorBody, user_config};
|
||||
|
||||
use super::{alacritty_parser::AlacrittyConfig, model::TerminalType};
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
use super::iterm_parser::ITermProfile;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ThemeType {
|
||||
LightAndDark { light: WarpTheme, dark: WarpTheme },
|
||||
Single(WarpTheme),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum ThemeError {
|
||||
/// A hex color is malformatted (not missing).
|
||||
HexColorError(UiHexColorError),
|
||||
/// A value in the theme is missing.
|
||||
MissingValueError,
|
||||
}
|
||||
|
||||
#[derive(Clone, Error, Debug)]
|
||||
pub enum HotkeyError {
|
||||
#[error("A hotkey window opens in a way Warp does not support")]
|
||||
UnsupportedWindowType,
|
||||
#[error("There are multiple hotkeys configured")]
|
||||
MultipleHotkeys,
|
||||
#[error("No hotkey is set")]
|
||||
MissingHotkey,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ConfigError {
|
||||
/// A general IO error when reading the file, excluding
|
||||
/// a NotFound error.
|
||||
FileIOError(std::io::Error),
|
||||
/// A file is missing.
|
||||
FileNotFoundError,
|
||||
/// A file is readable but is formatted incorrectly.
|
||||
MalformattedFileError(PathBuf),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, Hash, PartialEq, EnumIter, Serialize)]
|
||||
pub enum SettingType {
|
||||
Theme,
|
||||
OptionAsMeta,
|
||||
MouseAndScrollReporting,
|
||||
Font,
|
||||
DefaultShell,
|
||||
WorkingDirectory,
|
||||
HotkeyMode,
|
||||
WindowSize,
|
||||
CopyOnSelect,
|
||||
Opacity,
|
||||
CursorBlinking,
|
||||
}
|
||||
|
||||
impl SettingType {
|
||||
pub fn get_name(&self) -> &'static str {
|
||||
match self {
|
||||
SettingType::Theme => "Theme",
|
||||
SettingType::OptionAsMeta => "Option as Meta",
|
||||
SettingType::MouseAndScrollReporting => "Mouse/Scroll Reporting",
|
||||
SettingType::Font => "Font",
|
||||
SettingType::DefaultShell => "Default Shell",
|
||||
SettingType::WorkingDirectory => "Working Directory",
|
||||
SettingType::HotkeyMode => "Global hotkey",
|
||||
SettingType::WindowSize => "Window Dimensions",
|
||||
SettingType::CopyOnSelect => "Copy On Select",
|
||||
SettingType::Opacity => "Window Opacity",
|
||||
SettingType::CursorBlinking => "Cursor Blinking",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||
pub struct MouseAndScrollReporting {
|
||||
pub mouse_reporting: bool,
|
||||
pub scroll_reporting: bool,
|
||||
}
|
||||
|
||||
impl Default for MouseAndScrollReporting {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mouse_reporting: true,
|
||||
scroll_reporting: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct ImportedFont {
|
||||
pub family: Option<String>,
|
||||
pub size: Option<f32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct QuakeModeWindow {
|
||||
pub keystroke: Keystroke,
|
||||
pub autohide: bool,
|
||||
pub screen: Option<DisplayIdx>,
|
||||
pub pin_position: QuakeModePinPosition,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum GlobalHotkey {
|
||||
Activation(Keystroke),
|
||||
QuakeMode(QuakeModeWindow),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct OpacitySettings {
|
||||
pub opacity: Option<u8>,
|
||||
pub blur_radius: Option<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Config {
|
||||
pub theme: ImportableSetting<Result<ThemeType, ThemeError>>,
|
||||
pub option_as_meta: ImportableSetting<ExtraMetaKeys>,
|
||||
pub mouse_and_scroll_reporting: ImportableSetting<Option<MouseAndScrollReporting>>,
|
||||
pub font: ImportableSetting<ImportedFont>,
|
||||
pub default_shell: ImportableSetting<Option<StartupShell>>,
|
||||
pub working_directory: ImportableSetting<Option<WorkingDirectoryConfig>>,
|
||||
pub terminal_name: String,
|
||||
pub description: Option<String>,
|
||||
pub hotkey_mode: ImportableSetting<Result<GlobalHotkey, HotkeyError>>,
|
||||
pub opacity: ImportableSetting<OpacitySettings>,
|
||||
pub window_size: ImportableSetting<(Option<u16>, Option<u16>)>,
|
||||
pub copy_on_select: ImportableSetting<Option<bool>>,
|
||||
pub cursor_blinking: ImportableSetting<Option<bool>>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Creates all valid Configs for a given ParseableConfig type.
|
||||
/// Each profile (in terminals that support profiles) is mapped to a separate Config.
|
||||
pub async fn create_from_external_configs<Input: ParseableConfig>(
|
||||
fonts: Arc<Vec<FontInfo>>,
|
||||
) -> (Result<Vec<Self>, ConfigError>, IntervalTimer) {
|
||||
let mut timer = IntervalTimer::new();
|
||||
let config = match Input::from_config_paths(&mut timer).await {
|
||||
Ok(config) => config,
|
||||
Err(err) => {
|
||||
return (Err(err), timer);
|
||||
}
|
||||
};
|
||||
let configs = config
|
||||
.into_iter()
|
||||
.map(|config| config.remove_default_values())
|
||||
.map(|config| config.parse(&fonts))
|
||||
.filter(|config| config.is_valid())
|
||||
.collect();
|
||||
timer.mark_interval_end("TERMINAL_SETTINGS_PARSED");
|
||||
(Ok(configs), timer)
|
||||
}
|
||||
|
||||
pub(super) fn write_theme(&self) -> Option<ThemeType> {
|
||||
#[cfg(feature = "local_fs")]
|
||||
{
|
||||
if !self.theme.should_import {
|
||||
return None;
|
||||
}
|
||||
|
||||
let Ok(theme) = self.theme.value() else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let dir = user_config::themes_dir();
|
||||
|
||||
match theme {
|
||||
ThemeType::LightAndDark { light, dark } => {
|
||||
let light_theme_yaml_file_name =
|
||||
format!("{}_light_theme.yaml", self.terminal_name);
|
||||
let light_written = ThemeCreatorBody::write_theme(
|
||||
light,
|
||||
dir.clone(),
|
||||
light_theme_yaml_file_name,
|
||||
None,
|
||||
|_| light.clone(),
|
||||
);
|
||||
|
||||
let dark_theme_yaml_file_name =
|
||||
format!("{}_dark_theme.yaml", self.terminal_name);
|
||||
let dark_written = ThemeCreatorBody::write_theme(
|
||||
dark,
|
||||
dir,
|
||||
dark_theme_yaml_file_name,
|
||||
None,
|
||||
|_| dark.clone(),
|
||||
);
|
||||
|
||||
if let (Some(light), Some(dark)) = (light_written, dark_written) {
|
||||
Some(ThemeType::LightAndDark { light, dark })
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
ThemeType::Single(normal) => {
|
||||
let theme_yaml_file_name = format!("{}_theme.yaml", self.terminal_name);
|
||||
ThemeCreatorBody::write_theme(normal, dir, theme_yaml_file_name, None, |_| {
|
||||
ThemeType::Single(normal.clone())
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "local_fs"))]
|
||||
{
|
||||
log::warn!("Tried to save theme without a local filesystem.");
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Generates a Config from the given TerminalType.
|
||||
pub async fn create_from_terminal_type(
|
||||
terminal: TerminalType,
|
||||
fonts: Arc<Vec<FontInfo>>,
|
||||
) -> (Result<Vec<Self>, ConfigError>, IntervalTimer) {
|
||||
match terminal {
|
||||
TerminalType::Alacritty => {
|
||||
Config::create_from_external_configs::<AlacrittyConfig>(fonts).await
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
TerminalType::ITerm => {
|
||||
Config::create_from_external_configs::<ITermProfile>(fonts).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the list of [`SettingType`]s that have an importable setting associated with it.
|
||||
pub(super) fn valid_setting_types(&self) -> Vec<SettingType> {
|
||||
let mut out = vec![];
|
||||
let default_config = Config::default();
|
||||
if self.theme.value().is_ok() {
|
||||
out.push(self.theme.setting_type().clone());
|
||||
}
|
||||
|
||||
if *self.option_as_meta.value() != *default_config.option_as_meta.value() {
|
||||
out.push(self.option_as_meta.setting_type().clone());
|
||||
}
|
||||
|
||||
if *self.mouse_and_scroll_reporting.value()
|
||||
!= *default_config.mouse_and_scroll_reporting.value()
|
||||
{
|
||||
out.push(self.mouse_and_scroll_reporting.setting_type().clone());
|
||||
}
|
||||
|
||||
if *self.font.value() != *default_config.font.value() {
|
||||
out.push(self.font.setting_type().clone());
|
||||
}
|
||||
|
||||
if *self.default_shell.value() != *default_config.default_shell.value() {
|
||||
out.push(self.default_shell.setting_type().clone());
|
||||
}
|
||||
|
||||
if *self.working_directory.value() != *default_config.working_directory.value() {
|
||||
out.push(self.working_directory.setting_type().clone());
|
||||
}
|
||||
if *self.copy_on_select.value() != *default_config.copy_on_select.value() {
|
||||
out.push(self.copy_on_select.setting_type().clone());
|
||||
}
|
||||
if *self.cursor_blinking.value() != *default_config.cursor_blinking.value() {
|
||||
out.push(self.cursor_blinking.setting_type().clone());
|
||||
}
|
||||
if *self.window_size.value() != *default_config.window_size.value() {
|
||||
out.push(self.window_size.setting_type().clone());
|
||||
}
|
||||
if *self.opacity.value() != *default_config.opacity.value() {
|
||||
out.push(self.opacity.setting_type().clone());
|
||||
}
|
||||
|
||||
if self.hotkey_mode.value().is_ok() {
|
||||
out.push(self.hotkey_mode.setting_type().clone());
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
/// Returns whether or not this Config contains valid importable settings.
|
||||
pub fn is_valid(&self) -> bool {
|
||||
!self.valid_setting_types().is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Used for telemetry.
|
||||
#[derive(Clone, Serialize)]
|
||||
pub struct ParsedTerminalSetting {
|
||||
pub setting_type: SettingType,
|
||||
pub was_imported_by_user: bool,
|
||||
}
|
||||
|
||||
/// A wrapper for a setting along with its display name
|
||||
/// and whether or not the user has selected to import it.
|
||||
#[derive(Debug)]
|
||||
pub struct ImportableSetting<T> {
|
||||
pub(super) setting: T,
|
||||
setting_type: SettingType,
|
||||
pub should_import: bool,
|
||||
}
|
||||
|
||||
impl<T> ImportableSetting<T> {
|
||||
pub fn new(setting: T, setting_type: SettingType) -> Self {
|
||||
Self {
|
||||
setting,
|
||||
setting_type,
|
||||
should_import: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the a reference to the inner setting.
|
||||
pub fn value(&self) -> &T {
|
||||
&self.setting
|
||||
}
|
||||
|
||||
/// Returns the setting type.
|
||||
pub fn setting_type(&self) -> &SettingType {
|
||||
&self.setting_type
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Clone> ImportableSetting<T> {
|
||||
/// Returns Some(value) if we should import this setting and None otherwise.
|
||||
pub fn importable_value(&self) -> Option<T> {
|
||||
if self.should_import {
|
||||
Some(self.setting.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
theme: ImportableSetting::new(Err(ThemeError::MissingValueError), SettingType::Theme),
|
||||
option_as_meta: ImportableSetting::new(Default::default(), SettingType::OptionAsMeta),
|
||||
mouse_and_scroll_reporting: ImportableSetting::new(
|
||||
None,
|
||||
SettingType::MouseAndScrollReporting,
|
||||
),
|
||||
terminal_name: "".to_string(),
|
||||
font: ImportableSetting::new(
|
||||
ImportedFont {
|
||||
family: None,
|
||||
size: None,
|
||||
},
|
||||
SettingType::Font,
|
||||
),
|
||||
description: None,
|
||||
default_shell: ImportableSetting::new(None, SettingType::DefaultShell),
|
||||
working_directory: ImportableSetting::new(None, SettingType::WorkingDirectory),
|
||||
hotkey_mode: ImportableSetting::new(
|
||||
Err(HotkeyError::MissingHotkey),
|
||||
SettingType::HotkeyMode,
|
||||
),
|
||||
window_size: ImportableSetting::new((None, None), SettingType::WindowSize),
|
||||
opacity: ImportableSetting::new(
|
||||
OpacitySettings {
|
||||
opacity: None,
|
||||
blur_radius: None,
|
||||
},
|
||||
SettingType::Opacity,
|
||||
),
|
||||
copy_on_select: ImportableSetting::new(None, SettingType::CopyOnSelect),
|
||||
cursor_blinking: ImportableSetting::new(None, SettingType::CursorBlinking),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait ParseableConfig: PartialEq + Sized + Send {
|
||||
/// Reads the file at the given path into the struct implementing ParseableConfig.
|
||||
async fn from_file(path: PathBuf) -> Result<Vec<Self>, ConfigError>;
|
||||
|
||||
/// Creates a Warp-readable `Config`. Sets corresponding errors if values have
|
||||
/// not been configured from the default.
|
||||
fn parse(self, fonts: &[FontInfo]) -> Config;
|
||||
|
||||
/// Tries to read configuration from the list of default paths.
|
||||
///
|
||||
/// NOTE: Returns an error if
|
||||
/// none of the files are found or any of the files throw some other error.
|
||||
async fn from_config_paths(timer: &mut IntervalTimer) -> Result<Vec<Self>, ConfigError> {
|
||||
for path in Self::default_paths() {
|
||||
match Self::from_file(path).await {
|
||||
Err(ConfigError::FileNotFoundError) => continue,
|
||||
result => {
|
||||
timer.mark_interval_end("TERMINAL_SETTINGS_READ_FROM_FILE");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
timer.mark_interval_end("TERMINAL_SETTINGS_READ_FROM_FILE");
|
||||
Err(ConfigError::FileNotFoundError)
|
||||
}
|
||||
|
||||
/// Returns the list of paths in which to search for configuration files.
|
||||
fn default_paths() -> Vec<PathBuf>;
|
||||
|
||||
/// Strips all fields that have not been configured from the default.
|
||||
fn remove_default_values(self) -> Self;
|
||||
}
|
||||
|
||||
pub fn calculate_accent_color(
|
||||
background: impl Into<ColorU>,
|
||||
foreground: impl Into<ColorU>,
|
||||
cursor_color: impl Into<ColorU>,
|
||||
bright: AnsiColors,
|
||||
) -> ColorU {
|
||||
let cursor_color = cursor_color.into();
|
||||
let foreground = foreground.into();
|
||||
if cursor_color == foreground {
|
||||
pick_accent_color_from_options(
|
||||
&[background.into(), foreground],
|
||||
// Exclude white and black so that we don't choose either.
|
||||
&[
|
||||
bright.red.into(),
|
||||
bright.green.into(),
|
||||
bright.yellow.into(),
|
||||
bright.magenta.into(),
|
||||
bright.cyan.into(),
|
||||
bright.blue.into(),
|
||||
],
|
||||
)
|
||||
} else {
|
||||
cursor_color
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
||||
pub mod alacritty_parser;
|
||||
pub mod config;
|
||||
#[cfg(target_os = "macos")]
|
||||
pub mod iterm_parser;
|
||||
pub mod model;
|
||||
pub mod view;
|
||||
@@ -0,0 +1,275 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::interval_timer::IntervalTimer;
|
||||
use crate::settings::import::config::{Config, ConfigError};
|
||||
use crate::{send_telemetry_from_ctx, TelemetryEvent};
|
||||
|
||||
use serde::Serialize;
|
||||
use strum::IntoEnumIterator;
|
||||
use strum_macros::{EnumDiscriminants, EnumIter};
|
||||
use warp_core::features::FeatureFlag;
|
||||
use warpui::Entity;
|
||||
use warpui::ModelContext;
|
||||
use warpui::SingletonEntity;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
use super::config::HotkeyError;
|
||||
use super::config::SettingType;
|
||||
use super::config::ThemeType;
|
||||
|
||||
#[derive(Clone, Copy, Debug, EnumDiscriminants, Eq, Hash, PartialEq)]
|
||||
#[strum_discriminants(derive(EnumIter, Hash, Serialize))]
|
||||
#[strum_discriminants(name(TerminalType))]
|
||||
pub enum TerminalTypeAndProfile {
|
||||
Alacritty,
|
||||
#[cfg(target_os = "macos")]
|
||||
ITerm(usize),
|
||||
}
|
||||
|
||||
pub struct CompletedParseEvent {
|
||||
pub terminal: TerminalType,
|
||||
}
|
||||
|
||||
pub struct ImportedConfigModel {
|
||||
started: bool,
|
||||
parsed_terminals: HashMap<TerminalType, Result<Vec<Config>, ConfigError>>,
|
||||
}
|
||||
|
||||
impl ImportedConfigModel {
|
||||
pub fn new(_ctx: &mut ModelContext<Self>) -> Self {
|
||||
ImportedConfigModel {
|
||||
parsed_terminals: Default::default(),
|
||||
started: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub fn search_for_settings_to_import(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
use itertools::Itertools;
|
||||
use std::sync::Arc;
|
||||
use strum::IntoEnumIterator;
|
||||
self.started = true;
|
||||
|
||||
let loaded_system_fonts = warpui::fonts::Cache::handle(ctx)
|
||||
.update(ctx, |font_cache, ctx| font_cache.all_system_fonts(ctx));
|
||||
ctx.spawn(loaded_system_fonts, |_, fonts, ctx| {
|
||||
let fonts = fonts
|
||||
.into_iter()
|
||||
.map(|(_family_id, font_info)| font_info)
|
||||
.collect_vec();
|
||||
let fonts_ref = Arc::new(fonts);
|
||||
for terminal_type in TerminalType::iter() {
|
||||
if terminal_type == TerminalType::Alacritty
|
||||
&& !FeatureFlag::AlacrittySettingsImport.is_enabled()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let fonts_ref_clone = Arc::clone(&fonts_ref);
|
||||
ctx.spawn(
|
||||
async move {
|
||||
Config::create_from_terminal_type(terminal_type, fonts_ref_clone).await
|
||||
},
|
||||
move |model, output, ctx| {
|
||||
model.write_parse_results(terminal_type, output, ctx);
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub fn is_started(&self) -> bool {
|
||||
self.started
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn maybe_send_multiple_hotkeys_telemetry_event(
|
||||
&self,
|
||||
terminal_type: &TerminalType,
|
||||
configs: &Result<Vec<Config>, ConfigError>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
if let TerminalType::ITerm = terminal_type {
|
||||
if let Ok(configs) = configs {
|
||||
if configs.iter().any(|config| {
|
||||
matches!(
|
||||
config.hotkey_mode.setting,
|
||||
Err(HotkeyError::MultipleHotkeys)
|
||||
)
|
||||
}) {
|
||||
send_telemetry_from_ctx!(TelemetryEvent::ITermMultipleHotkeys, ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn write_parse_results(
|
||||
&mut self,
|
||||
terminal_type: TerminalType,
|
||||
(configs, timer): (Result<Vec<Config>, ConfigError>, IntervalTimer),
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
send_telemetry_from_ctx!(
|
||||
TelemetryEvent::SettingsImportConfigParsed {
|
||||
timing_data: timer.compute_stats(),
|
||||
terminal_type,
|
||||
settings_shown_to_user: configs
|
||||
.as_ref()
|
||||
.ok()
|
||||
.and_then(|configs| configs.first())
|
||||
.map(|config| config.valid_setting_types())
|
||||
},
|
||||
ctx
|
||||
);
|
||||
#[cfg(target_os = "macos")]
|
||||
self.maybe_send_multiple_hotkeys_telemetry_event(&terminal_type, &configs, ctx);
|
||||
self.parsed_terminals.insert(terminal_type, configs);
|
||||
ctx.emit(CompletedParseEvent {
|
||||
terminal: terminal_type,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn configs(&self) -> impl Iterator<Item = (TerminalTypeAndProfile, &Config)> {
|
||||
self.parsed_terminals
|
||||
.iter()
|
||||
.filter_map(|(terminal, parse_result)| {
|
||||
parse_result.as_ref().ok().map(|value| (terminal, value))
|
||||
})
|
||||
.flat_map(|(discriminant, vec)| match discriminant {
|
||||
TerminalType::Alacritty => vec
|
||||
.iter()
|
||||
.take(1)
|
||||
.map(|item| (TerminalTypeAndProfile::Alacritty, item))
|
||||
.collect::<Vec<(TerminalTypeAndProfile, &Config)>>()
|
||||
.into_iter(),
|
||||
#[cfg(target_os = "macos")]
|
||||
TerminalType::ITerm => vec
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, item)| (TerminalTypeAndProfile::ITerm(idx), item))
|
||||
.collect::<Vec<(TerminalTypeAndProfile, &Config)>>()
|
||||
.into_iter(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn config(&self, profile: &TerminalTypeAndProfile) -> Option<&Config> {
|
||||
self.parsed_terminals
|
||||
.get(&TerminalType::from(profile))
|
||||
.map(|vec| match profile {
|
||||
TerminalTypeAndProfile::Alacritty => vec.as_ref().ok().and_then(|vec| vec.first()),
|
||||
#[cfg(target_os = "macos")]
|
||||
TerminalTypeAndProfile::ITerm(idx) => {
|
||||
vec.as_ref().ok().and_then(|vec| vec.get(*idx))
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
log::warn!("Attempted to access an invalid profile.");
|
||||
None
|
||||
})
|
||||
}
|
||||
|
||||
fn config_mut(&mut self, profile: &TerminalTypeAndProfile) -> Option<&mut Config> {
|
||||
self.parsed_terminals
|
||||
.get_mut(&TerminalType::from(profile))
|
||||
.map(|vec| match profile {
|
||||
TerminalTypeAndProfile::Alacritty => {
|
||||
vec.as_mut().ok().and_then(|vec| vec.first_mut())
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
TerminalTypeAndProfile::ITerm(idx) => {
|
||||
vec.as_mut().ok().and_then(|vec| vec.get_mut(*idx))
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
log::warn!("Attempted to access an invalid profile.");
|
||||
None
|
||||
})
|
||||
}
|
||||
|
||||
pub fn toggle_should_import(
|
||||
&mut self,
|
||||
profile: &TerminalTypeAndProfile,
|
||||
setting: &SettingType,
|
||||
) {
|
||||
let Some(config) = self.config_mut(profile) else {
|
||||
log::warn!("Attempted to toggle import on an invalid profile!");
|
||||
return;
|
||||
};
|
||||
match setting {
|
||||
SettingType::Theme => config.theme.should_import = !config.theme.should_import,
|
||||
SettingType::OptionAsMeta => {
|
||||
config.option_as_meta.should_import = !config.option_as_meta.should_import
|
||||
}
|
||||
SettingType::MouseAndScrollReporting => {
|
||||
config.mouse_and_scroll_reporting.should_import =
|
||||
!config.mouse_and_scroll_reporting.should_import
|
||||
}
|
||||
SettingType::Font => config.font.should_import = !config.font.should_import,
|
||||
SettingType::DefaultShell => {
|
||||
config.default_shell.should_import = !config.default_shell.should_import
|
||||
}
|
||||
SettingType::WorkingDirectory => {
|
||||
config.working_directory.should_import = !config.working_directory.should_import
|
||||
}
|
||||
SettingType::HotkeyMode => {
|
||||
config.hotkey_mode.should_import = !config.hotkey_mode.should_import
|
||||
}
|
||||
SettingType::Opacity => config.opacity.should_import = !config.opacity.should_import,
|
||||
SettingType::WindowSize => {
|
||||
config.window_size.should_import = !config.window_size.should_import
|
||||
}
|
||||
SettingType::CopyOnSelect => {
|
||||
config.copy_on_select.should_import = !config.copy_on_select.should_import
|
||||
}
|
||||
SettingType::CursorBlinking => {
|
||||
config.cursor_blinking.should_import = !config.cursor_blinking.should_import
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn should_import(&self, profile: &TerminalTypeAndProfile, setting: &SettingType) -> bool {
|
||||
let Some(config) = self.config(profile) else {
|
||||
log::warn!("Attempted to read should_import on an invalid profile!");
|
||||
return false;
|
||||
};
|
||||
match setting {
|
||||
SettingType::Theme => config.theme.should_import,
|
||||
SettingType::OptionAsMeta => config.option_as_meta.should_import,
|
||||
SettingType::MouseAndScrollReporting => config.mouse_and_scroll_reporting.should_import,
|
||||
SettingType::Font => config.font.should_import,
|
||||
SettingType::DefaultShell => config.default_shell.should_import,
|
||||
SettingType::WorkingDirectory => config.working_directory.should_import,
|
||||
SettingType::HotkeyMode => config.hotkey_mode.should_import,
|
||||
SettingType::Opacity => config.opacity.should_import,
|
||||
SettingType::WindowSize => config.window_size.should_import,
|
||||
SettingType::CopyOnSelect => config.copy_on_select.should_import,
|
||||
SettingType::CursorBlinking => config.cursor_blinking.should_import,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn write_theme(&self, profile: &TerminalTypeAndProfile) -> Option<ThemeType> {
|
||||
self.config(profile)
|
||||
.map(|config| config.write_theme())
|
||||
.unwrap_or_else(|| {
|
||||
log::warn!("Attempted to write the theme from an invalid profile.");
|
||||
None
|
||||
})
|
||||
}
|
||||
|
||||
pub fn finished_searching_for_settings(&self) -> bool {
|
||||
TerminalType::iter()
|
||||
.filter(|terminal_type| {
|
||||
if !FeatureFlag::AlacrittySettingsImport.is_enabled() {
|
||||
*terminal_type != TerminalType::Alacritty
|
||||
} else {
|
||||
true
|
||||
}
|
||||
})
|
||||
.all(|terminal| self.parsed_terminals.contains_key(&terminal))
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for ImportedConfigModel {
|
||||
type Event = CompletedParseEvent;
|
||||
}
|
||||
|
||||
impl SingletonEntity for ImportedConfigModel {}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,429 @@
|
||||
use settings::{Setting as _, SettingsManager};
|
||||
use warp_core::features::FeatureFlag;
|
||||
use warpui::{rendering::GPUPowerPreference, AppContext, SingletonEntity};
|
||||
use warpui_extras::user_preferences;
|
||||
|
||||
use crate::{
|
||||
ai::cloud_agent_settings::CloudAgentSettings,
|
||||
appearance,
|
||||
banner::BannerState,
|
||||
drive::settings::WarpDriveSettings,
|
||||
report_if_error,
|
||||
resource_center::TipsCompleted,
|
||||
search::command_search::settings::CommandSearchSettings,
|
||||
terminal::{
|
||||
alt_screen_reporting::AltScreenReporting,
|
||||
general_settings::GeneralSettings,
|
||||
keys_settings::KeysSettings,
|
||||
ligature_settings::LigatureSettings,
|
||||
safe_mode_settings::SafeModeSettings,
|
||||
session_settings::{SessionSettings, SessionSettingsChangedEvent},
|
||||
settings::TerminalSettings,
|
||||
shared_session::settings::SharedSessionSettings,
|
||||
warpify::settings::WarpifySettings,
|
||||
BlockListSettings,
|
||||
},
|
||||
undo_close::UndoCloseSettings,
|
||||
window_settings::WindowSettings,
|
||||
workflows::aliases::WorkflowAliases,
|
||||
workspace::tab_settings::TabSettings,
|
||||
};
|
||||
|
||||
use warp_core::semantic_selection::SemanticSelection;
|
||||
|
||||
use super::{
|
||||
app_icon::AppIconSettings, app_installation_detection::UserAppInstallDetectionSettings,
|
||||
cloud_preferences::CloudPreferencesSettings, initializer::SettingsInitializer,
|
||||
native_preference::NativePreferenceSettings, AISettings, AccessibilitySettings,
|
||||
AliasExpansionSettings, AppEditorSettings, BlockVisibilitySettings, ChangelogSettings,
|
||||
CodeSettings, DebugSettings, EmacsBindingsSettings, FontSettings, FontSettingsChangedEvent,
|
||||
GPUSettings, InputBoxType, InputModeSettings, InputSettings, PaneSettings,
|
||||
SameLinePromptBlockSettings, ScrollSettings, SelectionSettings, SshSettings, ThemeSettings,
|
||||
VimBannerSettings, WarpDrivePrivacySettings,
|
||||
};
|
||||
|
||||
pub struct UserDefaultsOnStartup {
|
||||
pub should_restore_session: bool,
|
||||
pub tips_data: TipsCompleted,
|
||||
pub user_default_shell_unsupported_banner_state: BannerState,
|
||||
pub settings_file_error: Option<super::SettingsFileError>,
|
||||
}
|
||||
|
||||
/// Registers all settings groups with the application context.
|
||||
///
|
||||
/// This populates the `SettingsManager` with storage keys, default values,
|
||||
/// and hierarchy info for every setting. It does not set up appearance,
|
||||
/// rendering config, or event subscriptions.
|
||||
pub fn register_all_settings(ctx: &mut AppContext) {
|
||||
BlockListSettings::register(ctx);
|
||||
BlockVisibilitySettings::register(ctx);
|
||||
DebugSettings::register(ctx);
|
||||
SessionSettings::register(ctx);
|
||||
KeysSettings::register(ctx);
|
||||
FontSettings::register(ctx);
|
||||
TabSettings::register(ctx);
|
||||
WindowSettings::register(ctx);
|
||||
SafeModeSettings::register(ctx);
|
||||
TerminalSettings::register(ctx);
|
||||
PaneSettings::register(ctx);
|
||||
CommandSearchSettings::register(ctx);
|
||||
AliasExpansionSettings::register(ctx);
|
||||
CodeSettings::register(ctx);
|
||||
LigatureSettings::register(ctx);
|
||||
GPUSettings::register(ctx);
|
||||
ChangelogSettings::register(ctx);
|
||||
GeneralSettings::register(ctx);
|
||||
AISettings::register_and_subscribe_to_events(ctx);
|
||||
CloudAgentSettings::register(ctx);
|
||||
ScrollSettings::register(ctx);
|
||||
SelectionSettings::register(ctx);
|
||||
InputModeSettings::register(ctx);
|
||||
ThemeSettings::register(ctx);
|
||||
AccessibilitySettings::register(ctx);
|
||||
NativePreferenceSettings::register(ctx);
|
||||
CloudPreferencesSettings::register(ctx);
|
||||
WarpDrivePrivacySettings::register(ctx);
|
||||
UserAppInstallDetectionSettings::register(ctx);
|
||||
AppIconSettings::register(ctx);
|
||||
AppEditorSettings::register(ctx);
|
||||
InputSettings::register(ctx);
|
||||
WarpifySettings::register(ctx);
|
||||
AltScreenReporting::register(ctx);
|
||||
UndoCloseSettings::register(ctx);
|
||||
SshSettings::register(ctx);
|
||||
VimBannerSettings::register(ctx);
|
||||
SharedSessionSettings::register(ctx);
|
||||
WarpDriveSettings::register(ctx);
|
||||
WorkflowAliases::register(ctx);
|
||||
EmacsBindingsSettings::register(ctx);
|
||||
SameLinePromptBlockSettings::register(ctx);
|
||||
SemanticSelection::register(ctx);
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
super::LinuxAppConfiguration::register(ctx);
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
crate::util::file::external_editor::EditorSettings::register(ctx);
|
||||
}
|
||||
|
||||
/// Key written to the platform-native store after the first successful
|
||||
/// migration of public settings into `settings.toml`. Its presence prevents
|
||||
/// re-migration when the user intentionally deletes the TOML file to reset.
|
||||
const SETTINGS_FILE_MIGRATION_COMPLETE_KEY: &str = "SettingsFileMigrationComplete";
|
||||
|
||||
pub fn init(
|
||||
startup_toml_parse_error: Option<user_preferences::Error>,
|
||||
ctx: &mut AppContext,
|
||||
) -> UserDefaultsOnStartup {
|
||||
ctx.add_singleton_model(|_| SettingsInitializer::new());
|
||||
|
||||
register_all_settings(ctx);
|
||||
|
||||
// One-time migration: copy public settings from the platform-native store
|
||||
// into the TOML file so existing users don't lose their customizations
|
||||
// when the settings file feature is first enabled.
|
||||
if needs_settings_file_migration(ctx) {
|
||||
migrate_native_settings_to_settings_file(ctx);
|
||||
}
|
||||
|
||||
let use_thin_strokes = *FontSettings::as_ref(ctx).use_thin_strokes;
|
||||
|
||||
let general_settings = GeneralSettings::as_ref(ctx);
|
||||
let tips_features_used = general_settings.welcome_tips_features_used.clone();
|
||||
let tips_skipped_or_completed = *general_settings.welcome_tips_skipped_or_completed;
|
||||
let user_default_shell_unsupported_banner_state =
|
||||
*general_settings.user_default_shell_unsupported_banner_state;
|
||||
let should_restore_session = *general_settings.restore_session;
|
||||
|
||||
// Validate all public settings to detect values that parsed as TOML
|
||||
// but cannot be deserialized into the expected Rust types.
|
||||
let invalid_setting_keys =
|
||||
settings::SettingsManager::as_ref(ctx).validate_all_public_settings(ctx);
|
||||
let settings_file_error = if let Some(err) = startup_toml_parse_error {
|
||||
Some(super::SettingsFileError::FileParseFailed(err.to_string()))
|
||||
} else if !invalid_setting_keys.is_empty() {
|
||||
Some(super::SettingsFileError::InvalidSettings(
|
||||
invalid_setting_keys,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let user_defaults_on_startup = UserDefaultsOnStartup {
|
||||
should_restore_session,
|
||||
tips_data: TipsCompleted::new(tips_features_used, tips_skipped_or_completed),
|
||||
user_default_shell_unsupported_banner_state,
|
||||
settings_file_error,
|
||||
};
|
||||
|
||||
let gpu_settings = GPUSettings::as_ref(ctx);
|
||||
let prefer_low_power_gpu = *gpu_settings.prefer_low_power_gpu.value();
|
||||
let backend_preference = *gpu_settings.preferred_backend.value();
|
||||
|
||||
// Update the rendering config.
|
||||
ctx.update_rendering_config(|config| {
|
||||
config.glyphs.use_thin_strokes = use_thin_strokes;
|
||||
config.gpu_power_preference = if prefer_low_power_gpu {
|
||||
GPUPowerPreference::LowPower
|
||||
} else {
|
||||
GPUPowerPreference::default()
|
||||
};
|
||||
config.backend_preference = backend_preference;
|
||||
});
|
||||
|
||||
ctx.subscribe_to_model(&FontSettings::handle(ctx), |font_settings, event, ctx| {
|
||||
if matches!(event, FontSettingsChangedEvent::UseThinStrokes { .. }) {
|
||||
let use_thin_strokes = *font_settings.as_ref(ctx).use_thin_strokes;
|
||||
ctx.update_rendering_config(|config| {
|
||||
config.glyphs.use_thin_strokes = use_thin_strokes;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Keep input_box_type in sync whenever honor_ps1 changes —
|
||||
// Classic when PS1 is honored, Universal otherwise.
|
||||
ctx.subscribe_to_model(
|
||||
&SessionSettings::handle(ctx),
|
||||
|session_settings, event, ctx| {
|
||||
if let SessionSettingsChangedEvent::HonorPS1 { .. } = event {
|
||||
let new_honor_ps1 = *session_settings.as_ref(ctx).honor_ps1;
|
||||
let new_type = if new_honor_ps1 {
|
||||
InputBoxType::Classic
|
||||
} else {
|
||||
InputBoxType::Universal
|
||||
};
|
||||
InputSettings::handle(ctx).update(ctx, |input_settings, ctx| {
|
||||
report_if_error!(input_settings.input_box_type.set_value(new_type, ctx));
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
appearance::register(ctx);
|
||||
|
||||
// Set up hot-reload for the settings file. When the WarpConfig watcher
|
||||
// detects a change to settings.toml, reload preferences from disk and
|
||||
// push changed values into setting models.
|
||||
#[cfg(feature = "local_fs")]
|
||||
{
|
||||
let prefs = <settings::PublicPreferences as warpui::SingletonEntity>::as_ref(ctx);
|
||||
if prefs.is_settings_file() {
|
||||
ctx.subscribe_to_model(
|
||||
&crate::user_config::WarpConfig::handle(ctx),
|
||||
handle_warp_config_change,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
user_defaults_on_startup
|
||||
}
|
||||
|
||||
/// Handles a `WarpConfig` change event, reloading settings from disk when
|
||||
/// the settings file is modified, created, or deleted.
|
||||
#[cfg(feature = "local_fs")]
|
||||
fn handle_warp_config_change(
|
||||
_: warpui::ModelHandle<crate::user_config::WarpConfig>,
|
||||
event: &crate::user_config::WarpConfigUpdateEvent,
|
||||
ctx: &mut AppContext,
|
||||
) {
|
||||
use crate::user_config::{WarpConfig, WarpConfigUpdateEvent};
|
||||
|
||||
if !matches!(event, WarpConfigUpdateEvent::Settings) {
|
||||
return;
|
||||
}
|
||||
let prefs = <settings::PublicPreferences as warpui::SingletonEntity>::as_ref(ctx);
|
||||
if let Err(err) = prefs.reload_from_disk() {
|
||||
log::warn!("Settings file reload failed: {err}");
|
||||
WarpConfig::handle(ctx).update(ctx, |_, ctx| {
|
||||
ctx.emit(WarpConfigUpdateEvent::SettingsErrors(
|
||||
super::SettingsFileError::FileParseFailed(err.to_string()),
|
||||
));
|
||||
});
|
||||
return;
|
||||
}
|
||||
let failed_keys = settings::SettingsManager::handle(ctx)
|
||||
.update(ctx, |manager, ctx| manager.reload_all_public_settings(ctx));
|
||||
WarpConfig::handle(ctx).update(ctx, |_, ctx| {
|
||||
if failed_keys.is_empty() {
|
||||
ctx.emit(WarpConfigUpdateEvent::SettingsErrorsCleared);
|
||||
} else {
|
||||
ctx.emit(WarpConfigUpdateEvent::SettingsErrors(
|
||||
super::SettingsFileError::InvalidSettings(failed_keys),
|
||||
));
|
||||
}
|
||||
});
|
||||
}
|
||||
/// Returns the platform-native preferences backend.
|
||||
///
|
||||
/// Used directly for private settings, and also as the fallback for public
|
||||
/// settings when the settings file feature flag is disabled.
|
||||
fn init_platform_native_preferences() -> user_preferences::Model {
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(test)] {
|
||||
Box::<user_preferences::in_memory::InMemoryPreferences>::default()
|
||||
} else if #[cfg(any(target_os = "linux", feature = "integration_tests"))] {
|
||||
match user_preferences::file_backed::FileBackedUserPreferences::new(super::user_preferences_file_path()) {
|
||||
Ok(prefs) => Box::new(prefs) as user_preferences::Model,
|
||||
Err(err) => {
|
||||
crate::report_error!(anyhow::anyhow!(err));
|
||||
Box::<user_preferences::in_memory::InMemoryPreferences>::default()
|
||||
}
|
||||
}
|
||||
} else if #[cfg(target_os = "windows")] {
|
||||
let app_id = warp_core::channel::ChannelState::app_id();
|
||||
Box::new(user_preferences::registry_backed::RegistryBackedPreferences::new(app_id.application_name()))
|
||||
} else if #[cfg(target_os = "macos")] {
|
||||
Box::new(user_preferences::user_defaults::UserDefaultsPreferencesStorage::new(
|
||||
warp_core::channel::ChannelState::data_domain_if_not_default()
|
||||
))
|
||||
} else if #[cfg(target_family = "wasm")] {
|
||||
Box::<user_preferences::local_storage::LocalStoragePreferences>::default()
|
||||
} else {
|
||||
unreachable!("Unspecified user preferences implementation for current platform!");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates the platform-native preferences backend for private settings.
|
||||
///
|
||||
/// Private settings are always stored in the platform-native store (e.g.
|
||||
/// UserDefaults on macOS) and never appear in the user-visible TOML file.
|
||||
pub fn init_private_user_preferences() -> settings::PrivatePreferences {
|
||||
settings::PrivatePreferences::new(init_platform_native_preferences())
|
||||
}
|
||||
|
||||
/// Initializes the public UserPreferences provider.
|
||||
///
|
||||
/// When the `SettingsFile` feature flag is enabled, public settings are stored
|
||||
/// in `settings.toml` so they are user-visible and editable. When the flag is
|
||||
/// disabled, this falls back to the platform-native store (same as private
|
||||
/// settings), so all settings live in the same place.
|
||||
/// Returns `(preferences_backend, optional_parse_error)`. The parse error
|
||||
/// is `Some` only when the TOML settings file existed but could not be
|
||||
/// parsed; it should be propagated to the UI so the user sees a banner.
|
||||
pub fn init_public_user_preferences() -> (user_preferences::Model, Option<user_preferences::Error>)
|
||||
{
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(test)] {
|
||||
(Box::<user_preferences::in_memory::InMemoryPreferences>::default(), None)
|
||||
} else if #[cfg(target_family = "wasm")] {
|
||||
(Box::<user_preferences::local_storage::LocalStoragePreferences>::default(), None)
|
||||
} else {
|
||||
if warp_core::features::FeatureFlag::SettingsFile.is_enabled() {
|
||||
let (prefs, parse_error) =
|
||||
user_preferences::toml_backed::TomlBackedUserPreferences::new(
|
||||
super::user_preferences_toml_file_path(),
|
||||
);
|
||||
if let Some(err) = &parse_error {
|
||||
log::warn!("Settings file has syntax errors and could not be parsed: {err}");
|
||||
}
|
||||
(Box::new(prefs) as user_preferences::Model, parse_error)
|
||||
} else {
|
||||
(init_platform_native_preferences(), None)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` when we should migrate public settings from the
|
||||
/// platform-native store into the TOML settings file.
|
||||
///
|
||||
/// Migration is needed when all of the following are true:
|
||||
/// 1. The `SettingsFile` feature flag is enabled.
|
||||
/// 2. The `settings.toml` file does not yet exist on disk.
|
||||
/// 3. The migration-complete marker is absent from the native store
|
||||
/// (handles the case where a user deletes `settings.toml` to reset).
|
||||
fn needs_settings_file_migration(ctx: &AppContext) -> bool {
|
||||
if !FeatureFlag::SettingsFile.is_enabled() {
|
||||
return false;
|
||||
}
|
||||
|
||||
if super::user_preferences_toml_file_path().exists() {
|
||||
return false;
|
||||
}
|
||||
|
||||
use warp_core::user_preferences::GetUserPreferences as _;
|
||||
ctx.private_user_preferences()
|
||||
.read_value(SETTINGS_FILE_MIGRATION_COMPLETE_KEY)
|
||||
.unwrap_or_default()
|
||||
.as_deref()
|
||||
!= Some("true")
|
||||
}
|
||||
|
||||
/// Performs a one-time migration of public settings from the platform-native
|
||||
/// store (e.g. NSUserDefaults on macOS) into the TOML settings file.
|
||||
///
|
||||
/// For each public storage key registered with the `SettingsManager`, this
|
||||
/// reads the value from the native store and, if present, feeds it through
|
||||
/// `update_setting_with_storage_key` — which deserializes, validates, updates
|
||||
/// the in-memory setting, and writes to the TOML file with the correct
|
||||
/// hierarchy, `serialize_for_file` transforms, and `max_table_depth`.
|
||||
fn migrate_native_settings_to_settings_file(ctx: &mut AppContext) {
|
||||
use warp_core::user_preferences::GetUserPreferences as _;
|
||||
|
||||
log::info!("Migrating public settings from native store to settings.toml");
|
||||
|
||||
// Collect the storage keys for all public settings.
|
||||
let storage_keys: Vec<String> = SettingsManager::as_ref(ctx)
|
||||
.public_storage_keys()
|
||||
.map(str::to_owned)
|
||||
.collect();
|
||||
|
||||
// Read each public setting's value from the native store.
|
||||
let native_prefs = ctx.private_user_preferences();
|
||||
let values_to_migrate: Vec<(String, String)> = storage_keys
|
||||
.into_iter()
|
||||
.filter_map(|key| {
|
||||
let value = native_prefs.read_value(&key).unwrap_or_default()?;
|
||||
Some((key, value))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut migrated_count = 0;
|
||||
let mut failed_count = 0;
|
||||
let mut last_error: Option<anyhow::Error> = None;
|
||||
|
||||
// Write each value through the SettingsManager so the in-memory state
|
||||
// and the TOML file are both updated correctly.
|
||||
SettingsManager::handle(ctx).update(ctx, |manager, ctx| {
|
||||
for (key, value) in values_to_migrate {
|
||||
match manager.update_setting_with_storage_key(&key, value, false, ctx) {
|
||||
Ok(()) => migrated_count += 1,
|
||||
Err(err) => {
|
||||
log::warn!("Failed to migrate setting {key}: {err}");
|
||||
failed_count += 1;
|
||||
last_error = Some(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if let Some(err) = last_error {
|
||||
report_if_error!(Err::<(), _>(err.context(format!(
|
||||
"Settings file migration: {failed_count} of {} settings failed to migrate",
|
||||
migrated_count + failed_count
|
||||
))));
|
||||
}
|
||||
|
||||
log::info!("Settings file migration complete — migrated {migrated_count} settings, {failed_count} failed");
|
||||
|
||||
// Record the migration so it won't re-run if the user deletes the TOML
|
||||
// file. This marker is written unconditionally — for new users the native
|
||||
// store is empty so the migration is a no-op, but the marker still gets
|
||||
// written to indicate that migration was attempted.
|
||||
report_if_error!(ctx
|
||||
.private_user_preferences()
|
||||
.write_value(SETTINGS_FILE_MIGRATION_COMPLETE_KEY, "true".to_owned())
|
||||
.map_err(|err| anyhow::anyhow!(err)));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn init_and_register_user_preferences(ctx: &mut AppContext) {
|
||||
let (public_prefs, _parse_error) = init_public_user_preferences();
|
||||
ctx.add_singleton_model(move |_| settings::PublicPreferences::new(public_prefs));
|
||||
ctx.add_singleton_model(move |_| init_private_user_preferences());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "init_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,541 @@
|
||||
use instant::Duration;
|
||||
use settings::{
|
||||
is_settings_file_enabled, set_settings_file_enabled, PrivatePreferences, PublicPreferences,
|
||||
Setting, SettingsManager,
|
||||
};
|
||||
use settings_value::SettingsValue;
|
||||
use warp_core::features::FeatureFlag;
|
||||
use warp_core::settings::{macros::define_settings_group, SupportedPlatforms, SyncToCloud};
|
||||
use warp_core::user_preferences::GetUserPreferences as _;
|
||||
use warpui::SingletonEntity;
|
||||
use warpui_extras::user_preferences;
|
||||
|
||||
use crate::terminal::session_settings::{NotificationsMode, NotificationsSettings};
|
||||
|
||||
use super::{
|
||||
migrate_native_settings_to_settings_file, needs_settings_file_migration,
|
||||
SETTINGS_FILE_MIGRATION_COMPLETE_KEY,
|
||||
};
|
||||
|
||||
// A minimal settings group with one public and one private setting, used to
|
||||
// verify that migration only copies public settings.
|
||||
define_settings_group!(MigrationTestSettings, settings: [
|
||||
public_setting: PublicSetting {
|
||||
type: bool,
|
||||
default: false,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Never,
|
||||
private: false,
|
||||
toml_path: "migration_test.public_setting",
|
||||
},
|
||||
public_string_setting: PublicStringSetting {
|
||||
type: String,
|
||||
default: String::new(),
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Never,
|
||||
private: false,
|
||||
toml_path: "migration_test.public_string_setting",
|
||||
},
|
||||
private_setting: PrivateSetting {
|
||||
type: bool,
|
||||
default: false,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Never,
|
||||
private: true,
|
||||
},
|
||||
]);
|
||||
|
||||
/// Registers separate InMemoryPreferences singletons for public and private
|
||||
/// stores, then adds a SettingsManager and the test settings group.
|
||||
fn init_test_app(ctx: &mut warpui::AppContext) {
|
||||
ctx.add_singleton_model(move |_| {
|
||||
PublicPreferences::new(Box::<user_preferences::in_memory::InMemoryPreferences>::default())
|
||||
});
|
||||
ctx.add_singleton_model(move |_| -> PrivatePreferences {
|
||||
PrivatePreferences::new(Box::<user_preferences::in_memory::InMemoryPreferences>::default())
|
||||
});
|
||||
ctx.add_singleton_model(|_| SettingsManager::default());
|
||||
MigrationTestSettings::register(ctx);
|
||||
}
|
||||
|
||||
struct SettingsFileEnabledGuard(bool);
|
||||
|
||||
impl SettingsFileEnabledGuard {
|
||||
fn new(enabled: bool) -> Self {
|
||||
let previous = is_settings_file_enabled();
|
||||
set_settings_file_enabled(enabled);
|
||||
Self(previous)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SettingsFileEnabledGuard {
|
||||
fn drop(&mut self) {
|
||||
set_settings_file_enabled(self.0);
|
||||
}
|
||||
}
|
||||
|
||||
// Only tests that toggle the process-global SettingsFile routing flag need to
|
||||
// run serially.
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn test_migration_copies_public_settings_from_native_store() {
|
||||
warpui::App::test((), |mut app| async move {
|
||||
// Enable the settings file so `preferences_for_setting` routes
|
||||
// public setting writes to the Model singleton (not the private store).
|
||||
let _guard = FeatureFlag::SettingsFile.override_enabled(true);
|
||||
let _settings_file_enabled = SettingsFileEnabledGuard::new(true);
|
||||
|
||||
app.update(init_test_app);
|
||||
|
||||
// Seed the native (private) store with values for both settings.
|
||||
app.update(|ctx| {
|
||||
let native = ctx.private_user_preferences();
|
||||
native
|
||||
.write_value("PublicSetting", "true".to_owned())
|
||||
.unwrap();
|
||||
native
|
||||
.write_value("PrivateSetting", "true".to_owned())
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
// Before migration, in-memory values should still be defaults (the
|
||||
// public store is empty and registration read from there).
|
||||
app.read(|ctx| {
|
||||
let settings = MigrationTestSettings::as_ref(ctx);
|
||||
assert!(!*settings.public_setting.value());
|
||||
assert!(!*settings.private_setting.value());
|
||||
});
|
||||
|
||||
// Run the migration.
|
||||
app.update(|ctx| {
|
||||
migrate_native_settings_to_settings_file(ctx);
|
||||
});
|
||||
|
||||
// The public setting should now reflect the native store value.
|
||||
app.read(|ctx| {
|
||||
let settings = MigrationTestSettings::as_ref(ctx);
|
||||
assert!(
|
||||
*settings.public_setting.value(),
|
||||
"public setting should have been migrated from native store"
|
||||
);
|
||||
});
|
||||
|
||||
// The public store should now contain the migrated value.
|
||||
app.read(|ctx| {
|
||||
let public = PublicSetting::preferences_for_setting(ctx);
|
||||
let stored = public
|
||||
.read_value_with_hierarchy(PublicSetting::storage_key(), PublicSetting::hierarchy())
|
||||
.unwrap();
|
||||
assert_eq!(stored, Some("true".to_owned()));
|
||||
});
|
||||
// The private setting should NOT have been touched by migration
|
||||
// (it's private, so migration skips it). The in-memory value stays
|
||||
// at default because register() read from the public store (empty).
|
||||
app.read(|ctx| {
|
||||
let settings = MigrationTestSettings::as_ref(ctx);
|
||||
assert!(
|
||||
!*settings.private_setting.value(),
|
||||
"private setting should not be affected by migration"
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migration_writes_marker_to_native_store() {
|
||||
warpui::App::test((), |mut app| async move {
|
||||
app.update(init_test_app);
|
||||
|
||||
// No marker before migration.
|
||||
app.read(|ctx| {
|
||||
let marker = ctx
|
||||
.private_user_preferences()
|
||||
.read_value(SETTINGS_FILE_MIGRATION_COMPLETE_KEY)
|
||||
.unwrap();
|
||||
assert!(marker.is_none());
|
||||
});
|
||||
|
||||
app.update(|ctx| {
|
||||
migrate_native_settings_to_settings_file(ctx);
|
||||
});
|
||||
|
||||
// Marker should now be present.
|
||||
app.read(|ctx| {
|
||||
let marker = ctx
|
||||
.private_user_preferences()
|
||||
.read_value(SETTINGS_FILE_MIGRATION_COMPLETE_KEY)
|
||||
.unwrap();
|
||||
assert!(marker.is_some(), "migration marker should be written");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn test_migration_skips_settings_absent_from_native_store() {
|
||||
warpui::App::test((), |mut app| async move {
|
||||
let _guard = FeatureFlag::SettingsFile.override_enabled(true);
|
||||
let _settings_file_enabled = SettingsFileEnabledGuard::new(true);
|
||||
app.update(init_test_app);
|
||||
|
||||
// Don't seed anything in the native store — all settings are absent.
|
||||
|
||||
app.update(|ctx| {
|
||||
migrate_native_settings_to_settings_file(ctx);
|
||||
});
|
||||
|
||||
// Settings should remain at defaults.
|
||||
app.read(|ctx| {
|
||||
let settings = MigrationTestSettings::as_ref(ctx);
|
||||
assert!(!*settings.public_setting.value());
|
||||
assert_eq!(settings.public_string_setting.value().as_str(), "");
|
||||
});
|
||||
|
||||
// The public store should have nothing written.
|
||||
app.read(|ctx| {
|
||||
let public = PublicSetting::preferences_for_setting(ctx);
|
||||
assert!(
|
||||
public
|
||||
.read_value_with_hierarchy(
|
||||
PublicSetting::storage_key(),
|
||||
PublicSetting::hierarchy(),
|
||||
)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert!(public
|
||||
.read_value_with_hierarchy(
|
||||
PublicStringSetting::storage_key(),
|
||||
PublicStringSetting::hierarchy(),
|
||||
)
|
||||
.unwrap()
|
||||
.is_none());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migration_handles_string_setting() {
|
||||
warpui::App::test((), |mut app| async move {
|
||||
app.update(init_test_app);
|
||||
|
||||
// Seed a JSON-encoded string value in the native store.
|
||||
app.update(|ctx| {
|
||||
let native = ctx.private_user_preferences();
|
||||
native
|
||||
.write_value("PublicStringSetting", "\"Fira Code\"".to_owned())
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
app.update(|ctx| {
|
||||
migrate_native_settings_to_settings_file(ctx);
|
||||
});
|
||||
|
||||
app.read(|ctx| {
|
||||
let settings = MigrationTestSettings::as_ref(ctx);
|
||||
assert_eq!(
|
||||
settings.public_string_setting.value().as_str(),
|
||||
"Fira Code",
|
||||
"string setting should have been migrated"
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migration_does_not_rerun_when_marker_present() {
|
||||
warpui::App::test((), |mut app| async move {
|
||||
let _guard = FeatureFlag::SettingsFile.override_enabled(true);
|
||||
|
||||
app.update(init_test_app);
|
||||
|
||||
// Seed the native store with a public setting.
|
||||
app.update(|ctx| {
|
||||
let native = ctx.private_user_preferences();
|
||||
native
|
||||
.write_value("PublicSetting", "true".to_owned())
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
// Before migration, the guard should allow migration.
|
||||
app.read(|ctx| {
|
||||
assert!(
|
||||
needs_settings_file_migration(ctx),
|
||||
"migration should be needed before first run"
|
||||
);
|
||||
});
|
||||
|
||||
// Run migration.
|
||||
app.update(|ctx| {
|
||||
migrate_native_settings_to_settings_file(ctx);
|
||||
});
|
||||
|
||||
// After migration, the marker should prevent re-migration.
|
||||
app.read(|ctx| {
|
||||
assert!(
|
||||
!needs_settings_file_migration(ctx),
|
||||
"migration should not be needed after marker is written"
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn test_migration_with_multiple_setting_types() {
|
||||
warpui::App::test((), |mut app| async move {
|
||||
let _guard = FeatureFlag::SettingsFile.override_enabled(true);
|
||||
let _settings_file_enabled = SettingsFileEnabledGuard::new(true);
|
||||
|
||||
app.update(init_test_app);
|
||||
|
||||
// Seed the native store with values for all three settings.
|
||||
app.update(|ctx| {
|
||||
let native = ctx.private_user_preferences();
|
||||
native
|
||||
.write_value("PublicSetting", "true".to_owned())
|
||||
.unwrap();
|
||||
native
|
||||
.write_value("PublicStringSetting", "\"Custom Value\"".to_owned())
|
||||
.unwrap();
|
||||
native
|
||||
.write_value("PrivateSetting", "true".to_owned())
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
app.update(|ctx| {
|
||||
migrate_native_settings_to_settings_file(ctx);
|
||||
});
|
||||
|
||||
// Both public settings should have been migrated.
|
||||
app.read(|ctx| {
|
||||
let settings = MigrationTestSettings::as_ref(ctx);
|
||||
assert!(
|
||||
*settings.public_setting.value(),
|
||||
"public bool should have been migrated"
|
||||
);
|
||||
assert_eq!(
|
||||
settings.public_string_setting.value().as_str(),
|
||||
"Custom Value",
|
||||
"public string should have been migrated"
|
||||
);
|
||||
});
|
||||
|
||||
// Public store should contain the migrated bool.
|
||||
app.read(|ctx| {
|
||||
let public = PublicSetting::preferences_for_setting(ctx);
|
||||
assert_eq!(
|
||||
public
|
||||
.read_value_with_hierarchy(
|
||||
PublicSetting::storage_key(),
|
||||
PublicSetting::hierarchy(),
|
||||
)
|
||||
.unwrap(),
|
||||
Some("true".to_owned())
|
||||
);
|
||||
});
|
||||
|
||||
// Private setting should NOT have been migrated — in-memory
|
||||
// value stays at default because register() read from the
|
||||
// public store (which was empty for this key).
|
||||
app.read(|ctx| {
|
||||
let settings = MigrationTestSettings::as_ref(ctx);
|
||||
assert!(
|
||||
!*settings.private_setting.value(),
|
||||
"private setting should not be affected by migration"
|
||||
);
|
||||
});
|
||||
|
||||
// The private setting should NOT be in the public store.
|
||||
app.read(|ctx| {
|
||||
let public = PublicSetting::preferences_for_setting(ctx);
|
||||
assert!(
|
||||
public
|
||||
.read_value_with_hierarchy(
|
||||
PrivateSetting::storage_key(),
|
||||
PrivateSetting::hierarchy(),
|
||||
)
|
||||
.unwrap()
|
||||
.is_none(),
|
||||
"private setting should not appear in public store"
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests for serde ↔ file-format mismatch during migration
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// NotificationsSettings has #[serde(default)] and contains fields whose serde
|
||||
// and SettingsValue file formats differ:
|
||||
// - NotificationsMode: serde uses PascalCase ("Enabled"), file uses snake_case ("enabled")
|
||||
// - Duration: serde uses {"secs":N,"nanos":N}, file uses a plain integer
|
||||
//
|
||||
// The migration reads serde-format values from the native store and feeds them
|
||||
// through update_setting_with_storage_key, which tries from_file_value first.
|
||||
// If from_file_value silently defaults fields (due to #[serde(default)]), the
|
||||
// serde fallback is never reached and values are lost.
|
||||
|
||||
mod notifications_migration {
|
||||
use settings::{PrivatePreferences, PublicPreferences, SettingsManager};
|
||||
use warp_core::settings::{macros::define_settings_group, SupportedPlatforms, SyncToCloud};
|
||||
use warpui_extras::user_preferences;
|
||||
|
||||
use crate::terminal::session_settings::NotificationsSettings;
|
||||
|
||||
define_settings_group!(NotificationsMigrationTestSettings, settings: [
|
||||
notifications: MigrationTestNotifications {
|
||||
type: NotificationsSettings,
|
||||
default: NotificationsSettings::default(),
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Never,
|
||||
private: false,
|
||||
toml_path: "migration_test.notifications",
|
||||
max_table_depth: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
pub fn init_notifications_migration_test_app(ctx: &mut warpui::AppContext) {
|
||||
ctx.add_singleton_model(move |_| {
|
||||
PublicPreferences::new(
|
||||
Box::<user_preferences::in_memory::InMemoryPreferences>::default(),
|
||||
)
|
||||
});
|
||||
ctx.add_singleton_model(move |_| -> PrivatePreferences {
|
||||
PrivatePreferences::new(
|
||||
Box::<user_preferences::in_memory::InMemoryPreferences>::default(),
|
||||
)
|
||||
});
|
||||
ctx.add_singleton_model(|_| SettingsManager::default());
|
||||
NotificationsMigrationTestSettings::register(ctx);
|
||||
}
|
||||
}
|
||||
use notifications_migration::{
|
||||
init_notifications_migration_test_app, NotificationsMigrationTestSettings,
|
||||
};
|
||||
|
||||
// -- from_file_value unit tests: these demonstrate the derive-level bug ------
|
||||
|
||||
#[test]
|
||||
fn test_notifications_from_file_value_rejects_serde_format_enum() {
|
||||
// serde serializes NotificationsMode::Enabled as "Enabled" (PascalCase),
|
||||
// but from_file_value expects "enabled" (snake_case). When the field is
|
||||
// present but unparseable, from_file_value should return None — not
|
||||
// silently fall back to the #[serde(default)] value (Unset).
|
||||
let serde_json_value = serde_json::to_value(NotificationsSettings {
|
||||
mode: NotificationsMode::Enabled,
|
||||
..NotificationsSettings::default()
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let result = NotificationsSettings::from_file_value(&serde_json_value);
|
||||
assert!(
|
||||
result.is_none(),
|
||||
"from_file_value should reject serde-format enum values, but got: {result:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_notifications_from_file_value_rejects_serde_format_duration() {
|
||||
// serde serializes Duration as {"secs": N, "nanos": N}, but
|
||||
// Duration::from_file_value expects a plain integer. Use file-format
|
||||
// for mode ("unset") so that the failure is isolated to the Duration field.
|
||||
let json = serde_json::json!({
|
||||
"mode": "unset",
|
||||
"is_long_running_enabled": true,
|
||||
"long_running_threshold": {"secs": 60, "nanos": 0},
|
||||
"is_password_prompt_enabled": true,
|
||||
"is_agent_task_completed_enabled": true,
|
||||
"is_needs_attention_enabled": true,
|
||||
"play_notification_sound": true,
|
||||
});
|
||||
|
||||
let result = NotificationsSettings::from_file_value(&json);
|
||||
assert!(
|
||||
result.is_none(),
|
||||
"from_file_value should reject serde-format Duration, but got: {result:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// -- Migration integration tests: these demonstrate end-to-end data loss -----
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn test_migration_preserves_notifications_mode() {
|
||||
warpui::App::test((), |mut app| async move {
|
||||
let _guard = FeatureFlag::SettingsFile.override_enabled(true);
|
||||
let _settings_file_enabled = SettingsFileEnabledGuard::new(true);
|
||||
|
||||
app.update(init_notifications_migration_test_app);
|
||||
|
||||
// Seed the native store with serde-serialized NotificationsSettings
|
||||
// where mode is Enabled. In serde format: {"mode":"Enabled",...}.
|
||||
app.update(|ctx| {
|
||||
let native = ctx.private_user_preferences();
|
||||
let serde_value = serde_json::to_string(&NotificationsSettings {
|
||||
mode: NotificationsMode::Enabled,
|
||||
..NotificationsSettings::default()
|
||||
})
|
||||
.unwrap();
|
||||
native
|
||||
.write_value("MigrationTestNotifications", serde_value)
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
app.update(|ctx| {
|
||||
migrate_native_settings_to_settings_file(ctx);
|
||||
});
|
||||
|
||||
// Mode should be preserved as Enabled, not silently defaulted to Unset.
|
||||
app.read(|ctx| {
|
||||
let settings = NotificationsMigrationTestSettings::as_ref(ctx);
|
||||
assert_eq!(
|
||||
settings.notifications.value().mode,
|
||||
NotificationsMode::Enabled,
|
||||
"NotificationsMode should be preserved during migration"
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn test_migration_preserves_custom_long_running_threshold() {
|
||||
warpui::App::test((), |mut app| async move {
|
||||
let _guard = FeatureFlag::SettingsFile.override_enabled(true);
|
||||
let _settings_file_enabled = SettingsFileEnabledGuard::new(true);
|
||||
|
||||
app.update(init_notifications_migration_test_app);
|
||||
|
||||
// Seed with a non-default threshold (60s instead of default 30s).
|
||||
// serde serializes Duration as {"secs":60,"nanos":0}, which differs
|
||||
// from the file format (plain integer 60).
|
||||
let custom_threshold = Duration::from_secs(60);
|
||||
app.update(|ctx| {
|
||||
let native = ctx.private_user_preferences();
|
||||
let serde_value = serde_json::to_string(&NotificationsSettings {
|
||||
long_running_threshold: custom_threshold,
|
||||
..NotificationsSettings::default()
|
||||
})
|
||||
.unwrap();
|
||||
native
|
||||
.write_value("MigrationTestNotifications", serde_value)
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
app.update(|ctx| {
|
||||
migrate_native_settings_to_settings_file(ctx);
|
||||
});
|
||||
|
||||
app.read(|ctx| {
|
||||
let settings = NotificationsMigrationTestSettings::as_ref(ctx);
|
||||
assert_eq!(
|
||||
settings.notifications.value().long_running_threshold,
|
||||
custom_threshold,
|
||||
"custom long_running_threshold should be preserved during migration"
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use warp_core::{features::FeatureFlag, settings::Setting};
|
||||
use warpui::{Entity, ModelContext, SingletonEntity};
|
||||
|
||||
use crate::settings::{AISettings, FontSettings, ThinkingDisplayMode};
|
||||
use crate::{
|
||||
auth::auth_state::AuthState,
|
||||
report_if_error,
|
||||
settings::input::InputBoxType,
|
||||
settings::{InputSettings, PrivacySettings, ThemeSettings},
|
||||
terminal::session_settings::SessionSettings,
|
||||
themes::theme::ThemeKind,
|
||||
};
|
||||
|
||||
pub struct SettingsInitializer;
|
||||
|
||||
impl Default for SettingsInitializer {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl SettingsInitializer {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// A hook for changing settings values after a user is fetched from the server.
|
||||
///
|
||||
/// Specifically useful for adjusting settings for first-time users when the default value of a
|
||||
/// setting as set in define_settings_group! is no longer the desired default value,
|
||||
/// but we don't want to change it for existing users (which is what would happen if we changed the
|
||||
/// default value in define_settings_group! in code).
|
||||
pub fn handle_user_fetched(&self, auth_state: Arc<AuthState>, ctx: &mut ModelContext<Self>) {
|
||||
/// We use a font-size of 16px (12pt) on Windows to more closely match the default font size of
|
||||
/// Windows terminal.
|
||||
const DEFAULT_WINDOWS_MONOSPACE_FONT_SIZE: f32 = 16.;
|
||||
|
||||
if auth_state.is_onboarded() == Some(false) {
|
||||
PrivacySettings::handle(ctx).update(ctx, |settings, ctx| {
|
||||
// Previously, secret redaction had a built-in default set of regexes that users couldn't change.
|
||||
// We want to add that default list to all existing users' lists, so we don't regress their current secret redaction experience.
|
||||
// However, for new users, we don't want to add these defaults without their explicit action, so we disable adding them here.
|
||||
settings.disable_default_regex_trigger(ctx);
|
||||
});
|
||||
|
||||
if FeatureFlag::DefaultAdeberryTheme.is_enabled() {
|
||||
log::debug!("Setting default theme to Adeberry for new user");
|
||||
ThemeSettings::handle(ctx).update(ctx, |settings, ctx| {
|
||||
if *settings.theme_kind.value() == ThemeKind::Phenomenon {
|
||||
report_if_error!(settings.theme_kind.set_value(ThemeKind::Adeberry, ctx));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if cfg!(windows) {
|
||||
log::debug!("Setting default font size to 16px (12pt) for a new Windows user");
|
||||
FontSettings::handle(ctx).update(ctx, |settings, ctx| {
|
||||
report_if_error!(settings
|
||||
.monospace_font_size
|
||||
.set_value(DEFAULT_WINDOWS_MONOSPACE_FONT_SIZE, ctx));
|
||||
})
|
||||
}
|
||||
|
||||
let did_update_input_type = InputSettings::handle(ctx).update(ctx, |settings, ctx| {
|
||||
if !settings.input_box_type.is_value_explicitly_set()
|
||||
&& *settings.input_box_type.value() == InputBoxType::Classic
|
||||
{
|
||||
log::debug!("Setting default input type to Warp prompt for new user");
|
||||
report_if_error!(settings
|
||||
.input_box_type
|
||||
.set_value(InputBoxType::Universal, ctx));
|
||||
ctx.notify();
|
||||
return true;
|
||||
}
|
||||
false
|
||||
});
|
||||
// Keep honor_ps1 in sync: Universal input requires honor_ps1 = false.
|
||||
if did_update_input_type {
|
||||
SessionSettings::handle(ctx).update(ctx, |settings, ctx| {
|
||||
if *settings.honor_ps1.value() {
|
||||
report_if_error!(settings.honor_ps1.set_value(false, ctx));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate NLD settings when AgentView is enabled.
|
||||
//
|
||||
// Explicitly set `nld_in_terminal_enabled_internal` for all users if
|
||||
// it has not previously been set.
|
||||
//
|
||||
// For existing users, when the old, previously-global autodetection setting
|
||||
// (`ai_autodetection_enabled_internal`) true, set `nld_in_terminal_enabled_internal` to
|
||||
// true. Otherwise, explicitly set to `false`.
|
||||
//
|
||||
// Any further user modification of the setting will be via explicit update, so it'll
|
||||
// be exempt from this logic, which is effectively one-time upon first startup of a binary
|
||||
// containing this logic.
|
||||
//
|
||||
// TODO(zachbai): Remove this approximately 6 weeks from 2/5/26.
|
||||
if FeatureFlag::AgentView.is_enabled() {
|
||||
AISettings::handle(ctx).update(ctx, |ai_settings, ctx| {
|
||||
if ai_settings
|
||||
.nld_in_terminal_enabled_internal
|
||||
.is_value_explicitly_set()
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let is_existing_user = auth_state.is_onboarded() == Some(true);
|
||||
let was_global_autodetection_enabled_for_existing_user =
|
||||
*ai_settings.ai_autodetection_enabled_internal && is_existing_user;
|
||||
report_if_error!(ai_settings
|
||||
.nld_in_terminal_enabled_internal
|
||||
.set_value(was_global_autodetection_enabled_for_existing_user, ctx));
|
||||
});
|
||||
}
|
||||
|
||||
// Migrate the old `KeepThinkingExpanded` bool setting to the new
|
||||
// `ThinkingDisplayMode` enum setting.
|
||||
//
|
||||
// The old setting was a boolean (default: false) that controlled whether
|
||||
// agent thinking blocks stayed expanded after streaming. It has been
|
||||
// replaced by a three-option enum: ShowAndCollapse (default),
|
||||
// AlwaysShow, and NeverShow.
|
||||
//
|
||||
// If the user explicitly set `KeepThinkingExpanded` to `true`, migrate
|
||||
// them to `ThinkingDisplayMode::AlwaysShow` so they don't lose their
|
||||
// preference when updating to the new client.
|
||||
//
|
||||
// TODO(jefflloyd): Remove this approximately 6 weeks from 3/19/26.
|
||||
{
|
||||
use warp_core::user_preferences::GetUserPreferences as _;
|
||||
|
||||
AISettings::handle(ctx).update(ctx, |ai_settings, ctx| {
|
||||
// If the new setting already has a value in preferences, the
|
||||
// migration has already run (or the user set it directly).
|
||||
let new_key_exists = ctx
|
||||
.private_user_preferences()
|
||||
.read_value("ThinkingDisplayMode")
|
||||
.unwrap_or_default()
|
||||
.is_some();
|
||||
|
||||
if new_key_exists {
|
||||
return;
|
||||
}
|
||||
|
||||
// Read the old boolean setting directly from preferences
|
||||
// because `KeepThinkingExpanded` has been removed from the
|
||||
// `AISettings` struct — there is no typed field left to query.
|
||||
let old_value_was_true = ctx
|
||||
.private_user_preferences()
|
||||
.read_value("KeepThinkingExpanded")
|
||||
.unwrap_or_default()
|
||||
.and_then(|v| serde_json::from_str::<bool>(&v).ok())
|
||||
== Some(true);
|
||||
|
||||
if old_value_was_true {
|
||||
report_if_error!(ai_settings
|
||||
.thinking_display_mode
|
||||
.set_value(ThinkingDisplayMode::AlwaysShow, ctx));
|
||||
}
|
||||
|
||||
// Clean up the old key.
|
||||
let _ = ctx
|
||||
.private_user_preferences()
|
||||
.remove_value("KeepThinkingExpanded");
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for SettingsInitializer {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
/// Mark CloudPreferencesSyncer as global application state.
|
||||
impl SingletonEntity for SettingsInitializer {}
|
||||
@@ -0,0 +1,233 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
/// TODO: move alias_expansion setting into this group.
|
||||
use settings::{define_settings_group, RespectUserSyncSetting, SupportedPlatforms, SyncToCloud};
|
||||
use std::collections::HashMap;
|
||||
use warpui::{AppContext, SingletonEntity};
|
||||
|
||||
use crate::terminal::input::inline_menu::InlineMenuType;
|
||||
use crate::terminal::session_settings::SessionSettings;
|
||||
use settings::Setting as _;
|
||||
|
||||
pub const MAX_TIMES_TO_SHOW_AUTOSUGGESTION_HINT: i8 = 2;
|
||||
|
||||
#[derive(
|
||||
Debug,
|
||||
Copy,
|
||||
Clone,
|
||||
PartialEq,
|
||||
Eq,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
Default,
|
||||
schemars::JsonSchema,
|
||||
settings_value::SettingsValue,
|
||||
)]
|
||||
#[schemars(description = "Terminal input style.", rename_all = "snake_case")]
|
||||
pub enum InputBoxType {
|
||||
/// AI-first input
|
||||
Universal,
|
||||
|
||||
#[default]
|
||||
/// Terminal-first input
|
||||
Classic,
|
||||
}
|
||||
|
||||
define_settings_group!(InputSettings,
|
||||
settings: [
|
||||
show_hint_text: ShowHintText {
|
||||
type: bool,
|
||||
default: true,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "terminal.input.show_hint_text",
|
||||
description: "Whether hint text is shown in the terminal input.",
|
||||
},
|
||||
classic_completions_mode: ClassicCompletionsMode {
|
||||
type: bool,
|
||||
default: false,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "terminal.input.classic_completions_mode",
|
||||
description: "Whether classic completions mode is enabled.",
|
||||
},
|
||||
completions_open_while_typing: CompletionsOpenWhileTyping {
|
||||
type: bool,
|
||||
default: false,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "terminal.input.completions_open_while_typing",
|
||||
description: "Whether the completions menu opens automatically while typing.",
|
||||
},
|
||||
error_underlining: ErrorUnderliningEnabled {
|
||||
type: bool,
|
||||
default: true,
|
||||
supported_platforms: SupportedPlatforms::DESKTOP,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "terminal.input.error_underlining_enabled",
|
||||
description: "Whether command errors are underlined in the input.",
|
||||
},
|
||||
syntax_highlighting: SyntaxHighlighting {
|
||||
type: bool,
|
||||
default: true,
|
||||
supported_platforms: SupportedPlatforms::DESKTOP,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "terminal.input.syntax_highlighting",
|
||||
description: "Whether syntax highlighting is enabled in the terminal input.",
|
||||
},
|
||||
command_corrections: CommandCorrections {
|
||||
type: bool,
|
||||
default: true,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "terminal.input.command_corrections",
|
||||
description: "Whether command corrections are suggested for mistyped commands.",
|
||||
},
|
||||
workflows_box_expanded: WorkflowsBoxExpanded {
|
||||
type: bool,
|
||||
default: true,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: true,
|
||||
storage_key: "WorkflowsBoxOpen",
|
||||
},
|
||||
autosuggestion_accepted_count: AutosuggestionAcceptedCount {
|
||||
type: i8,
|
||||
default: 0,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: true,
|
||||
},
|
||||
input_box_type: InputBoxTypeSetting {
|
||||
type: InputBoxType,
|
||||
default: InputBoxType::Classic,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "terminal.input.input_box_type_setting",
|
||||
description: "The terminal input style.",
|
||||
},
|
||||
at_context_menu_in_terminal_mode: AtContextMenuInTerminalMode {
|
||||
type: bool,
|
||||
default: true,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "terminal.input.at_context_menu_in_terminal_mode",
|
||||
description: "Whether the @ context menu is available in terminal mode.",
|
||||
},
|
||||
enable_slash_commands_in_terminal: EnableSlashCommandsInTerminal {
|
||||
type: bool,
|
||||
default: true,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "terminal.input.enable_slash_commands_in_terminal",
|
||||
description: "Whether slash commands are available in the terminal input.",
|
||||
},
|
||||
outline_codebase_symbols_for_at_context_menu: OutlineCodebaseSymbolsForAtContextMenu {
|
||||
type: bool,
|
||||
default: true,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "terminal.input.outline_codebase_symbols_for_at_context_menu",
|
||||
description: "Whether codebase symbols appear in the @ context menu.",
|
||||
},
|
||||
completions_menu_width: CompletionsMenuWidth {
|
||||
type: f32,
|
||||
default: 330.,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Never,
|
||||
private: true,
|
||||
},
|
||||
completions_menu_height: CompletionsMenuHeight {
|
||||
type: f32,
|
||||
default: 185.,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Never,
|
||||
private: true,
|
||||
},
|
||||
show_agent_tips: ShowAgentTips {
|
||||
type: bool,
|
||||
default: true,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "agents.warp_agent.input.show_agent_tips",
|
||||
description: "Whether agent tips are displayed in the input.",
|
||||
},
|
||||
// Whether to show the terminal input message bar (contextual hints at the bottom of terminal input).
|
||||
// Only applicable when FeatureFlag::AgentView is enabled.
|
||||
show_terminal_input_message_bar: ShowTerminalInputMessageBar {
|
||||
type: bool,
|
||||
default: true,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "terminal.input.show_terminal_input_message_bar",
|
||||
description: "Whether the terminal input message bar is shown.",
|
||||
},
|
||||
// Per-menu custom content heights set by drag-to-resize. Not user-visible.
|
||||
inline_menu_custom_content_heights: InlineMenuCustomContentHeights {
|
||||
type: HashMap<InlineMenuType, f32>,
|
||||
default: HashMap::default(),
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Never,
|
||||
private: true,
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
impl InputSettings {
|
||||
pub fn input_type(&self, app: &AppContext) -> InputBoxType {
|
||||
let stored_input_type_value = &self.input_box_type;
|
||||
|
||||
// Check if the user has explicitly set the InputBoxTypeSetting
|
||||
let computed_input_type_value = if stored_input_type_value.is_value_explicitly_set() {
|
||||
// User has explicitly set the value, use it
|
||||
**stored_input_type_value
|
||||
} else {
|
||||
// User hasn't set it explicitly, use our computed default.
|
||||
// If the user is in Preview or isn't using PS1, default to UDI.
|
||||
// TODO(CORE-3752): migrate unit and integration tests to pass with UDI instead of Classic
|
||||
let should_default_to_universal = (cfg!(feature = "preview_channel")
|
||||
|| !*SessionSettings::as_ref(app).honor_ps1.value())
|
||||
&& !cfg!(feature = "integration_tests")
|
||||
&& !cfg!(test);
|
||||
|
||||
if should_default_to_universal {
|
||||
InputBoxType::Universal
|
||||
} else {
|
||||
InputBoxType::Classic
|
||||
}
|
||||
};
|
||||
|
||||
// PS1 input is only valid when honor_ps1 is active. If the user has PS1 selected
|
||||
// but the shell has not signalled PS1 support, fall back to Warp input.
|
||||
let is_ps1_enabled = *SessionSettings::as_ref(app).honor_ps1
|
||||
&& computed_input_type_value == InputBoxType::Classic;
|
||||
if is_ps1_enabled {
|
||||
InputBoxType::Classic
|
||||
} else {
|
||||
InputBoxType::Universal
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_universal_developer_input_enabled(&self, app: &AppContext) -> bool {
|
||||
self.input_type(app) == InputBoxType::Universal
|
||||
}
|
||||
|
||||
pub fn is_classic_input_enabled(&self, app: &AppContext) -> bool {
|
||||
self.input_type(app) == InputBoxType::Classic
|
||||
}
|
||||
|
||||
pub fn is_terminal_input_message_bar_enabled(&self) -> bool {
|
||||
*self.show_terminal_input_message_bar
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
use crate::terminal::block_list_viewport::InputMode;
|
||||
use settings::{
|
||||
macros::define_settings_group, RespectUserSyncSetting, Setting, SupportedPlatforms, SyncToCloud,
|
||||
};
|
||||
|
||||
define_settings_group!(InputModeSettings, settings: [
|
||||
input_mode: InputModeState {
|
||||
type: InputMode,
|
||||
// Note that for new users, we now overrride this default value in SettingsInitializer
|
||||
// to set it to InputMode::Waterfall.
|
||||
default: InputMode::PinnedToBottom,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
storage_key: "InputMode",
|
||||
toml_path: "appearance.input.input_mode",
|
||||
description: "The position of the terminal input.",
|
||||
},
|
||||
]);
|
||||
|
||||
impl InputModeSettings {
|
||||
pub fn is_pinned_to_top(&self) -> bool {
|
||||
*self.input_mode.value() == InputMode::PinnedToTop
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
use settings::{macros::define_settings_group, SupportedPlatforms, SyncToCloud};
|
||||
use warpui::platform::linux;
|
||||
|
||||
define_settings_group!(LinuxAppConfiguration,
|
||||
settings: [
|
||||
force_x11: ForceX11 {
|
||||
type: bool,
|
||||
// Default to true on WSL and false on all other platforms.
|
||||
default: !linux::is_wsl(),
|
||||
supported_platforms: SupportedPlatforms::LINUX,
|
||||
sync_to_cloud: SyncToCloud::Never,
|
||||
private: false,
|
||||
toml_path: "system.force_x11",
|
||||
description: "Whether to force X11 instead of Wayland on Linux.",
|
||||
},
|
||||
]
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
pub use settings::macros::*;
|
||||
@@ -0,0 +1 @@
|
||||
pub use settings::manager::*;
|
||||
@@ -0,0 +1,592 @@
|
||||
mod accessibility;
|
||||
pub mod ai;
|
||||
mod alias_expansion;
|
||||
pub mod app_icon;
|
||||
pub mod app_installation_detection;
|
||||
mod block_visibility;
|
||||
mod changelog;
|
||||
pub mod cloud_preferences;
|
||||
pub mod cloud_preferences_syncer;
|
||||
mod code;
|
||||
mod debug;
|
||||
mod editor;
|
||||
mod emacs_bindings;
|
||||
pub mod font;
|
||||
mod gpu;
|
||||
pub mod import;
|
||||
mod init;
|
||||
pub mod initializer;
|
||||
mod input;
|
||||
mod input_mode;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux;
|
||||
pub mod macros;
|
||||
pub mod manager;
|
||||
pub mod native_preference;
|
||||
mod onboarding;
|
||||
mod pane;
|
||||
mod privacy;
|
||||
mod same_line_prompt_block;
|
||||
mod scroll;
|
||||
mod select;
|
||||
mod ssh;
|
||||
mod theme;
|
||||
mod vim_banner;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "schema_validation_tests.rs"]
|
||||
mod schema_validation_tests;
|
||||
|
||||
pub use accessibility::*;
|
||||
pub use ai::*;
|
||||
pub use alias_expansion::*;
|
||||
pub use block_visibility::*;
|
||||
pub use changelog::*;
|
||||
pub use cloud_preferences::*;
|
||||
pub use code::*;
|
||||
pub use debug::*;
|
||||
pub use editor::*;
|
||||
pub use emacs_bindings::*;
|
||||
pub use font::*;
|
||||
pub use gpu::*;
|
||||
pub use init::*;
|
||||
pub use input::*;
|
||||
pub use input_mode::*;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use linux::*;
|
||||
pub use native_preference::*;
|
||||
pub use onboarding::*;
|
||||
pub use pane::*;
|
||||
pub use privacy::*;
|
||||
pub use same_line_prompt_block::*;
|
||||
pub use scroll::*;
|
||||
pub use select::*;
|
||||
pub use ssh::*;
|
||||
pub use theme::*;
|
||||
pub use vim_banner::*;
|
||||
use warp_core::user_preferences::GetUserPreferences as _;
|
||||
|
||||
/// Describes errors encountered when loading settings from `settings.toml`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum SettingsFileError {
|
||||
/// The entire file failed to parse as valid TOML.
|
||||
FileParseFailed(String),
|
||||
/// Individual setting values failed to deserialize. Contains the storage
|
||||
/// keys of the settings that could not be loaded.
|
||||
InvalidSettings(Vec<String>),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SettingsFileError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::FileParseFailed(_) => {
|
||||
write!(f, "Couldn't parse due to invalid syntax")
|
||||
}
|
||||
Self::InvalidSettings(keys) => match keys.as_slice() {
|
||||
[key] => write!(f, "Invalid value for '{key}'"),
|
||||
_ => write!(f, "Invalid values for: {}", keys.join(", ")),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SettingsFileError {
|
||||
/// Returns the user-facing `(heading, description)` pair used to present
|
||||
/// this error. Shared between the workspace-level banner
|
||||
/// (`Workspace::render_settings_error_banner`) and the settings nav rail
|
||||
/// footer (`render_settings_error_alert`) so the two UIs stay in sync.
|
||||
pub fn heading_and_description(&self) -> (String, String) {
|
||||
match self {
|
||||
Self::FileParseFailed(_) => (
|
||||
"Your settings file contains an error.".to_owned(),
|
||||
format!("{self}. Open the file to fix it."),
|
||||
),
|
||||
Self::InvalidSettings(keys) => match keys.len() {
|
||||
1 => (
|
||||
"Your settings file contains an error.".to_owned(),
|
||||
format!("{self}. The default value is being used."),
|
||||
),
|
||||
_ => (
|
||||
"Your settings file contains errors.".to_owned(),
|
||||
format!("{self}. Default values are being used."),
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
use crate::{
|
||||
root_view::QuakeModePinPosition,
|
||||
terminal::{BlockListSettings, BlockPadding},
|
||||
themes::theme::{ThemeKind, WarpTheme},
|
||||
user_config::WarpConfig,
|
||||
};
|
||||
use lazy_static::lazy_static;
|
||||
use pathfinder_geometry::{rect::RectF, vector::Vector2F};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use settings::Setting as _;
|
||||
use std::{collections::HashMap, ops::Mul, path::PathBuf};
|
||||
use warp_core::features::FeatureFlag;
|
||||
use warpui::{
|
||||
elements::DEFAULT_UI_LINE_HEIGHT_RATIO, keymap::Keystroke, AppContext, DisplayIdx,
|
||||
SingletonEntity,
|
||||
};
|
||||
|
||||
// The following are user preferences keys.
|
||||
pub const CHANGELOG_VERSIONS: &str = "ChangelogVersions";
|
||||
pub const RESTORE_SESSION: &str = "RestoreSession";
|
||||
pub const INPUT_MODE: &str = "InputMode";
|
||||
pub const ACTIVATION_HOTKEY_ENABLED: &str = "ActivationHotkeyEnabled";
|
||||
pub const ACTIVATION_HOTKEY_KEYBINDING: &str = "ActivationHotkeyKeybinding";
|
||||
pub const DISMISSED_AI_ASSISTANT_WELCOME_KEY: &str = "DismissedWarpAIWarmWelcome";
|
||||
|
||||
pub const TIMES_TO_SHOW_AUTOSUGGESTION_HINT: i8 = 2;
|
||||
pub const QUAKE_WINDOW_AUTOHIDE_SUPPORTED: bool = cfg!(any(target_os = "macos", windows));
|
||||
|
||||
lazy_static! {
|
||||
pub static ref DEFAULT_QUAKE_MODE_SIZE_PERCENTAGES: HashMap<QuakeModePinPosition, SizePercentages> =
|
||||
HashMap::from_iter([
|
||||
(
|
||||
QuakeModePinPosition::Top,
|
||||
SizePercentages {
|
||||
width: 100,
|
||||
height: 30
|
||||
}
|
||||
),
|
||||
(
|
||||
QuakeModePinPosition::Bottom,
|
||||
SizePercentages {
|
||||
width: 100,
|
||||
height: 30
|
||||
}
|
||||
),
|
||||
(
|
||||
QuakeModePinPosition::Left,
|
||||
SizePercentages {
|
||||
width: 40,
|
||||
height: 100
|
||||
}
|
||||
),
|
||||
(
|
||||
QuakeModePinPosition::Right,
|
||||
SizePercentages {
|
||||
width: 40,
|
||||
height: 100
|
||||
}
|
||||
)
|
||||
]);
|
||||
}
|
||||
|
||||
/// Keys which may be interpreted as the meta key.
|
||||
#[derive(
|
||||
Copy,
|
||||
Clone,
|
||||
Debug,
|
||||
Default,
|
||||
PartialEq,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
schemars::JsonSchema,
|
||||
settings_value::SettingsValue,
|
||||
)]
|
||||
#[schemars(description = "Additional keys that act as the meta key.")]
|
||||
pub struct ExtraMetaKeys {
|
||||
#[schemars(description = "Whether the left Alt key acts as meta.")]
|
||||
pub left_alt: bool,
|
||||
#[schemars(description = "Whether the right Alt key acts as meta.")]
|
||||
pub right_alt: bool,
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Copy,
|
||||
Clone,
|
||||
Debug,
|
||||
Default,
|
||||
PartialEq,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
schemars::JsonSchema,
|
||||
settings_value::SettingsValue,
|
||||
)]
|
||||
#[schemars(description = "What Ctrl+Tab does.", rename_all = "snake_case")]
|
||||
pub enum CtrlTabBehavior {
|
||||
#[default]
|
||||
ActivatePrevNextTab,
|
||||
CycleMostRecentSession,
|
||||
}
|
||||
|
||||
impl CtrlTabBehavior {
|
||||
pub fn as_dropdown_label(&self) -> &str {
|
||||
match self {
|
||||
Self::ActivatePrevNextTab => "Activate previous/next tab",
|
||||
Self::CycleMostRecentSession => "Cycle most recent session",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ExtraMetaKeys {
|
||||
pub fn toggle_left_key(&self) -> Self {
|
||||
ExtraMetaKeys {
|
||||
left_alt: !self.left_alt,
|
||||
right_alt: self.right_alt,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toggle_right_key(&self) -> Self {
|
||||
ExtraMetaKeys {
|
||||
left_alt: self.left_alt,
|
||||
right_alt: !self.right_alt,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// App-wide UI settings.
|
||||
///
|
||||
/// DO NOT ADD ANYTHING NEW HERE!
|
||||
///
|
||||
/// This struct is deprecated; all new settings should make use of the
|
||||
/// macros in app/src/settings/macros.rs.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Settings;
|
||||
|
||||
/// This enum is used to enforce a ternary option with a dropdown in the features page. We may
|
||||
/// later allow users to have both quake mode and activation mode enabled simultaneously. If/when
|
||||
/// that happens we'll remove this enum. These options are not modeled as a ternary option in the
|
||||
/// serialized user-defaults, but as independent options.
|
||||
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize)]
|
||||
pub enum GlobalHotkeyMode {
|
||||
#[default]
|
||||
Disabled,
|
||||
/// "Quake mode" shows a dedicated window with special properties (thanks to it using an Appkit
|
||||
/// NSPanel).
|
||||
QuakeMode,
|
||||
/// "Activation hotkey" shows/hides all of the normal windows
|
||||
ActivationHotkey,
|
||||
}
|
||||
|
||||
impl GlobalHotkeyMode {
|
||||
pub fn as_dropdown_label(&self) -> &str {
|
||||
match self {
|
||||
Self::Disabled => "Disabled",
|
||||
Self::QuakeMode => "Dedicated hotkey window",
|
||||
Self::ActivationHotkey => "Show/hide all windows",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Clone,
|
||||
Copy,
|
||||
PartialEq,
|
||||
Eq,
|
||||
Debug,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
schemars::JsonSchema,
|
||||
settings_value::SettingsValue,
|
||||
)]
|
||||
#[schemars(description = "Window size as width and height percentages of the screen.")]
|
||||
pub struct SizePercentages {
|
||||
#[schemars(description = "Width as a percentage of screen width (0–100).")]
|
||||
pub width: u8,
|
||||
#[schemars(description = "Height as a percentage of screen height (0–100).")]
|
||||
pub height: u8,
|
||||
}
|
||||
|
||||
impl SizePercentages {
|
||||
pub fn width_decimal(&self) -> f32 {
|
||||
(self.width as f32 / 100.).min(1.)
|
||||
}
|
||||
|
||||
pub fn height_decimal(&self) -> f32 {
|
||||
(self.height as f32 / 100.).min(1.)
|
||||
}
|
||||
}
|
||||
|
||||
impl Mul<Vector2F> for SizePercentages {
|
||||
type Output = Vector2F;
|
||||
|
||||
fn mul(self, rhs: Vector2F) -> Vector2F {
|
||||
Vector2F::new(
|
||||
self.width_decimal() * rhs.x(),
|
||||
self.height_decimal() * rhs.y(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Clone,
|
||||
Debug,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
PartialEq,
|
||||
schemars::JsonSchema,
|
||||
settings_value::SettingsValue,
|
||||
)]
|
||||
#[schemars(description = "Configuration for the hotkey window.")]
|
||||
pub struct QuakeModeSettings {
|
||||
#[schemars(
|
||||
description = "Keyboard shortcut to toggle the hotkey window. Format: modifiers (cmd, ctrl, alt, shift, meta) and a key joined by '-', e.g. \"cmd-shift-a\" or \"alt-enter\". Bindings are case-sensitive: when shift is present, the key must be its shifted form (e.g., \"ctrl-shift-E\", not \"ctrl-shift-e\")."
|
||||
)]
|
||||
pub keybinding: Option<Keystroke>,
|
||||
#[schemars(description = "Screen edge where the hotkey window is pinned.")]
|
||||
pub active_pin_position: QuakeModePinPosition,
|
||||
#[schemars(description = "Window size percentages for each pin position.")]
|
||||
pub pin_position_to_size_percentages: HashMap<QuakeModePinPosition, SizePercentages>,
|
||||
#[schemars(description = "Display to pin the hotkey window to.")]
|
||||
pub pin_screen: Option<DisplayIdx>,
|
||||
/// Whether we should hide quake mode window when it loses focus, this could happen either when
|
||||
/// user focuses on another warp window or another app.
|
||||
#[schemars(description = "Whether to hide the hotkey window when it loses focus.")]
|
||||
pub hide_window_when_unfocused: bool,
|
||||
}
|
||||
|
||||
impl Default for QuakeModeSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
keybinding: Default::default(),
|
||||
active_pin_position: Default::default(),
|
||||
pin_position_to_size_percentages: DEFAULT_QUAKE_MODE_SIZE_PERCENTAGES.clone(),
|
||||
pin_screen: Default::default(),
|
||||
// Defaults to `true` only when it's supported on this platform.
|
||||
hide_window_when_unfocused: QUAKE_WINDOW_AUTOHIDE_SUPPORTED,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl QuakeModeSettings {
|
||||
pub fn width_percentage(&self) -> u8 {
|
||||
self.size_percentages_for_pin_position(&self.active_pin_position)
|
||||
.width
|
||||
}
|
||||
|
||||
pub fn height_percentage(&self) -> u8 {
|
||||
self.size_percentages_for_pin_position(&self.active_pin_position)
|
||||
.height
|
||||
}
|
||||
|
||||
pub fn size_changed_from_default(&self) -> bool {
|
||||
self.size_percentages_for_pin_position(&self.active_pin_position)
|
||||
!= *DEFAULT_QUAKE_MODE_SIZE_PERCENTAGES
|
||||
.get(&self.active_pin_position)
|
||||
.expect("Default should have every pin position")
|
||||
}
|
||||
|
||||
pub fn size_percentages_for_pin_position(
|
||||
&self,
|
||||
pin_position: &QuakeModePinPosition,
|
||||
) -> SizePercentages {
|
||||
*self
|
||||
.pin_position_to_size_percentages
|
||||
.get(pin_position)
|
||||
.unwrap_or_else(|| {
|
||||
DEFAULT_QUAKE_MODE_SIZE_PERCENTAGES
|
||||
.get(&self.active_pin_position)
|
||||
.expect("Default should have every pin position")
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolves the display bounds for quake mode (respecting the pinned screen setting)
|
||||
/// and calculates the window bounds.
|
||||
pub fn resolve_quake_mode_bounds(&self, ctx: &mut AppContext) -> RectF {
|
||||
let display_bounds = self
|
||||
.pin_screen
|
||||
.and_then(|display_idx| ctx.windows().bounds_for_display_idx(display_idx))
|
||||
.unwrap_or_else(|| ctx.windows().active_display_bounds());
|
||||
self.calculate_quake_mode_bounds_from_settings(display_bounds)
|
||||
}
|
||||
|
||||
pub fn calculate_quake_mode_bounds_from_settings(&self, display_bounds: RectF) -> RectF {
|
||||
let size_percentages = self.size_percentages_for_pin_position(&self.active_pin_position);
|
||||
let quake_window_size = size_percentages * display_bounds.size();
|
||||
|
||||
match self.active_pin_position {
|
||||
QuakeModePinPosition::Top => {
|
||||
// Position the frame in the center of the display on x-axis.
|
||||
let x_axis_offset =
|
||||
display_bounds.size().x() * (1. - size_percentages.width_decimal()) / 2.;
|
||||
let quake_window_origin = Vector2F::new(
|
||||
display_bounds.origin().x() + x_axis_offset,
|
||||
display_bounds.origin().y(),
|
||||
);
|
||||
|
||||
RectF::new(quake_window_origin, quake_window_size)
|
||||
}
|
||||
QuakeModePinPosition::Bottom => {
|
||||
// Position the frame in the center of the display on x-axis.
|
||||
let x_axis_offset =
|
||||
display_bounds.size().x() * (1. - size_percentages.width_decimal()) / 2.;
|
||||
let quake_window_origin = Vector2F::new(
|
||||
display_bounds.origin().x() + x_axis_offset,
|
||||
display_bounds.lower_left().y() - quake_window_size.y(),
|
||||
);
|
||||
|
||||
RectF::new(quake_window_origin, quake_window_size)
|
||||
}
|
||||
QuakeModePinPosition::Left => {
|
||||
// Position the frame in the center of the display on y-axis.
|
||||
let y_axis_offset =
|
||||
display_bounds.size().y() * (1. - size_percentages.height_decimal()) / 2.;
|
||||
let quake_window_origin = Vector2F::new(
|
||||
display_bounds.origin().x(),
|
||||
display_bounds.origin().y() + y_axis_offset,
|
||||
);
|
||||
|
||||
RectF::new(quake_window_origin, quake_window_size)
|
||||
}
|
||||
QuakeModePinPosition::Right => {
|
||||
// Position the frame in the center of the display on y-axis.
|
||||
let y_axis_offset =
|
||||
display_bounds.size().y() * (1. - size_percentages.height_decimal()) / 2.;
|
||||
let quake_window_origin = Vector2F::new(
|
||||
display_bounds.upper_right().x() - quake_window_size.x(),
|
||||
display_bounds.origin().y() + y_axis_offset,
|
||||
);
|
||||
|
||||
RectF::new(quake_window_origin, quake_window_size)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Circumstances when FG color can be automatically changed to increase contrast with BG color
|
||||
#[derive(
|
||||
Clone,
|
||||
Copy,
|
||||
Debug,
|
||||
Default,
|
||||
PartialEq,
|
||||
Eq,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
schemars::JsonSchema,
|
||||
settings_value::SettingsValue,
|
||||
)]
|
||||
#[schemars(
|
||||
description = "When to adjust foreground color to ensure readability against the background.",
|
||||
rename_all = "snake_case"
|
||||
)]
|
||||
pub enum EnforceMinimumContrast {
|
||||
/// Never change FG color
|
||||
Never,
|
||||
/// FG color can be changed, but only if the FG is specified with default colors
|
||||
#[default]
|
||||
OnlyNamedColors,
|
||||
/// FG color is changed regardless of how FG was specified
|
||||
Always,
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
pub fn has_changelog_been_shown(changelog_version: &str, ctx: &mut AppContext) -> bool {
|
||||
let changelog_versions = ctx
|
||||
.private_user_preferences()
|
||||
.read_value(CHANGELOG_VERSIONS)
|
||||
.unwrap_or_default();
|
||||
changelog_versions.is_some_and(|versions| -> bool {
|
||||
let res = serde_json::from_str::<Value>(&versions);
|
||||
match res {
|
||||
Ok(versions) => versions[&changelog_version].as_bool().unwrap_or(false),
|
||||
Err(e) => {
|
||||
log::warn!("Error deserializing changlog user default {e}");
|
||||
false
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn mark_changelog_shown(changelog_version: &str, ctx: &mut AppContext) -> bool {
|
||||
ctx.private_user_preferences()
|
||||
.read_value(CHANGELOG_VERSIONS)
|
||||
.unwrap_or_default()
|
||||
.map_or(Ok(json!({})), |versions| {
|
||||
serde_json::from_str::<Value>(&versions)
|
||||
})
|
||||
.is_ok_and(|mut versions| {
|
||||
log::info!(
|
||||
"Marking changelog {changelog_version} as shown in versions {versions:?}"
|
||||
);
|
||||
|
||||
versions[&changelog_version] = Value::Bool(true);
|
||||
let _ = ctx.private_user_preferences().write_value(
|
||||
CHANGELOG_VERSIONS,
|
||||
serde_json::to_string(&versions).expect("changelog versions should serialize"),
|
||||
);
|
||||
true
|
||||
})
|
||||
}
|
||||
|
||||
pub fn theme_for_theme_kind(theme_kind: &ThemeKind, ctx: &mut AppContext) -> WarpTheme {
|
||||
match theme_kind {
|
||||
ThemeKind::InMemory(in_memory_theme) => in_memory_theme.theme(),
|
||||
_ => WarpConfig::as_ref(ctx).theme_config().theme(theme_kind),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Terminal Spacing settings. BlockPadding and inline_separator_height values are measured in grid
|
||||
/// cells, not pixels.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct TerminalSpacing {
|
||||
pub block_padding: BlockPadding,
|
||||
pub prompt_to_editor_padding: f32,
|
||||
pub editor_bottom_padding: f32,
|
||||
pub block_borders_enabled: bool,
|
||||
pub overflow_offset: f32,
|
||||
pub subshell_separator_height: f32,
|
||||
}
|
||||
|
||||
impl TerminalSpacing {
|
||||
pub fn normal(line_height_ratio: f32, ctx: &AppContext) -> Self {
|
||||
Self {
|
||||
block_padding: BlockPadding {
|
||||
padding_top: 1.1 * (DEFAULT_UI_LINE_HEIGHT_RATIO / line_height_ratio).min(1.0),
|
||||
command_padding_top: 0.19
|
||||
* (DEFAULT_UI_LINE_HEIGHT_RATIO / line_height_ratio).min(1.0),
|
||||
middle: 0.5 * (DEFAULT_UI_LINE_HEIGHT_RATIO / line_height_ratio).min(1.0),
|
||||
bottom: 1. * (DEFAULT_UI_LINE_HEIGHT_RATIO / line_height_ratio).min(1.0),
|
||||
},
|
||||
prompt_to_editor_padding: 10.,
|
||||
editor_bottom_padding: 20.,
|
||||
block_borders_enabled: *BlockListSettings::as_ref(ctx).show_block_dividers.value()
|
||||
|| !FeatureFlag::MinimalistUI.is_enabled(),
|
||||
overflow_offset: 12.,
|
||||
// Subshell separators are actually hidden in normal spacing b/c they are meant to be
|
||||
// shown inside the block padding instead.
|
||||
subshell_separator_height: 0.,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn compact(line_height_ratio: f32, ctx: &AppContext) -> Self {
|
||||
Self {
|
||||
block_padding: BlockPadding {
|
||||
padding_top: 0.3 * (DEFAULT_UI_LINE_HEIGHT_RATIO / line_height_ratio).min(1.0),
|
||||
command_padding_top: 0.,
|
||||
middle: 0.,
|
||||
bottom: 0.2 * (DEFAULT_UI_LINE_HEIGHT_RATIO / line_height_ratio).min(1.0),
|
||||
},
|
||||
prompt_to_editor_padding: 0.,
|
||||
editor_bottom_padding: 4.,
|
||||
block_borders_enabled: *BlockListSettings::as_ref(ctx).show_block_dividers.value()
|
||||
|| !FeatureFlag::MinimalistUI.is_enabled(),
|
||||
overflow_offset: 6.,
|
||||
subshell_separator_height: 1.1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The argument type for set_extra_meta_keys action.
|
||||
#[derive(Clone)]
|
||||
pub struct ExtraMetaKeysChangedArg {
|
||||
pub keys: ExtraMetaKeys,
|
||||
}
|
||||
|
||||
/// Returns the path to the user preferences file.
|
||||
pub fn user_preferences_file_path() -> PathBuf {
|
||||
warp_core::paths::config_local_dir().join("user_preferences.json")
|
||||
}
|
||||
|
||||
/// Returns the path to the TOML settings file.
|
||||
pub fn user_preferences_toml_file_path() -> PathBuf {
|
||||
warp_core::paths::config_local_dir().join("settings.toml")
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use settings::{macros::define_settings_group, SupportedPlatforms, SyncToCloud};
|
||||
|
||||
#[derive(
|
||||
Clone,
|
||||
Copy,
|
||||
Debug,
|
||||
Default,
|
||||
Eq,
|
||||
PartialEq,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
schemars::JsonSchema,
|
||||
settings_value::SettingsValue,
|
||||
)]
|
||||
#[schemars(
|
||||
description = "Preference for using the native desktop app or the web app.",
|
||||
rename_all = "snake_case"
|
||||
)]
|
||||
pub enum UserNativePreference {
|
||||
#[default]
|
||||
NotSelected,
|
||||
Web,
|
||||
Desktop,
|
||||
}
|
||||
|
||||
define_settings_group!(NativePreferenceSettings, settings: [
|
||||
user_native_redirect_preference: UserNativeRedirectPreference {
|
||||
type: UserNativePreference,
|
||||
default: UserNativePreference::default(),
|
||||
supported_platforms: SupportedPlatforms::WEB,
|
||||
// Once setting sync is enabled we should sync this to the cloud
|
||||
sync_to_cloud: SyncToCloud::Never,
|
||||
private: false,
|
||||
storage_key: "UserNativePreference",
|
||||
toml_path: "general.user_native_preference",
|
||||
description: "Whether to prefer the native desktop app or the web app.",
|
||||
},
|
||||
preference_dialog_dismissed: UserNativePreferenceDialogDismissed {
|
||||
type: bool,
|
||||
default: false,
|
||||
supported_platforms: SupportedPlatforms::WEB,
|
||||
sync_to_cloud: SyncToCloud::Never,
|
||||
private: true,
|
||||
},
|
||||
]);
|
||||
@@ -0,0 +1,231 @@
|
||||
use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel;
|
||||
use crate::ai::execution_profiles::{ActionPermission, WriteToPtyPermission};
|
||||
use crate::drive::settings::WarpDriveSettings;
|
||||
use crate::report_if_error;
|
||||
use crate::settings::ai::DefaultSessionMode;
|
||||
use crate::settings::{AISettings, CodeSettings};
|
||||
use crate::workspace::tab_settings::TabSettings;
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
use onboarding::slides::{AgentAutonomy, AgentDevelopmentSettings};
|
||||
use onboarding::{SelectedSettings, SessionDefault, UICustomizationSettings};
|
||||
use settings::Setting as _;
|
||||
use warp_core::features::FeatureFlag;
|
||||
use warpui::{AppContext, SingletonEntity as _};
|
||||
|
||||
/// Applies onboarding settings based on the user's selected mode.
|
||||
pub fn apply_onboarding_settings(selected_settings: &SelectedSettings, app: &mut AppContext) {
|
||||
let is_ai_enabled = match selected_settings {
|
||||
SelectedSettings::AgentDrivenDevelopment {
|
||||
agent_settings,
|
||||
ui_customization,
|
||||
..
|
||||
} => {
|
||||
apply_agent_settings(agent_settings, app);
|
||||
let is_ai_enabled = !agent_settings.disable_oz;
|
||||
if let Some(ui) = ui_customization {
|
||||
apply_ui_customization_settings(ui, true, app);
|
||||
}
|
||||
is_ai_enabled
|
||||
}
|
||||
SelectedSettings::Terminal {
|
||||
ui_customization,
|
||||
cli_agent_toolbar_enabled,
|
||||
show_agent_notifications,
|
||||
} => {
|
||||
// In old onboarding, there's nothing to set for terminal intent.
|
||||
if !FeatureFlag::OpenWarpNewSettingsModes.is_enabled() {
|
||||
true
|
||||
} else {
|
||||
if let Some(ui) = ui_customization {
|
||||
apply_ui_customization_settings(ui, false, app);
|
||||
}
|
||||
AISettings::handle(app).update(app, |settings, ctx| {
|
||||
report_if_error!(settings
|
||||
.should_render_cli_agent_footer
|
||||
.set_value(*cli_agent_toolbar_enabled, ctx));
|
||||
report_if_error!(settings
|
||||
.show_agent_notifications
|
||||
.set_value(*show_agent_notifications, ctx));
|
||||
});
|
||||
false
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if FeatureFlag::OpenWarpNewSettingsModes.is_enabled() {
|
||||
AISettings::handle(app).update(app, |settings, ctx| {
|
||||
report_if_error!(settings.is_any_ai_enabled.set_value(is_ai_enabled, ctx));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies the explicit UI customization settings chosen during the
|
||||
/// "Customize your UI" onboarding slide.
|
||||
fn apply_ui_customization_settings(
|
||||
ui: &UICustomizationSettings,
|
||||
is_agent_intent: bool,
|
||||
app: &mut AppContext,
|
||||
) {
|
||||
// Customize UI slide should only exist with this flag enabled.
|
||||
if !FeatureFlag::OpenWarpNewSettingsModes.is_enabled() {
|
||||
return;
|
||||
}
|
||||
TabSettings::handle(app).update(app, |settings, ctx| {
|
||||
report_if_error!(settings
|
||||
.use_vertical_tabs
|
||||
.set_value(ui.use_vertical_tabs, ctx));
|
||||
report_if_error!(settings
|
||||
.show_code_review_button
|
||||
.set_value(ui.show_code_review_button, ctx));
|
||||
});
|
||||
|
||||
WarpDriveSettings::handle(app).update(app, |settings, ctx| {
|
||||
report_if_error!(settings
|
||||
.enable_warp_drive
|
||||
.set_value(ui.show_warp_drive, ctx));
|
||||
});
|
||||
|
||||
CodeSettings::handle(app).update(app, |settings, ctx| {
|
||||
report_if_error!(settings
|
||||
.show_project_explorer
|
||||
.set_value(ui.show_project_explorer, ctx));
|
||||
report_if_error!(settings
|
||||
.show_global_search
|
||||
.set_value(ui.show_global_search, ctx));
|
||||
});
|
||||
|
||||
// For agent intent, configure showing conversation history.
|
||||
// For terminal intent, this option was not surfaced in onboarding, so leave the default.
|
||||
// It will be hidden anyway because AI is off, but we want to keep the default in case they enable AI later.
|
||||
if is_agent_intent {
|
||||
AISettings::handle(app).update(app, |settings, ctx| {
|
||||
report_if_error!(settings
|
||||
.show_conversation_history
|
||||
.set_value(ui.show_conversation_history, ctx));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_agent_settings(agent_settings: &AgentDevelopmentSettings, app: &mut AppContext) {
|
||||
// Apply session default mode.
|
||||
let default_mode = match agent_settings.session_default {
|
||||
SessionDefault::Agent => DefaultSessionMode::Agent,
|
||||
SessionDefault::Terminal => DefaultSessionMode::Terminal,
|
||||
};
|
||||
AISettings::handle(app).update(app, |settings, ctx| {
|
||||
report_if_error!(settings
|
||||
.default_session_mode_internal
|
||||
.set_value(default_mode, ctx));
|
||||
});
|
||||
|
||||
let workspace_autonomy_settings = UserWorkspaces::as_ref(app).ai_autonomy_settings();
|
||||
|
||||
AISettings::handle(app).update(app, |settings, ctx| {
|
||||
report_if_error!(settings
|
||||
.should_render_cli_agent_footer
|
||||
.set_value(agent_settings.cli_agent_toolbar_enabled, ctx));
|
||||
report_if_error!(settings
|
||||
.show_agent_notifications
|
||||
.set_value(agent_settings.show_agent_notifications, ctx));
|
||||
});
|
||||
|
||||
AIExecutionProfilesModel::handle(app).update(app, |profiles, ctx| {
|
||||
let default_profile_info = profiles.default_profile(ctx);
|
||||
let default_profile_id = *default_profile_info.id();
|
||||
|
||||
// Preserve the existing cloud default profile for users who are
|
||||
// already logged in (or who log in at the end of onboarding). A
|
||||
// `Some` sync_id means the profile is backed by a cloud object that
|
||||
// was either loaded at startup or reconciled during the post-login
|
||||
// initial load, and its values represent what the user has stored
|
||||
// previously. Overwriting those with the onboarding-selected
|
||||
// base_model / autonomy would silently discard their prior
|
||||
// customizations. Fresh `Unsynced` default profiles (brand-new
|
||||
// users, or users without any cloud default yet) still receive the
|
||||
// onboarding values.
|
||||
if default_profile_info.sync_id().is_some() {
|
||||
log::info!(
|
||||
"Preserving existing cloud default execution profile; skipping \
|
||||
onboarding-driven overrides for profile {default_profile_id:?}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
profiles.set_base_model(
|
||||
default_profile_id,
|
||||
Some(agent_settings.selected_model_id.clone()),
|
||||
ctx,
|
||||
);
|
||||
|
||||
// If autonomy is None, the workspace enforces autonomy settings, so skip setting them.
|
||||
let Some(autonomy) = agent_settings.autonomy else {
|
||||
return;
|
||||
};
|
||||
|
||||
let permissions = action_permissions_for_onboarding_autonomy(autonomy);
|
||||
|
||||
// Only set permissions that are not enforced by the workspace
|
||||
if !workspace_autonomy_settings.has_override_for_code_diffs() {
|
||||
profiles.set_apply_code_diffs(default_profile_id, &permissions.apply_code_diffs, ctx);
|
||||
}
|
||||
if !workspace_autonomy_settings.has_override_for_read_files() {
|
||||
profiles.set_read_files(default_profile_id, &permissions.read_files, ctx);
|
||||
}
|
||||
if !workspace_autonomy_settings.has_override_for_execute_commands() {
|
||||
profiles.set_execute_commands(default_profile_id, &permissions.execute_commands, ctx);
|
||||
}
|
||||
// Note: MCP permissions don't have a workspace-level override, so always set them
|
||||
profiles.set_mcp_permissions(default_profile_id, &permissions.mcp_permissions, ctx);
|
||||
if !workspace_autonomy_settings.has_override_for_write_to_pty() {
|
||||
profiles.set_write_to_pty(default_profile_id, &permissions.write_to_pty, ctx);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
struct OnboardingAutonomyPermissions {
|
||||
apply_code_diffs: ActionPermission,
|
||||
read_files: ActionPermission,
|
||||
execute_commands: ActionPermission,
|
||||
mcp_permissions: ActionPermission,
|
||||
write_to_pty: WriteToPtyPermission,
|
||||
}
|
||||
|
||||
fn action_permissions_for_onboarding_autonomy(
|
||||
autonomy: AgentAutonomy,
|
||||
) -> OnboardingAutonomyPermissions {
|
||||
match autonomy {
|
||||
// Full autonomy promises "Runs commands, writes code, and reads files
|
||||
// without asking," so every permission is `AlwaysAllow`. The command
|
||||
// denylist still takes precedence at runtime when a specific command
|
||||
// is considered unsafe.
|
||||
AgentAutonomy::Full => OnboardingAutonomyPermissions {
|
||||
apply_code_diffs: ActionPermission::AlwaysAllow,
|
||||
read_files: ActionPermission::AlwaysAllow,
|
||||
execute_commands: ActionPermission::AlwaysAllow,
|
||||
mcp_permissions: ActionPermission::AlwaysAllow,
|
||||
write_to_pty: WriteToPtyPermission::AlwaysAllow,
|
||||
},
|
||||
// Partial autonomy: reads are always allowed, applying code diffs
|
||||
// always asks, and the agent decides on command / MCP execution
|
||||
// (asking only for sensitive actions).
|
||||
AgentAutonomy::Partial => OnboardingAutonomyPermissions {
|
||||
apply_code_diffs: ActionPermission::AlwaysAsk,
|
||||
read_files: ActionPermission::AlwaysAllow,
|
||||
execute_commands: ActionPermission::AgentDecides,
|
||||
mcp_permissions: ActionPermission::AgentDecides,
|
||||
write_to_pty: WriteToPtyPermission::AlwaysAsk,
|
||||
},
|
||||
AgentAutonomy::None => OnboardingAutonomyPermissions {
|
||||
apply_code_diffs: ActionPermission::AlwaysAsk,
|
||||
read_files: ActionPermission::AlwaysAsk,
|
||||
execute_commands: ActionPermission::AlwaysAsk,
|
||||
mcp_permissions: ActionPermission::AlwaysAsk,
|
||||
write_to_pty: WriteToPtyPermission::AlwaysAsk,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "onboarding_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,170 @@
|
||||
use ai::LLMId;
|
||||
use chrono::{DateTime, Utc};
|
||||
use onboarding::slides::{AgentAutonomy, AgentDevelopmentSettings, ProjectOnboardingSettings};
|
||||
use onboarding::SelectedSettings;
|
||||
use warpui::{App, SingletonEntity};
|
||||
|
||||
use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel;
|
||||
use crate::ai::execution_profiles::{
|
||||
AIExecutionProfile, ActionPermission, CloudAIExecutionProfileModel,
|
||||
};
|
||||
use crate::ai::mcp::TemplatableMCPServerManager;
|
||||
use crate::auth::AuthStateProvider;
|
||||
use crate::cloud_object::model::persistence::{CloudModel, CloudModelEvent};
|
||||
use crate::cloud_object::{Revision, ServerAIExecutionProfile, ServerMetadata, ServerPermissions};
|
||||
use crate::network::NetworkStatus;
|
||||
use crate::server::cloud_objects::update_manager::UpdateManager;
|
||||
use crate::server::ids::{ServerId, SyncId};
|
||||
use crate::server::sync_queue::SyncQueue;
|
||||
use crate::settings::{apply_onboarding_settings, PrivacySettings};
|
||||
use crate::test_util::settings::initialize_settings_for_tests;
|
||||
use crate::workspaces::team_tester::TeamTesterStatus;
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
use crate::LaunchMode;
|
||||
|
||||
fn mock_server_metadata(uid: ServerId) -> ServerMetadata {
|
||||
ServerMetadata {
|
||||
uid,
|
||||
revision: Revision::now(),
|
||||
metadata_last_updated_ts: DateTime::<Utc>::default().into(),
|
||||
trashed_ts: None,
|
||||
folder_id: None,
|
||||
is_welcome_object: false,
|
||||
creator_uid: None,
|
||||
last_editor_uid: None,
|
||||
current_editor_uid: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Regression test for: "Logging in to an existing user at the end of
|
||||
/// onboarding should preserve the user's cloud-stored default execution
|
||||
/// profile rather than overwriting it with the onboarding-selected
|
||||
/// base_model and autonomy."
|
||||
///
|
||||
/// Simulates the full post-login flow:
|
||||
/// 1. User starts unauthenticated; `AIExecutionProfilesModel` begins in
|
||||
/// `Unsynced`.
|
||||
/// 2. User logs into an existing account; their cloud default profile
|
||||
/// arrives via initial bulk load and `InitialLoadCompleted` fires.
|
||||
/// 3. The reconciliation handler promotes the local state to `Synced`
|
||||
/// with the cloud profile's `sync_id`.
|
||||
/// 4. `apply_onboarding_settings` runs (as it would from
|
||||
/// `handle_cloud_preferences_syncer_event`) with onboarding-selected
|
||||
/// values that differ from what's on the cloud profile.
|
||||
/// 5. The cloud profile's stored values must be preserved.
|
||||
#[test]
|
||||
fn apply_onboarding_settings_preserves_existing_cloud_profile_on_existing_user_login() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_settings_for_tests(&mut app);
|
||||
app.add_singleton_model(|_| AuthStateProvider::new_for_test());
|
||||
app.add_singleton_model(SyncQueue::mock);
|
||||
app.add_singleton_model(|_| NetworkStatus::new());
|
||||
app.add_singleton_model(TeamTesterStatus::mock);
|
||||
app.add_singleton_model(UpdateManager::mock);
|
||||
app.add_singleton_model(CloudModel::mock);
|
||||
app.add_singleton_model(|_| TemplatableMCPServerManager::default());
|
||||
app.add_singleton_model(PrivacySettings::mock);
|
||||
app.add_singleton_model(UserWorkspaces::default_mock);
|
||||
let profile_model = app.add_singleton_model(|ctx| {
|
||||
AIExecutionProfilesModel::new(&LaunchMode::new_for_unit_test(), ctx)
|
||||
});
|
||||
|
||||
// The existing user's stored cloud default profile. Values are
|
||||
// deliberately chosen to differ from both `AIExecutionProfile`'s
|
||||
// defaults and from the onboarding values we'll pass below, so any
|
||||
// accidental overwrite is detectable.
|
||||
let cloud_uid = ServerId::from(7);
|
||||
let cloud_sync_id = SyncId::ServerId(cloud_uid);
|
||||
let cloud_stored_model = LLMId::from("claude-existing-cloud-model");
|
||||
let cloud_profile = AIExecutionProfile {
|
||||
name: "Default".to_string(),
|
||||
is_default_profile: true,
|
||||
base_model: Some(cloud_stored_model.clone()),
|
||||
apply_code_diffs: ActionPermission::AlwaysAllow,
|
||||
read_files: ActionPermission::AlwaysAllow,
|
||||
execute_commands: ActionPermission::AlwaysAllow,
|
||||
mcp_permissions: ActionPermission::AlwaysAllow,
|
||||
..Default::default()
|
||||
};
|
||||
let server_object = ServerAIExecutionProfile {
|
||||
id: cloud_sync_id,
|
||||
model: CloudAIExecutionProfileModel::new(cloud_profile),
|
||||
metadata: mock_server_metadata(cloud_uid),
|
||||
permissions: ServerPermissions::mock_personal(),
|
||||
};
|
||||
|
||||
// Insert the existing user's cloud profile via the initial-load
|
||||
// path (no per-object events) and emit `InitialLoadCompleted` so
|
||||
// `AIExecutionProfilesModel` reconciles to `Synced`.
|
||||
CloudModel::handle(&app).update(&mut app, move |cloud_model, ctx| {
|
||||
let server_objects: Vec<ServerAIExecutionProfile> = vec![server_object];
|
||||
cloud_model.update_objects_from_initial_load(server_objects, false, false, ctx);
|
||||
ctx.emit(CloudModelEvent::InitialLoadCompleted);
|
||||
});
|
||||
|
||||
// Sanity: reconciliation occurred and the model now reads the
|
||||
// cloud profile.
|
||||
profile_model.read(&app, |model, ctx| {
|
||||
let info = model.default_profile(ctx);
|
||||
assert_eq!(info.sync_id(), Some(cloud_sync_id));
|
||||
assert_eq!(info.data().base_model, Some(cloud_stored_model.clone()));
|
||||
});
|
||||
|
||||
// Simulate the onboarding handoff: the user picked a different
|
||||
// base_model and "None" autonomy on the agent slide, which would
|
||||
// map to every `ActionPermission` being `AlwaysAsk`.
|
||||
let onboarding_settings = SelectedSettings::AgentDrivenDevelopment {
|
||||
agent_settings: AgentDevelopmentSettings {
|
||||
selected_model_id: LLMId::from("onboarding-chosen-model"),
|
||||
autonomy: Some(AgentAutonomy::None),
|
||||
cli_agent_toolbar_enabled: true,
|
||||
session_default: onboarding::SessionDefault::Agent,
|
||||
disable_oz: false,
|
||||
show_agent_notifications: true,
|
||||
},
|
||||
project_settings: ProjectOnboardingSettings::default(),
|
||||
ui_customization: None,
|
||||
};
|
||||
|
||||
app.update(|ctx| {
|
||||
apply_onboarding_settings(&onboarding_settings, ctx);
|
||||
});
|
||||
|
||||
// Post-condition: the cloud profile retains its stored values.
|
||||
// Every field touched by `apply_agent_settings` should be
|
||||
// unchanged.
|
||||
profile_model.read(&app, |model, ctx| {
|
||||
let info = model.default_profile(ctx);
|
||||
assert_eq!(
|
||||
info.sync_id(),
|
||||
Some(cloud_sync_id),
|
||||
"still pointing at the existing cloud profile"
|
||||
);
|
||||
assert_eq!(
|
||||
info.data().base_model,
|
||||
Some(cloud_stored_model.clone()),
|
||||
"base_model should not be overwritten by onboarding for existing users"
|
||||
);
|
||||
assert_eq!(
|
||||
info.data().apply_code_diffs,
|
||||
ActionPermission::AlwaysAllow,
|
||||
"apply_code_diffs should not be overwritten by onboarding for existing users"
|
||||
);
|
||||
assert_eq!(
|
||||
info.data().read_files,
|
||||
ActionPermission::AlwaysAllow,
|
||||
"read_files should not be overwritten by onboarding for existing users"
|
||||
);
|
||||
assert_eq!(
|
||||
info.data().execute_commands,
|
||||
ActionPermission::AlwaysAllow,
|
||||
"execute_commands should not be overwritten by onboarding for existing users"
|
||||
);
|
||||
assert_eq!(
|
||||
info.data().mcp_permissions,
|
||||
ActionPermission::AlwaysAllow,
|
||||
"mcp_permissions should not be overwritten by onboarding for existing users"
|
||||
);
|
||||
});
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
use settings::{
|
||||
macros::define_settings_group, RespectUserSyncSetting, SupportedPlatforms, SyncToCloud,
|
||||
};
|
||||
|
||||
define_settings_group!(PaneSettings, settings: [
|
||||
should_dim_inactive_panes: ShouldDimInactivePanes {
|
||||
type: bool,
|
||||
default: false,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "appearance.panes.should_dim_inactive_panes",
|
||||
description: "Whether inactive panes are visually dimmed.",
|
||||
},
|
||||
focus_panes_on_hover: FocusPaneOnHover {
|
||||
type: bool,
|
||||
default: false,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "appearance.panes.focus_pane_on_hover",
|
||||
description: "Whether panes are focused when hovered over.",
|
||||
}
|
||||
]);
|
||||
@@ -0,0 +1,865 @@
|
||||
use std::fmt::Display;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use regex::Regex;
|
||||
use warp_core::features::FeatureFlag;
|
||||
use warp_core::report_if_error;
|
||||
use warp_core::user_preferences::GetUserPreferences as _;
|
||||
use warpui::{AppContext, Entity, ModelContext, SingletonEntity, UpdateModel};
|
||||
|
||||
use crate::ai::blocklist::telemetry_banner::should_collect_ai_ugc_telemetry;
|
||||
use crate::auth::auth_state::AuthState;
|
||||
use crate::auth::AuthStateProvider;
|
||||
use crate::cloud_object::model::persistence::CloudModel;
|
||||
use crate::report_error;
|
||||
use crate::server::cloud_objects::update_manager::UpdateManager;
|
||||
#[cfg(test)]
|
||||
use crate::server::server_api::auth::MockAuthClient;
|
||||
use crate::server::server_api::auth::{AuthClient, SyncedUserSettings};
|
||||
use crate::server::server_api::ServerApiProvider;
|
||||
use crate::terminal::safe_mode_settings::SafeModeSettings;
|
||||
|
||||
use settings::{
|
||||
macros::{define_settings_group, maybe_define_setting, register_settings_events},
|
||||
RespectUserSyncSetting, Setting, SupportedPlatforms, SyncToCloud,
|
||||
};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::cloud_preferences_syncer::CloudPreferencesSyncer;
|
||||
use crate::workspaces::workspace::EnterpriseSecretRegex;
|
||||
|
||||
pub trait RegexDisplayInfo {
|
||||
fn pattern(&self) -> &str;
|
||||
fn name(&self) -> Option<&str>;
|
||||
}
|
||||
|
||||
pub const TELEMETRY_ENABLED_DEFAULTS_KEY: &str = "TelemetryEnabled";
|
||||
pub const CRASH_REPORTING_ENABLED_DEFAULTS_KEY: &str = "CrashReportingEnabled";
|
||||
pub const CLOUD_CONVERSATION_STORAGE_ENABLED_DEFAULTS_KEY: &str = "CloudConversationStorageEnabled";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
|
||||
#[schemars(description = "A custom regex pattern for detecting and redacting secrets.")]
|
||||
pub struct CustomSecretRegex {
|
||||
#[serde(with = "serde_regex")]
|
||||
#[schemars(with = "String", description = "The regex pattern to match secrets.")]
|
||||
pub pattern: Regex,
|
||||
#[serde(default)]
|
||||
#[schemars(description = "Optional display name for this secret pattern.")]
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
impl CustomSecretRegex {
|
||||
pub fn pattern(&self) -> &Regex {
|
||||
&self.pattern
|
||||
}
|
||||
}
|
||||
|
||||
impl RegexDisplayInfo for CustomSecretRegex {
|
||||
fn pattern(&self) -> &str {
|
||||
self.pattern.as_str()
|
||||
}
|
||||
|
||||
fn name(&self) -> Option<&str> {
|
||||
self.name.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
impl RegexDisplayInfo for EnterpriseSecretRegex {
|
||||
fn pattern(&self) -> &str {
|
||||
&self.pattern
|
||||
}
|
||||
|
||||
fn name(&self) -> Option<&str> {
|
||||
self.name.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for CustomSecretRegex {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.pattern.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for CustomSecretRegex {
|
||||
/// We do not factor in the name to equality checks --
|
||||
/// if the regex is the same, then the regex is the same.
|
||||
/// This allows us to avoid adding duplicate regexes.
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.pattern.as_str() == other.pattern.as_str()
|
||||
}
|
||||
}
|
||||
|
||||
impl settings_value::SettingsValue for CustomSecretRegex {}
|
||||
|
||||
define_settings_group!(WarpDrivePrivacySettings, settings: [
|
||||
is_telemetry_enabled: IsTelemetryEnabled {
|
||||
type: bool,
|
||||
default: true,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::No),
|
||||
private: false,
|
||||
storage_key: "TelemetryEnabled",
|
||||
toml_path: "privacy.telemetry_enabled",
|
||||
description: "Whether anonymous usage telemetry is collected.",
|
||||
},
|
||||
is_crash_reporting_enabled: IsCrashReportingEnabled {
|
||||
type: bool,
|
||||
default: true,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::No),
|
||||
private: false,
|
||||
storage_key: "CrashReportingEnabled",
|
||||
toml_path: "privacy.crash_reporting_enabled",
|
||||
description: "Whether crash reports are sent.",
|
||||
},
|
||||
is_cloud_conversation_storage_enabled: IsCloudConversationStorageEnabled {
|
||||
type: bool,
|
||||
default: true,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::No),
|
||||
private: false,
|
||||
storage_key: "CloudConversationStorageEnabled",
|
||||
toml_path: "agents.cloud_conversation_storage_enabled",
|
||||
description: "Whether conversations are stored in the cloud.",
|
||||
},
|
||||
]);
|
||||
|
||||
maybe_define_setting!(CustomSecretRegexList, group: PrivacySettings, {
|
||||
type: Vec<CustomSecretRegex>,
|
||||
default: Vec::new(),
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::No),
|
||||
private: false,
|
||||
toml_path: "privacy.custom_secret_regex_list",
|
||||
description: "Custom regex patterns for detecting and redacting secrets.",
|
||||
});
|
||||
|
||||
maybe_define_setting!(HasInitializedDefaultSecretRegexes, group: PrivacySettings, {
|
||||
type: bool,
|
||||
default: false,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::No),
|
||||
private: true,
|
||||
});
|
||||
|
||||
/// Singleton model for managing the user's privacy settings (whether the user has enabled crash
|
||||
/// reporting and/or telemetry).
|
||||
pub struct PrivacySettings {
|
||||
auth_state: Arc<AuthState>,
|
||||
auth_client: Arc<dyn AuthClient>,
|
||||
pub is_telemetry_enabled: bool,
|
||||
pub is_crash_reporting_enabled: bool,
|
||||
pub is_cloud_conversation_storage_enabled: bool,
|
||||
pub has_initialized_default_secret_regexes: HasInitializedDefaultSecretRegexes,
|
||||
/// List of user defined secret regexes.
|
||||
/// Enterprise-level secret regexes will always take precedence over user-level secrets,
|
||||
/// but they both used to support additive behavior.
|
||||
/// It's a [Vec<CustomSecretRegex>], but also a user setting.
|
||||
pub user_secret_regex_list: CustomSecretRegexList,
|
||||
/// List of enterprise-level secret regexes provided by the organization.
|
||||
/// These are kept separate from user-level secrets to support additive behavior.
|
||||
pub enterprise_secret_regex_list: Vec<CustomSecretRegex>,
|
||||
/// Whether or not the user's organization has forced telemetry on, in which case we ignore any
|
||||
/// user local/cloud settings. If false, we fall back to the user's settings.
|
||||
/// This is populated by the server when teams data is fetched.
|
||||
pub is_telemetry_force_enabled: bool,
|
||||
/// Whether or not the user's organization has enabled enterprise secret redaction.
|
||||
/// This is populated by the server when teams data is fetched.
|
||||
pub is_enterprise_secret_redaction_enabled: bool,
|
||||
}
|
||||
|
||||
/// A snapshot of a user's [`PrivacySettings`] settings at some point in time.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct PrivacySettingsSnapshot {
|
||||
is_telemetry_enabled: bool,
|
||||
is_crash_reporting_enabled: bool,
|
||||
is_telemetry_force_enabled: bool,
|
||||
should_collect_ai_ugc_telemetry: bool,
|
||||
// This is an option so that, if a user has not set this value (and it's set to its default value of true),
|
||||
// the default value won't override a value that the user previously set on a different device.
|
||||
// This is set to a non-option once the user manually changes this setting.
|
||||
cloud_conversation_storage_enabled: Option<bool>,
|
||||
}
|
||||
|
||||
impl PrivacySettingsSnapshot {
|
||||
pub fn cloud_conversation_storage_enabled(&self) -> Option<bool> {
|
||||
self.cloud_conversation_storage_enabled
|
||||
}
|
||||
|
||||
pub fn is_telemetry_enabled(&self) -> bool {
|
||||
self.is_telemetry_enabled
|
||||
}
|
||||
|
||||
pub fn is_crash_reporting_enabled(&self) -> bool {
|
||||
self.is_crash_reporting_enabled
|
||||
}
|
||||
|
||||
pub fn is_telemetry_force_enabled(&self) -> bool {
|
||||
self.is_telemetry_force_enabled
|
||||
}
|
||||
|
||||
pub fn should_disable_telemetry(&self) -> bool {
|
||||
// If a user has opted in to the agent mode analytics experiment, telemetry must be enabled.
|
||||
!self.is_telemetry_enabled
|
||||
&& !self.is_telemetry_force_enabled
|
||||
&& !FeatureFlag::AgentModeAnalytics.is_enabled()
|
||||
}
|
||||
|
||||
pub fn should_collect_ai_ugc_telemetry(&self) -> bool {
|
||||
self.should_collect_ai_ugc_telemetry
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn mock() -> Self {
|
||||
Self {
|
||||
cloud_conversation_storage_enabled: None,
|
||||
is_telemetry_enabled: true,
|
||||
is_crash_reporting_enabled: true,
|
||||
is_telemetry_force_enabled: true,
|
||||
should_collect_ai_ugc_telemetry: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PrivacySettings {
|
||||
/// Registers a singleton PrivacySettings model on `app`.
|
||||
///
|
||||
/// We expose this function publicly (while keeping the constructor private) to prevent
|
||||
/// instantiation another PrivacySettings struct, in the case where a developer might be
|
||||
/// unaware that it is registered as a singleton model.
|
||||
pub fn register_singleton(ctx: &mut AppContext) {
|
||||
let handle = ctx.add_singleton_model(PrivacySettings::new);
|
||||
|
||||
register_settings_events!(
|
||||
PrivacySettings,
|
||||
user_secret_regex_list,
|
||||
CustomSecretRegexList,
|
||||
handle,
|
||||
ctx
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns a new PrivacySettings object initialized from locally cached values. Server-side
|
||||
/// settings are fetched later via `fetch_or_update_settings`, which is called from
|
||||
/// `on_user_fetched` after the user's auth state is established.
|
||||
fn new(ctx: &mut ModelContext<Self>) -> Self {
|
||||
let auth_state = AuthStateProvider::as_ref(ctx).get().clone();
|
||||
let auth_client = ServerApiProvider::as_ref(ctx).get_auth_client();
|
||||
|
||||
let is_telemetry_enabled: bool = ctx
|
||||
.private_user_preferences()
|
||||
.read_value(TELEMETRY_ENABLED_DEFAULTS_KEY)
|
||||
.unwrap_or_default()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or(true);
|
||||
|
||||
let is_crash_reporting_enabled: bool = ctx
|
||||
.private_user_preferences()
|
||||
.read_value(CRASH_REPORTING_ENABLED_DEFAULTS_KEY)
|
||||
.unwrap_or_default()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or(true);
|
||||
|
||||
let is_cloud_conversation_storage_enabled: bool = ctx
|
||||
.private_user_preferences()
|
||||
.read_value(CLOUD_CONVERSATION_STORAGE_ENABLED_DEFAULTS_KEY)
|
||||
.unwrap_or_default()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or(true);
|
||||
|
||||
// Make sure the user-preferences stores match what's in memory.
|
||||
// Needed for warp drive preferences to work and no harm in doing in general.
|
||||
let _ = ctx.private_user_preferences().write_value(
|
||||
TELEMETRY_ENABLED_DEFAULTS_KEY,
|
||||
serde_json::to_string(&is_telemetry_enabled)
|
||||
.expect("is_telemetry_enabled is a boolean."),
|
||||
);
|
||||
let _ = ctx.private_user_preferences().write_value(
|
||||
CRASH_REPORTING_ENABLED_DEFAULTS_KEY,
|
||||
serde_json::to_string(&is_crash_reporting_enabled)
|
||||
.expect("is_crash_reporting_enabled is a boolean."),
|
||||
);
|
||||
let _ = ctx.private_user_preferences().write_value(
|
||||
CLOUD_CONVERSATION_STORAGE_ENABLED_DEFAULTS_KEY,
|
||||
serde_json::to_string(&is_cloud_conversation_storage_enabled)
|
||||
.expect("is_cloud_conversation_storage_enabled is a boolean."),
|
||||
);
|
||||
|
||||
// Listen for changes to the cloud model and update ourselves when they happen.
|
||||
ctx.subscribe_to_model(&WarpDrivePrivacySettings::handle(ctx), |me, event, ctx| {
|
||||
let privacy_settings = WarpDrivePrivacySettings::as_ref(ctx);
|
||||
match event {
|
||||
WarpDrivePrivacySettingsChangedEvent::IsTelemetryEnabled { .. } => {
|
||||
me.set_is_telemetry_enabled(
|
||||
*privacy_settings.is_telemetry_enabled.value(),
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
WarpDrivePrivacySettingsChangedEvent::IsCrashReportingEnabled { .. } => {
|
||||
me.set_is_crash_reporting_enabled(
|
||||
*privacy_settings.is_crash_reporting_enabled.value(),
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
WarpDrivePrivacySettingsChangedEvent::IsCloudConversationStorageEnabled {
|
||||
..
|
||||
} => {
|
||||
me.set_is_cloud_conversation_storage_enabled(
|
||||
*privacy_settings
|
||||
.is_cloud_conversation_storage_enabled
|
||||
.value(),
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let user_secret_regex_list: CustomSecretRegexList =
|
||||
CustomSecretRegexList::new_from_storage(ctx);
|
||||
let has_initialized_default_secret_regexes: HasInitializedDefaultSecretRegexes =
|
||||
HasInitializedDefaultSecretRegexes::new_from_storage(ctx);
|
||||
|
||||
Self {
|
||||
auth_state,
|
||||
auth_client,
|
||||
is_crash_reporting_enabled,
|
||||
is_telemetry_enabled,
|
||||
is_cloud_conversation_storage_enabled,
|
||||
user_secret_regex_list,
|
||||
has_initialized_default_secret_regexes,
|
||||
is_telemetry_force_enabled: false,
|
||||
is_enterprise_secret_redaction_enabled: false,
|
||||
enterprise_secret_regex_list: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_telemetry_force_enabled(&self) -> bool {
|
||||
self.is_telemetry_force_enabled
|
||||
}
|
||||
|
||||
pub fn set_is_telemetry_force_enabled(&mut self, is_telemetry_force_enabled: bool) {
|
||||
self.is_telemetry_force_enabled = is_telemetry_force_enabled;
|
||||
}
|
||||
|
||||
pub fn is_enterprise_secret_redaction_enabled(&self) -> bool {
|
||||
self.is_enterprise_secret_redaction_enabled
|
||||
}
|
||||
|
||||
pub fn set_enterprise_secret_redaction_settings(
|
||||
&mut self,
|
||||
enabled: bool,
|
||||
enterprise_regexes: Vec<EnterpriseSecretRegex>,
|
||||
change_event_reason: ChangeEventReason,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
if enabled {
|
||||
// First time: Force enable secret redaction setting (safe mode).
|
||||
if !self.is_enterprise_secret_redaction_enabled {
|
||||
let safe_mode_settings = SafeModeSettings::handle(ctx);
|
||||
ctx.update_model(&safe_mode_settings, |safe_mode_settings, ctx| {
|
||||
let _ = safe_mode_settings.safe_mode_enabled.set_value(true, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
// Convert EnterpriseSecretRegex to CustomSecretRegex for internal use
|
||||
let mut enterprise_secrets = Vec::new();
|
||||
for enterprise_regex in enterprise_regexes {
|
||||
if let Ok(regex) = Regex::new(&enterprise_regex.pattern) {
|
||||
enterprise_secrets.push(CustomSecretRegex {
|
||||
pattern: regex,
|
||||
name: enterprise_regex.name,
|
||||
});
|
||||
} else {
|
||||
log::error!(
|
||||
"Invalid enterprise secret regex pattern: {}",
|
||||
enterprise_regex.pattern
|
||||
);
|
||||
}
|
||||
}
|
||||
self.enterprise_secret_regex_list = enterprise_secrets;
|
||||
} else {
|
||||
// Clear enterprise secrets when disabled
|
||||
self.enterprise_secret_regex_list.clear();
|
||||
}
|
||||
|
||||
self.is_enterprise_secret_redaction_enabled = enabled;
|
||||
|
||||
ctx.emit(PrivacySettingsChangedEvent::CustomSecretRegexList {
|
||||
change_event_reason,
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
pub fn refresh_to_default(&mut self) {
|
||||
// TODO(zach): this seems incorrect - should we also update the values on disk?
|
||||
self.is_telemetry_enabled = true;
|
||||
self.is_crash_reporting_enabled = true;
|
||||
self.is_cloud_conversation_storage_enabled = true;
|
||||
self.is_telemetry_force_enabled = false;
|
||||
self.is_enterprise_secret_redaction_enabled = false;
|
||||
}
|
||||
|
||||
/// Fetch the user's privacy settings from the server if any or update the server settings.
|
||||
pub fn fetch_or_update_settings(&self, ctx: &mut ModelContext<Self>) {
|
||||
let auth_client_clone = self.auth_client.clone();
|
||||
let _ = ctx.spawn(
|
||||
async move { auth_client_clone.get_user_settings().await },
|
||||
Self::initialize_from_fetched_settings_or_update_settings,
|
||||
);
|
||||
}
|
||||
|
||||
/// Initializes state from the [`SyncedUserSettings`] fetched from the server, if any.
|
||||
/// If there are no settings from the server, updates the server settings with local settings.
|
||||
/// TODO: Make this a server-side db transaction.
|
||||
fn initialize_from_fetched_settings_or_update_settings(
|
||||
&mut self,
|
||||
fetched_settings: Result<Option<SyncedUserSettings>>,
|
||||
ctx: &mut ModelContext<PrivacySettings>,
|
||||
) {
|
||||
match fetched_settings {
|
||||
Ok(Some(fetched_settings)) => {
|
||||
// Until the login experience stops hiding the telemetry settings,
|
||||
// we assume that locally enabled telemetry is unintentional.
|
||||
// As such, where settings differ, we respect whichever setting that is disabled.
|
||||
self.overwrite_local_settings_if_cloud_disabled(fetched_settings, ctx);
|
||||
// If any local setting is disabled, we have to update the server.
|
||||
if !self.is_telemetry_enabled
|
||||
|| !self.is_crash_reporting_enabled
|
||||
|| !self.is_cloud_conversation_storage_enabled
|
||||
{
|
||||
self.update_server_with_local_settings(ctx);
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
// This indicates the user had not logged in before.
|
||||
log::info!("User has no synced privacy settings.");
|
||||
self.update_server_with_local_settings(ctx);
|
||||
}
|
||||
Err(err) => {
|
||||
report_error!(err.context("Failed to fetch user settings."));
|
||||
}
|
||||
}
|
||||
|
||||
self.maybe_sync_with_warp_drive_prefs(ctx);
|
||||
}
|
||||
|
||||
fn overwrite_local_settings_if_cloud_disabled(
|
||||
&mut self,
|
||||
fetched_settings: SyncedUserSettings,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
// For now, only overwrite the user's locally stored setting if the cloud version
|
||||
// has is_crash_reporting disabled. Until we implement a more reliable retry
|
||||
// mechanism for update settings requests, in addition to possibly a UI for the
|
||||
// user to resolve the conflicting settings themselves, default to "safe" behavior.
|
||||
// Namely, we want to avoid incidentally overwriting is_crash_reporting_enabled to
|
||||
// `true`.
|
||||
if self.is_crash_reporting_enabled && !fetched_settings.is_crash_reporting_enabled {
|
||||
self.set_is_crash_reporting_enabled(fetched_settings.is_crash_reporting_enabled, ctx);
|
||||
}
|
||||
|
||||
// For now, only overwrite the user's locally stored setting if the cloud version
|
||||
// has is_telemetry_enabled disabled. Until we implement a more reliable retry
|
||||
// mechanism for update settings requests, in addition to possibly a UI for the
|
||||
// user to resolve the conflicting settings themselves, default to "safe" behavior.
|
||||
// Namely, we want to avoid incidentally overwriting is_telemetry_enabled to
|
||||
// `true`.
|
||||
if self.is_telemetry_enabled && !fetched_settings.is_telemetry_enabled {
|
||||
self.set_is_telemetry_enabled(fetched_settings.is_telemetry_enabled, ctx);
|
||||
}
|
||||
|
||||
if self.is_cloud_conversation_storage_enabled
|
||||
&& !fetched_settings.is_cloud_conversation_storage_enabled
|
||||
{
|
||||
self.set_is_cloud_conversation_storage_enabled(
|
||||
fetched_settings.is_cloud_conversation_storage_enabled,
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Constructor for tests only.
|
||||
#[cfg(test)]
|
||||
pub fn mock(_ctx: &mut ModelContext<Self>) -> Self {
|
||||
Self {
|
||||
auth_state: Arc::new(AuthState::new_for_test()),
|
||||
auth_client: Arc::new(MockAuthClient::new()),
|
||||
is_crash_reporting_enabled: true,
|
||||
is_telemetry_enabled: true,
|
||||
is_cloud_conversation_storage_enabled: true,
|
||||
user_secret_regex_list: CustomSecretRegexList::new(None),
|
||||
has_initialized_default_secret_regexes: HasInitializedDefaultSecretRegexes::new(None),
|
||||
is_telemetry_force_enabled: false,
|
||||
is_enterprise_secret_redaction_enabled: false,
|
||||
enterprise_secret_regex_list: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a snapshot of the user's privacy settings.
|
||||
///
|
||||
/// The returned snapshot is not stateful, thus its values should be used shortly after the
|
||||
/// snapshot is returned.
|
||||
pub fn get_snapshot(&self, app: &AppContext) -> PrivacySettingsSnapshot {
|
||||
PrivacySettingsSnapshot {
|
||||
cloud_conversation_storage_enabled: (!self.is_cloud_conversation_storage_enabled)
|
||||
.then_some(false),
|
||||
is_telemetry_enabled: self.is_telemetry_enabled,
|
||||
is_crash_reporting_enabled: self.is_crash_reporting_enabled,
|
||||
is_telemetry_force_enabled: self.is_telemetry_force_enabled,
|
||||
should_collect_ai_ugc_telemetry: should_collect_ai_ugc_telemetry(
|
||||
app,
|
||||
self.is_telemetry_enabled,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets `is_crash_reporting_enabled` to the given value.
|
||||
///
|
||||
/// Additionally, this writes the given value to the user's local defaults, and additionally
|
||||
/// sends a request to update the user's `is_crash_reporting_enabled` value stored server-side.
|
||||
/// Finally, emits a `PrivacySettingsEvent::UpdateIsCrashReportingEnabled` event.
|
||||
pub fn set_is_crash_reporting_enabled(
|
||||
&mut self,
|
||||
new_value: bool,
|
||||
ctx: &mut ModelContext<PrivacySettings>,
|
||||
) {
|
||||
let old_value = self.is_crash_reporting_enabled;
|
||||
if new_value != old_value {
|
||||
self.is_crash_reporting_enabled = new_value;
|
||||
|
||||
WarpDrivePrivacySettings::handle(ctx).update(ctx, |settings, ctx| {
|
||||
log::info!("Setting is_crash_reporting_enabled to {new_value}");
|
||||
let _ = settings
|
||||
.is_crash_reporting_enabled
|
||||
.set_value(new_value, ctx);
|
||||
});
|
||||
|
||||
if self.auth_state.is_logged_in() {
|
||||
let auth_client = self.auth_client.clone();
|
||||
let _ = ctx.spawn(
|
||||
async move { auth_client.set_is_crash_reporting_enabled(new_value).await },
|
||||
|_, _, _| (),
|
||||
);
|
||||
}
|
||||
ctx.emit(PrivacySettingsChangedEvent::UpdateIsCrashReportingEnabled {
|
||||
old_value,
|
||||
new_value,
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets `is_telemetry_enabled` to the given value.
|
||||
///
|
||||
/// Additionally, this writes the given value to the user's local defaults, and additionally
|
||||
/// sends a request to update the user's `is_telemetry_enabled` value stored server-side.
|
||||
/// Finally, emits a `PrivacySettingsEvent::UpdateIsTelemetryEnabled` event.
|
||||
pub fn set_is_telemetry_enabled(
|
||||
&mut self,
|
||||
new_value: bool,
|
||||
ctx: &mut ModelContext<PrivacySettings>,
|
||||
) {
|
||||
let old_value = self.is_telemetry_enabled;
|
||||
if new_value != old_value {
|
||||
self.is_telemetry_enabled = new_value;
|
||||
|
||||
WarpDrivePrivacySettings::handle(ctx).update(ctx, |settings, ctx| {
|
||||
log::info!("Setting is_telemetry_enabled to {new_value}");
|
||||
let _ = settings.is_telemetry_enabled.set_value(new_value, ctx);
|
||||
});
|
||||
|
||||
if self.auth_state.is_logged_in() {
|
||||
let auth_client = self.auth_client.clone();
|
||||
let _ = ctx.spawn(
|
||||
async move { auth_client.set_is_telemetry_enabled(new_value).await },
|
||||
|_, _, _| (),
|
||||
);
|
||||
}
|
||||
ctx.emit(PrivacySettingsChangedEvent::UpdateIsTelemetryEnabled {
|
||||
old_value,
|
||||
new_value,
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_is_cloud_conversation_storage_enabled(
|
||||
&mut self,
|
||||
new_value: bool,
|
||||
ctx: &mut ModelContext<PrivacySettings>,
|
||||
) {
|
||||
let old_value = self.is_cloud_conversation_storage_enabled;
|
||||
if new_value == old_value {
|
||||
return;
|
||||
}
|
||||
|
||||
self.is_cloud_conversation_storage_enabled = new_value;
|
||||
|
||||
WarpDrivePrivacySettings::handle(ctx).update(ctx, |settings, ctx| {
|
||||
log::info!("Setting is_cloud_conversation_storage_enabled to {new_value}");
|
||||
let _ = settings
|
||||
.is_cloud_conversation_storage_enabled
|
||||
.set_value(new_value, ctx);
|
||||
});
|
||||
|
||||
if self.auth_state.is_logged_in() {
|
||||
let auth_client = self.auth_client.clone();
|
||||
let _ = ctx.spawn(
|
||||
async move {
|
||||
auth_client
|
||||
.set_is_cloud_conversation_storage_enabled(new_value)
|
||||
.await
|
||||
},
|
||||
|_, _, _| (),
|
||||
);
|
||||
}
|
||||
|
||||
ctx.emit(
|
||||
PrivacySettingsChangedEvent::UpdateIsCloudConversationStorageEnabled {
|
||||
old_value,
|
||||
new_value,
|
||||
},
|
||||
);
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
pub fn remove_user_secret_regex(&mut self, idx: &usize, ctx: &mut ModelContext<Self>) {
|
||||
let mut new_user_secret_regex_list = self.user_secret_regex_list.to_vec();
|
||||
new_user_secret_regex_list.remove(*idx);
|
||||
if self
|
||||
.user_secret_regex_list
|
||||
.set_value(new_user_secret_regex_list, ctx)
|
||||
.is_err()
|
||||
{
|
||||
log::error!("Custom Secret Regex List failed to serialize")
|
||||
}
|
||||
}
|
||||
|
||||
/// Initializes the custom secret regex list with the default regexes, provided
|
||||
/// non matches can be found.
|
||||
/// This can be called when a user first enables secret redaction.
|
||||
pub fn add_all_recommended_regex(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
let mut new_user_secret_regex_list = self.user_secret_regex_list.to_vec();
|
||||
let num_existing_regexes = new_user_secret_regex_list.len();
|
||||
|
||||
// Add all the default regexes if they don't already exist
|
||||
for default_regex in crate::terminal::model::secrets::regexes::DEFAULT_REGEXES_WITH_NAMES {
|
||||
if let Ok(regex) = Regex::new(default_regex.pattern) {
|
||||
let custom_regex = CustomSecretRegex {
|
||||
pattern: regex,
|
||||
name: Some(default_regex.name.to_string()),
|
||||
};
|
||||
if !new_user_secret_regex_list.contains(&custom_regex) {
|
||||
new_user_secret_regex_list.push(custom_regex);
|
||||
}
|
||||
} else {
|
||||
log::error!("Failed to compile default regex: {}", default_regex.pattern);
|
||||
}
|
||||
}
|
||||
|
||||
if num_existing_regexes == new_user_secret_regex_list.len() {
|
||||
return;
|
||||
}
|
||||
|
||||
if self
|
||||
.user_secret_regex_list
|
||||
.set_value(new_user_secret_regex_list, ctx)
|
||||
.is_err()
|
||||
{
|
||||
log::error!("Failed to serialize default regexes to custom secret regex list")
|
||||
}
|
||||
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
/// Disables the default regex trigger, so that it will not be executed.
|
||||
pub fn disable_default_regex_trigger(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
if self
|
||||
.has_initialized_default_secret_regexes
|
||||
.set_value(true, ctx)
|
||||
.is_err()
|
||||
{
|
||||
log::error!("Failed to disable default regex trigger");
|
||||
}
|
||||
}
|
||||
|
||||
/// Initializes the custom secret regex list with the default regexes.
|
||||
/// This will only be executed once per user, and only if they haven't already initialized.
|
||||
pub fn initialize_default_regexes_once(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
// Only initialize if we haven't done so before
|
||||
if !*self.has_initialized_default_secret_regexes.value() {
|
||||
self.add_all_recommended_regex(ctx);
|
||||
|
||||
// Mark as initialized
|
||||
if self
|
||||
.has_initialized_default_secret_regexes
|
||||
.set_value(true, ctx)
|
||||
.is_err()
|
||||
{
|
||||
log::error!("Failed to set has_initialized_default_secret_regexes flag");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends request(s) to update server-side user settings with current local values.
|
||||
fn update_server_with_local_settings(&self, ctx: &mut ModelContext<Self>) {
|
||||
if self.auth_state.is_logged_in() {
|
||||
let auth_client = self.auth_client.clone();
|
||||
let snapshot = self.get_snapshot(ctx);
|
||||
let _ = ctx.spawn(
|
||||
async move {
|
||||
let result = auth_client.update_user_settings(snapshot).await;
|
||||
if let Err(err) = result {
|
||||
report_error!(
|
||||
err.context("Failed to update server with local privacy settings.")
|
||||
)
|
||||
}
|
||||
},
|
||||
|_, _, _| (),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// We wait until warp drive prefs have loaded and then either
|
||||
/// 1) use them as the data store for is_telemetry_enabled and is_crash_reporting_enabled, if those
|
||||
/// values are set in warp drive, or
|
||||
/// 2) update the warp drive prefs to match the values from the legacy user_settings endpoint so
|
||||
/// that we can use warp drive prefs going forward.
|
||||
pub fn maybe_sync_with_warp_drive_prefs(&mut self, ctx: &mut ModelContext<Self>) {
|
||||
// Wait for cloud objects to load, and, if telemetry & crash reporting are synced to warp drive
|
||||
// initialize from the warp drive values.
|
||||
let update_manager = UpdateManager::as_ref(ctx);
|
||||
ctx.spawn(
|
||||
update_manager.initial_load_complete(),
|
||||
Self::handle_warp_drive_objects_loaded,
|
||||
);
|
||||
}
|
||||
|
||||
fn handle_warp_drive_objects_loaded(&mut self, _: (), ctx: &mut ModelContext<Self>) {
|
||||
self.initialize_default_regexes_once(ctx);
|
||||
// Check if the warp drive preferences are set. If they are, and telemetry and crash reporting
|
||||
// are set as warp drive prefs, then use those. Otherwise, update the warp drive prefs to match
|
||||
// the values from the legacy user_settings endpoint so that we can use warp drive prefs going forward.
|
||||
let cloud_model = CloudModel::as_ref(ctx);
|
||||
let cloud_prefs = cloud_model.get_all_cloud_preferences_by_storage_key();
|
||||
let cloud_telemetry_value =
|
||||
cloud_prefs
|
||||
.get(IsTelemetryEnabled::storage_key())
|
||||
.map(|pref| {
|
||||
pref.model()
|
||||
.string_model
|
||||
.value
|
||||
.as_bool()
|
||||
.unwrap_or_default()
|
||||
});
|
||||
let cloud_crash_reporting_value = cloud_prefs
|
||||
.get(IsCrashReportingEnabled::storage_key())
|
||||
.map(|pref| {
|
||||
pref.model()
|
||||
.string_model
|
||||
.value
|
||||
.as_bool()
|
||||
.unwrap_or_default()
|
||||
});
|
||||
let cloud_conversation_storage_value = cloud_prefs
|
||||
.get(IsCloudConversationStorageEnabled::storage_key())
|
||||
.map(|pref| {
|
||||
pref.model()
|
||||
.string_model
|
||||
.value
|
||||
.as_bool()
|
||||
.unwrap_or_default()
|
||||
});
|
||||
|
||||
match (
|
||||
cloud_telemetry_value,
|
||||
cloud_crash_reporting_value,
|
||||
cloud_conversation_storage_value,
|
||||
) {
|
||||
(
|
||||
Some(is_telemetry_enabled),
|
||||
Some(is_crash_reporting_enabled),
|
||||
Some(is_cloud_conversation_storage_enabled),
|
||||
) => {
|
||||
log::info!(
|
||||
"Warp Drive privacy preferences are set, using those for telemetry={is_telemetry_enabled}, \
|
||||
crash_reporting={is_crash_reporting_enabled}, cloud_conversation_storage={is_cloud_conversation_storage_enabled}"
|
||||
);
|
||||
self.set_is_telemetry_enabled(is_telemetry_enabled, ctx);
|
||||
self.set_is_crash_reporting_enabled(is_crash_reporting_enabled, ctx);
|
||||
self.set_is_cloud_conversation_storage_enabled(
|
||||
is_cloud_conversation_storage_enabled,
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
log::info!(
|
||||
"Warp Drive privacy preferences are not set, syncing local PrivacySettings values to \
|
||||
WarpDrivePrivacySettings and cloud. telemetry={}, crash_reporting={}, \
|
||||
cloud_conversation_storage={}",
|
||||
self.is_telemetry_enabled,
|
||||
self.is_crash_reporting_enabled,
|
||||
self.is_cloud_conversation_storage_enabled
|
||||
);
|
||||
// First, ensure WarpDrivePrivacySettings (the define_settings_group model)
|
||||
// reflects the actual PrivacySettings in-memory values. These may differ
|
||||
// because WarpDrivePrivacySettings defaults to `true` for all three settings,
|
||||
// while the user may have changed them to `false` via PrivacySettings before
|
||||
// signing up. Without this step, maybe_sync_local_prefs_to_cloud would read
|
||||
// the stale WarpDrivePrivacySettings defaults and push those to the cloud.
|
||||
WarpDrivePrivacySettings::handle(ctx).update(ctx, |settings, ctx| {
|
||||
report_if_error!(settings
|
||||
.is_telemetry_enabled
|
||||
.set_value(self.is_telemetry_enabled, ctx));
|
||||
report_if_error!(settings
|
||||
.is_crash_reporting_enabled
|
||||
.set_value(self.is_crash_reporting_enabled, ctx));
|
||||
report_if_error!(settings
|
||||
.is_cloud_conversation_storage_enabled
|
||||
.set_value(self.is_cloud_conversation_storage_enabled, ctx));
|
||||
});
|
||||
CloudPreferencesSyncer::handle(ctx).update(ctx, |syncer, ctx| {
|
||||
syncer.maybe_sync_local_prefs_to_cloud(
|
||||
vec![
|
||||
IsTelemetryEnabled::storage_key().to_string(),
|
||||
IsCrashReportingEnabled::storage_key().to_string(),
|
||||
IsCloudConversationStorageEnabled::storage_key().to_string(),
|
||||
],
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Events emitted when PrivacySettings is updated.
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum PrivacySettingsChangedEvent {
|
||||
UpdateIsTelemetryEnabled {
|
||||
old_value: bool,
|
||||
new_value: bool,
|
||||
},
|
||||
UpdateIsCrashReportingEnabled {
|
||||
old_value: bool,
|
||||
new_value: bool,
|
||||
},
|
||||
UpdateIsCloudConversationStorageEnabled {
|
||||
old_value: bool,
|
||||
new_value: bool,
|
||||
},
|
||||
CustomSecretRegexList {
|
||||
change_event_reason: ChangeEventReason,
|
||||
},
|
||||
HasInitializedDefaultSecretRegexes {
|
||||
change_event_reason: ChangeEventReason,
|
||||
},
|
||||
}
|
||||
|
||||
impl Entity for PrivacySettings {
|
||||
type Event = PrivacySettingsChangedEvent;
|
||||
}
|
||||
|
||||
impl SingletonEntity for PrivacySettings {}
|
||||
@@ -0,0 +1,47 @@
|
||||
use settings::{RespectUserSyncSetting, SupportedPlatforms, SyncToCloud};
|
||||
use warp_core::define_settings_group;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(
|
||||
Debug,
|
||||
Default,
|
||||
Clone,
|
||||
Copy,
|
||||
Eq,
|
||||
PartialEq,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
schemars::JsonSchema,
|
||||
settings_value::SettingsValue,
|
||||
)]
|
||||
#[schemars(rename_all = "snake_case")]
|
||||
pub enum SLPBlockState {
|
||||
/// The block has not been shown to the user.
|
||||
#[default]
|
||||
NotShown,
|
||||
|
||||
// The block has been triggered/shown.
|
||||
Shown,
|
||||
|
||||
/// The block should not be shown to the user e.g. if the user is NOT using PS1.
|
||||
DoNotShow,
|
||||
}
|
||||
|
||||
// This isn't a user-visible setting, but rather a record of a
|
||||
// Warp action that should be persisted the same way we would a setting.
|
||||
//
|
||||
// When a user has been shown the same line prompt onboarding block,
|
||||
// we want to remember that they have already been shown it.
|
||||
// That way, we skip displaying it in the future and prevent it from becoming
|
||||
// an annoyance. We use a Setting for this, so we get the underlying infrastructure
|
||||
// for free e.g. cloud-syncing.
|
||||
define_settings_group!(SameLinePromptBlockSettings, settings: [
|
||||
same_line_prompt_block_state: SameLinePromptBlockState {
|
||||
type: SLPBlockState,
|
||||
default: SLPBlockState::NotShown,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: true,
|
||||
},
|
||||
]);
|
||||
@@ -0,0 +1,75 @@
|
||||
use schemars::SchemaGenerator;
|
||||
use settings::schema::SettingSchemaEntry;
|
||||
|
||||
fn entries() -> Vec<&'static SettingSchemaEntry> {
|
||||
inventory::iter::<SettingSchemaEntry>.into_iter().collect()
|
||||
}
|
||||
|
||||
/// Validates that every registered setting's file default value conforms to
|
||||
/// its generated JSON schema.
|
||||
///
|
||||
/// This catches mismatches where `SettingsValue::to_file_value` produces
|
||||
/// a shape that differs from what `file_schema` declares (e.g. Duration
|
||||
/// serialized as integer seconds vs. the schemars-derived `{secs, nanos}`
|
||||
/// object).
|
||||
///
|
||||
/// Because this test lives in the app crate, all real settings are linked
|
||||
/// via `inventory`, giving full coverage of every setting in the application.
|
||||
#[test]
|
||||
fn file_defaults_validate_against_schema() {
|
||||
let mut failures = Vec::new();
|
||||
|
||||
for entry in entries() {
|
||||
// Skip private settings — they have no toml_path and aren't in the
|
||||
// user-visible schema.
|
||||
if entry.is_private {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Generate the type's schema with a fresh generator so $defs accumulate.
|
||||
let mut schema_gen = SchemaGenerator::default();
|
||||
let schema = (entry.schema_fn)(&mut schema_gen);
|
||||
let schema_value = schema.to_value();
|
||||
|
||||
// Build a root schema document with $defs for $ref resolution.
|
||||
let mut root = serde_json::Map::new();
|
||||
root.insert(
|
||||
"$schema".to_string(),
|
||||
serde_json::Value::String("https://json-schema.org/draft/2020-12/schema".to_string()),
|
||||
);
|
||||
if let serde_json::Value::Object(obj) = schema_value {
|
||||
for (k, v) in obj {
|
||||
root.insert(k, v);
|
||||
}
|
||||
}
|
||||
let defs = schema_gen.take_definitions(true);
|
||||
if !defs.is_empty() {
|
||||
root.insert("$defs".to_string(), serde_json::Value::Object(defs));
|
||||
}
|
||||
let root_value = serde_json::Value::Object(root);
|
||||
|
||||
// Parse the file default value.
|
||||
let default_json = (entry.file_default_value_fn)();
|
||||
let default_value: serde_json::Value =
|
||||
serde_json::from_str(&default_json).unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"file_default_value_fn for '{}' produced invalid JSON: {e}",
|
||||
entry.storage_key
|
||||
)
|
||||
});
|
||||
|
||||
// Validate.
|
||||
if let Err(err) = jsonschema::draft202012::validate(&root_value, &default_value) {
|
||||
failures.push(format!(
|
||||
" '{}': default {default_json} — {err}",
|
||||
entry.storage_key,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
assert!(
|
||||
failures.is_empty(),
|
||||
"File default values that do not match their schema:\n{}",
|
||||
failures.join("\n")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
use settings::{macros::define_settings_group, SupportedPlatforms, SyncToCloud};
|
||||
|
||||
define_settings_group!(ScrollSettings, settings: [
|
||||
mouse_scroll_multiplier: MouseScrollMultiplier {
|
||||
type: f32,
|
||||
default: 3.0,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Never,
|
||||
private: false,
|
||||
toml_path: "general.mouse_scroll_multiplier",
|
||||
description: "The scroll speed multiplier for mouse scroll events.",
|
||||
},
|
||||
]);
|
||||
@@ -0,0 +1,104 @@
|
||||
use std::ops::Not;
|
||||
|
||||
use warpui::{clipboard::ClipboardContent, AppContext};
|
||||
|
||||
use settings::{
|
||||
macros::define_settings_group, RespectUserSyncSetting, Setting, SupportedPlatforms, SyncToCloud,
|
||||
};
|
||||
|
||||
define_settings_group!(SelectionSettings, settings: [
|
||||
copy_on_select: CopyOnSelect {
|
||||
type: bool,
|
||||
default: true,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "terminal.copy_on_select",
|
||||
description: "Whether text is automatically copied to the clipboard when selected.",
|
||||
},
|
||||
linux_selection_clipboard: LinuxSelectionClipboard {
|
||||
type: bool,
|
||||
default: true,
|
||||
supported_platforms: SupportedPlatforms::LINUX,
|
||||
sync_to_cloud: SyncToCloud::PerPlatform(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "system.linux_selection_clipboard",
|
||||
description: "Whether the Linux primary selection clipboard is used.",
|
||||
},
|
||||
middle_click_paste_enabled: MiddleClickPasteEnabled {
|
||||
type: bool,
|
||||
default: true,
|
||||
supported_platforms: SupportedPlatforms::OR(
|
||||
SupportedPlatforms::WINDOWS.into(),
|
||||
SupportedPlatforms::MAC.into()
|
||||
),
|
||||
sync_to_cloud: SyncToCloud::PerPlatform(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "terminal.input.middle_click_paste_enabled",
|
||||
description: "Whether middle-click pastes from the clipboard.",
|
||||
}
|
||||
]);
|
||||
|
||||
impl SelectionSettings {
|
||||
pub fn copy_on_select_enabled(&self) -> bool {
|
||||
*self.copy_on_select.value()
|
||||
}
|
||||
|
||||
/// Returns whether honoring the Linux primary selection clipboard is enabled. On non-linux
|
||||
/// platforms this always returns false.
|
||||
pub fn linux_selection_clipboard_enabled(&self) -> bool {
|
||||
*self.linux_selection_clipboard.value()
|
||||
&& self
|
||||
.linux_selection_clipboard
|
||||
.is_supported_on_current_platform()
|
||||
}
|
||||
|
||||
/// Writes the selection content to the user's clipboard if `copy_on_select` is enabled.
|
||||
pub fn maybe_copy_on_select(&self, clipboard_content: ClipboardContent, ctx: &mut AppContext) {
|
||||
self.maybe_write_to_linux_selection_clipboard(|_| clipboard_content.clone(), ctx);
|
||||
if self.copy_on_select_enabled() && !clipboard_content.plain_text.is_empty() {
|
||||
ctx.clipboard().write(clipboard_content);
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes the selected content to the user's primary selection clipboard. On non-Linux
|
||||
/// platforms this is a noop.
|
||||
pub fn maybe_write_to_linux_selection_clipboard(
|
||||
&self,
|
||||
clipboard_contents_fn: impl FnOnce(&mut AppContext) -> ClipboardContent,
|
||||
ctx: &mut AppContext,
|
||||
) {
|
||||
if self.linux_selection_clipboard_enabled() {
|
||||
let clipboard_content = clipboard_contents_fn(ctx);
|
||||
if !clipboard_content.plain_text.is_empty() {
|
||||
ctx.clipboard()
|
||||
.write_to_primary_clipboard(clipboard_content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn maybe_read_from_linux_selection_clipboard(
|
||||
&self,
|
||||
ctx: &mut AppContext,
|
||||
) -> Option<ClipboardContent> {
|
||||
self.linux_selection_clipboard_enabled()
|
||||
.then(|| ctx.clipboard().read_from_primary_clipboard())
|
||||
}
|
||||
|
||||
/// Implements the correct middle-click paste behavior for the current platform.
|
||||
///
|
||||
/// Linux has the "primary clipboard" to which it maps the middle mouse button. Other platforms
|
||||
/// lack this separate clipboard, and so we map middle-click to the normal clipboard on those
|
||||
/// platforms.
|
||||
pub fn read_for_middle_click_paste(&self, ctx: &mut AppContext) -> Option<ClipboardContent> {
|
||||
if cfg!(target_os = "linux") {
|
||||
return self.maybe_read_from_linux_selection_clipboard(ctx);
|
||||
}
|
||||
(self
|
||||
.middle_click_paste_enabled
|
||||
.is_supported_on_current_platform()
|
||||
&& *self.middle_click_paste_enabled.value())
|
||||
.then(|| ctx.clipboard().read())
|
||||
.filter(|content| content.is_empty().not())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
use settings::{
|
||||
macros::define_settings_group, RespectUserSyncSetting, SupportedPlatforms, SyncToCloud,
|
||||
};
|
||||
|
||||
define_settings_group!(SshSettings,
|
||||
settings: [
|
||||
enable_legacy_ssh_wrapper: EnableSshWrapper {
|
||||
type: bool,
|
||||
default: true,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
storage_key: "EnableSSHWrapper",
|
||||
toml_path: "warpify.ssh.enable_legacy_ssh_wrapper",
|
||||
description: "Whether the legacy SSH wrapper is enabled for SSH sessions.",
|
||||
},
|
||||
]
|
||||
);
|
||||
@@ -0,0 +1,83 @@
|
||||
use warpui::{platform::SystemTheme, AppContext};
|
||||
|
||||
use crate::themes::theme::{RespectSystemTheme, SelectedSystemThemes, ThemeKind};
|
||||
use settings::{
|
||||
macros::define_settings_group, RespectUserSyncSetting, Setting, SupportedPlatforms, SyncToCloud,
|
||||
};
|
||||
|
||||
// Settings group for themes related settings.
|
||||
// Note that we store just the information needed to derive the current
|
||||
// theme state, which boils down to:
|
||||
// ThemeKind: the theme to use when the system theme is off.
|
||||
// UseSystemTheme: whether to respect the system theme.
|
||||
// SelectedSystemThemes: the themes to use when the system theme is on.
|
||||
define_settings_group!(ThemeSettings, settings: [
|
||||
theme_kind: Theme {
|
||||
type: ThemeKind,
|
||||
// Note that for new users, we now override this default value in SettingsInitializer
|
||||
// to set the default theme to Phenomenon.
|
||||
default: ThemeKind::default(),
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
toml_path: "appearance.themes.theme",
|
||||
max_table_depth: 0,
|
||||
description: "The color theme.",
|
||||
},
|
||||
use_system_theme: UseSystemTheme {
|
||||
type: bool,
|
||||
default: false,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
storage_key: "SystemTheme",
|
||||
toml_path: "appearance.themes.system_theme",
|
||||
description: "Whether to match the system light/dark theme.",
|
||||
},
|
||||
selected_system_themes: SystemThemes {
|
||||
type: SelectedSystemThemes,
|
||||
default: SelectedSystemThemes::default(),
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
storage_key: "SelectedSystemThemes",
|
||||
toml_path: "appearance.themes.selected_system_themes",
|
||||
max_table_depth: 0,
|
||||
description: "The themes to use for system light and dark modes.",
|
||||
},
|
||||
]);
|
||||
|
||||
impl Theme {
|
||||
fn current_value_is_syncable(&self) -> bool {
|
||||
let current_value = self.value();
|
||||
// Don't sync custom themes because they reference local files that aren't synced to the cloud.
|
||||
!matches!(current_value, ThemeKind::Custom(_))
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a derived value for whether to respect the system theme based on
|
||||
/// the current theme settings.
|
||||
pub fn respect_system_theme(theme_settings: &ThemeSettings) -> RespectSystemTheme {
|
||||
if *theme_settings.use_system_theme.value() {
|
||||
RespectSystemTheme::On(theme_settings.selected_system_themes.value().clone())
|
||||
} else {
|
||||
RespectSystemTheme::Off
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the current theme kind based on the theme settings and the system theme.
|
||||
pub fn derived_theme_kind(theme_settings: &ThemeSettings, system_theme: SystemTheme) -> ThemeKind {
|
||||
let respect_system_theme = respect_system_theme(theme_settings);
|
||||
match respect_system_theme {
|
||||
RespectSystemTheme::On(selected_system_themes) => match system_theme {
|
||||
SystemTheme::Light => selected_system_themes.light.clone(),
|
||||
SystemTheme::Dark => selected_system_themes.dark.clone(),
|
||||
},
|
||||
RespectSystemTheme::Off => theme_settings.theme_kind.value().clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the current theme kind based on the theme settings and active app context.
|
||||
pub fn active_theme_kind(theme_settings: &ThemeSettings, app: &AppContext) -> ThemeKind {
|
||||
derived_theme_kind(theme_settings, app.system_theme())
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
use crate::banner::BannerState;
|
||||
use settings::{RespectUserSyncSetting, SupportedPlatforms, SyncToCloud};
|
||||
use warp_core::define_settings_group;
|
||||
|
||||
// This isn't exactly a setting, but rather a record of a
|
||||
// user action that should be persisted the same way we would a setting.
|
||||
//
|
||||
// When a user dismisses the Vim keybindings banner,
|
||||
// we want to remember that they did so.
|
||||
// That way, we skip displaying it in the future
|
||||
// and prevent it from becoming an annoyance.
|
||||
define_settings_group!(VimBannerSettings, settings: [
|
||||
vim_keybindings_banner_state: VimKeybindingsBannerState {
|
||||
type: BannerState,
|
||||
default: BannerState::NotDismissed,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: true,
|
||||
},
|
||||
]);
|
||||
Reference in New Issue
Block a user