Initial public release of Warp.

Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
David Stern
2026-04-28 08:43:33 -05:00
commit 0dbd3d567a
4982 changed files with 1431549 additions and 0 deletions
+17
View File
@@ -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
+748
View File
@@ -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);
});
});
}
+15
View File
@@ -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.",
},
]);
+136
View File
@@ -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",
}
]);
+35
View File
@@ -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.",
}
]);
+15
View File
@@ -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.",
},
]);
+198
View File
@@ -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
+63
View File
@@ -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.",
},
]);
+67
View File
@@ -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)
}
}
+253
View File
@@ -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
}
}
+21
View File
@@ -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,
},
]);
+142
View File
@@ -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
}
}
+25
View File
@@ -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.",
},
]);
+466
View File
@@ -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
}
);
});
}
+449
View File
@@ -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
+6
View File
@@ -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;
+275
View File
@@ -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
+429
View File
@@ -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;
+541
View File
@@ -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"
);
});
});
}
+180
View File
@@ -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 {}
+233
View File
@@ -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
}
}
+25
View File
@@ -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
}
}
+17
View File
@@ -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.",
},
]
);
+1
View File
@@ -0,0 +1 @@
pub use settings::macros::*;
+1
View File
@@ -0,0 +1 @@
pub use settings::manager::*;
+592
View File
@@ -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 (0100).")]
pub width: u8,
#[schemars(description = "Height as a percentage of screen height (0100).")]
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")
}
+47
View File
@@ -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,
},
]);
+231
View File
@@ -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;
+170
View File
@@ -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"
);
});
})
}
+24
View File
@@ -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.",
}
]);
+865
View File
@@ -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")
);
}
+13
View File
@@ -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.",
},
]);
+104
View File
@@ -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())
}
}
+18
View File
@@ -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.",
},
]
);
+83
View File
@@ -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())
}
+20
View File
@@ -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,
},
]);