diff --git a/Cargo.lock b/Cargo.lock index b2955988941ce54f866114368c5f8f54b02f669d..b8385ee20db9f68dd981c0544e89f3609f96fc98 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -312,6 +312,7 @@ dependencies = [ "gpui", "language_model", "log", + "paths", "project", "regex", "schemars", diff --git a/crates/agent_settings/Cargo.toml b/crates/agent_settings/Cargo.toml index 15f35a931dedad303c46895c487655b9ddbc7496..b2db5677dcfdc0994e7ce7a03c9c1dd850eb8514 100644 --- a/crates/agent_settings/Cargo.toml +++ b/crates/agent_settings/Cargo.toml @@ -30,6 +30,7 @@ util.workspace = true [dev-dependencies] fs.workspace = true gpui = { workspace = true, features = ["test-support"] } +paths.workspace = true serde_json_lenient.workspace = true serde_json.workspace = true diff --git a/crates/agent_settings/src/agent_settings.rs b/crates/agent_settings/src/agent_settings.rs index 5ba7a95b3a2df16bdb198722da73e589475cfd8d..33a9591b84f907793b4e2bf2c178d7b0e9649d2e 100644 --- a/crates/agent_settings/src/agent_settings.rs +++ b/crates/agent_settings/src/agent_settings.rs @@ -5,15 +5,17 @@ use std::sync::{Arc, LazyLock}; use agent_client_protocol::ModelId; use collections::{HashSet, IndexMap}; +use fs::Fs; use gpui::{App, Pixels, px}; use language_model::LanguageModel; use project::DisableAiSettings; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use settings::{ - DefaultAgentView, DockPosition, LanguageModelParameters, LanguageModelSelection, - NewThreadLocation, NotifyWhenAgentWaiting, RegisterSetting, Settings, SidebarDockPosition, - SidebarSide, ThinkingBlockDisplay, ToolPermissionMode, + DefaultAgentView, DockPosition, DockSide, LanguageModelParameters, LanguageModelSelection, + NewThreadLocation, NotifyWhenAgentWaiting, RegisterSetting, Settings, SettingsContent, + SettingsStore, SidebarDockPosition, SidebarSide, ThinkingBlockDisplay, ToolPermissionMode, + update_settings_file, }; pub use crate::agent_profile::*; @@ -22,6 +24,104 @@ pub const SUMMARIZE_THREAD_PROMPT: &str = include_str!("prompts/summarize_thread pub const SUMMARIZE_THREAD_DETAILED_PROMPT: &str = include_str!("prompts/summarize_thread_detailed_prompt.txt"); +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct PanelLayout { + pub(crate) agent_dock: Option, + pub(crate) project_panel_dock: Option, + pub(crate) outline_panel_dock: Option, + pub(crate) collaboration_panel_dock: Option, + pub(crate) git_panel_dock: Option, + pub(crate) notification_panel_button: Option, +} + +impl PanelLayout { + const AGENT: Self = Self { + agent_dock: Some(DockPosition::Left), + project_panel_dock: Some(DockSide::Right), + outline_panel_dock: Some(DockSide::Right), + collaboration_panel_dock: Some(DockPosition::Right), + git_panel_dock: Some(DockPosition::Right), + notification_panel_button: Some(false), + }; + + const EDITOR: Self = Self { + agent_dock: Some(DockPosition::Right), + project_panel_dock: Some(DockSide::Left), + outline_panel_dock: Some(DockSide::Left), + collaboration_panel_dock: Some(DockPosition::Left), + git_panel_dock: Some(DockPosition::Left), + notification_panel_button: Some(true), + }; + + pub fn is_agent_layout(&self) -> bool { + *self == Self::AGENT + } + + pub fn is_editor_layout(&self) -> bool { + *self == Self::EDITOR + } + + fn read_from(content: &SettingsContent) -> Self { + Self { + agent_dock: content.agent.as_ref().and_then(|a| a.dock), + project_panel_dock: content.project_panel.as_ref().and_then(|p| p.dock), + outline_panel_dock: content.outline_panel.as_ref().and_then(|p| p.dock), + collaboration_panel_dock: content.collaboration_panel.as_ref().and_then(|p| p.dock), + git_panel_dock: content.git_panel.as_ref().and_then(|p| p.dock), + notification_panel_button: content.notification_panel.as_ref().and_then(|p| p.button), + } + } + + fn write_to(&self, settings: &mut SettingsContent) { + settings.agent.get_or_insert_default().dock = self.agent_dock; + settings.project_panel.get_or_insert_default().dock = self.project_panel_dock; + settings.outline_panel.get_or_insert_default().dock = self.outline_panel_dock; + settings.collaboration_panel.get_or_insert_default().dock = self.collaboration_panel_dock; + settings.git_panel.get_or_insert_default().dock = self.git_panel_dock; + settings.notification_panel.get_or_insert_default().button = self.notification_panel_button; + } + + fn write_diff_to(&self, current_merged: &PanelLayout, settings: &mut SettingsContent) { + if self.agent_dock != current_merged.agent_dock { + settings.agent.get_or_insert_default().dock = self.agent_dock; + } + if self.project_panel_dock != current_merged.project_panel_dock { + settings.project_panel.get_or_insert_default().dock = self.project_panel_dock; + } + if self.outline_panel_dock != current_merged.outline_panel_dock { + settings.outline_panel.get_or_insert_default().dock = self.outline_panel_dock; + } + if self.collaboration_panel_dock != current_merged.collaboration_panel_dock { + settings.collaboration_panel.get_or_insert_default().dock = + self.collaboration_panel_dock; + } + if self.git_panel_dock != current_merged.git_panel_dock { + settings.git_panel.get_or_insert_default().dock = self.git_panel_dock; + } + if self.notification_panel_button != current_merged.notification_panel_button { + settings.notification_panel.get_or_insert_default().button = + self.notification_panel_button; + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WindowLayout { + Editor(Option), + Agent(Option), + Custom(PanelLayout), +} + +impl WindowLayout { + pub fn agent() -> Self { + Self::Agent(None) + } + + pub fn editor() -> Self { + Self::Editor(None) + } +} + #[derive(Clone, Debug, RegisterSetting)] pub struct AgentSettings { pub enabled: bool, @@ -98,6 +198,50 @@ impl AgentSettings { .map(|sel| ModelId::new(format!("{}/{}", sel.provider.0, sel.model))) .collect() } + + pub fn get_layout(cx: &App) -> WindowLayout { + let store = cx.global::(); + let merged = store.merged_settings(); + let user_layout = store + .raw_user_settings() + .map(|u| PanelLayout::read_from(u.content.as_ref())) + .unwrap_or_default(); + let merged_layout = PanelLayout::read_from(merged); + + if merged_layout.is_agent_layout() { + return WindowLayout::Agent(Some(user_layout)); + } + + if merged_layout.is_editor_layout() { + return WindowLayout::Editor(Some(user_layout)); + } + + WindowLayout::Custom(user_layout) + } + + pub fn set_layout(layout: WindowLayout, fs: Arc, cx: &App) { + let merged = PanelLayout::read_from(cx.global::().merged_settings()); + + match layout { + WindowLayout::Agent(None) => { + update_settings_file(fs, cx, move |settings, _cx| { + PanelLayout::AGENT.write_diff_to(&merged, settings); + }); + } + WindowLayout::Editor(None) => { + update_settings_file(fs, cx, move |settings, _cx| { + PanelLayout::EDITOR.write_diff_to(&merged, settings); + }); + } + WindowLayout::Agent(Some(saved)) + | WindowLayout::Editor(Some(saved)) + | WindowLayout::Custom(saved) => { + update_settings_file(fs, cx, move |settings, _cx| { + saved.write_to(settings); + }); + } + } + } } #[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize, JsonSchema)] @@ -555,6 +699,7 @@ fn compile_regex_rules( #[cfg(test)] mod tests { use super::*; + use gpui::{TestAppContext, UpdateGlobal}; use serde_json::json; use settings::ToolPermissionMode; use settings::ToolPermissionsContent; @@ -1031,4 +1176,220 @@ mod tests { let permissions = compile_tool_permissions(Some(content)); assert_eq!(permissions.default, ToolPermissionMode::Deny); } + + #[gpui::test] + fn test_get_layout(cx: &mut gpui::App) { + let store = SettingsStore::test(cx); + cx.set_global(store); + project::DisableAiSettings::register(cx); + AgentSettings::register(cx); + + // The test default settings match the editor layout. + let layout = AgentSettings::get_layout(cx); + assert!(matches!(layout, WindowLayout::Editor(_))); + + // Switch defaults to the agent layout (simulating the AgentV2 flag). + SettingsStore::update_global(cx, |store, cx| { + store.update_default_settings(cx, |defaults| { + defaults.agent.get_or_insert_default().dock = Some(DockPosition::Left); + defaults.project_panel.get_or_insert_default().dock = Some(DockSide::Right); + defaults.outline_panel.get_or_insert_default().dock = Some(DockSide::Right); + defaults.collaboration_panel.get_or_insert_default().dock = + Some(DockPosition::Right); + defaults.git_panel.get_or_insert_default().dock = Some(DockPosition::Right); + defaults.notification_panel.get_or_insert_default().button = Some(false); + }); + }); + + // Should be Agent with an empty user layout (user hasn't customized). + let layout = AgentSettings::get_layout(cx); + let WindowLayout::Agent(Some(user_layout)) = layout else { + panic!("expected Agent(Some), got {:?}", layout); + }; + assert_eq!(user_layout, PanelLayout::default()); + + // User explicitly sets agent dock to left (matching the default). + // The merged result is still agent, but the user layout captures + // only what the user wrote. + SettingsStore::update_global(cx, |store, cx| { + store + .set_user_settings(r#"{ "agent": { "dock": "left" } }"#, cx) + .unwrap(); + }); + + let layout = AgentSettings::get_layout(cx); + let WindowLayout::Agent(Some(user_layout)) = layout else { + panic!("expected Agent(Some), got {:?}", layout); + }; + assert_eq!(user_layout.agent_dock, Some(DockPosition::Left)); + assert_eq!(user_layout.project_panel_dock, None); + assert_eq!(user_layout.outline_panel_dock, None); + assert_eq!(user_layout.collaboration_panel_dock, None); + assert_eq!(user_layout.git_panel_dock, None); + assert_eq!(user_layout.notification_panel_button, None); + + // User sets a combination that doesn't match either preset: + // agent on the left but project panel also on the left. + SettingsStore::update_global(cx, |store, cx| { + store + .set_user_settings( + r#"{ + "agent": { "dock": "left" }, + "project_panel": { "dock": "left" } + }"#, + cx, + ) + .unwrap(); + }); + + let layout = AgentSettings::get_layout(cx); + let WindowLayout::Custom(user_layout) = layout else { + panic!("expected Custom, got {:?}", layout); + }; + assert_eq!(user_layout.agent_dock, Some(DockPosition::Left)); + assert_eq!(user_layout.project_panel_dock, Some(DockSide::Left)); + } + + #[gpui::test] + fn test_set_layout_round_trip(cx: &mut gpui::App) { + let store = SettingsStore::test(cx); + cx.set_global(store); + project::DisableAiSettings::register(cx); + AgentSettings::register(cx); + + // User has a custom layout: agent on the right with project panel + // also on the right. This doesn't match either preset. + SettingsStore::update_global(cx, |store, cx| { + store + .set_user_settings( + r#"{ + "agent": { "dock": "right" }, + "project_panel": { "dock": "right" } + }"#, + cx, + ) + .unwrap(); + }); + + let original = AgentSettings::get_layout(cx); + let WindowLayout::Custom(ref original_user_layout) = original else { + panic!("expected Custom, got {:?}", original); + }; + assert_eq!(original_user_layout.agent_dock, Some(DockPosition::Right)); + assert_eq!( + original_user_layout.project_panel_dock, + Some(DockSide::Right) + ); + assert_eq!(original_user_layout.outline_panel_dock, None); + + // Switch to the agent layout. This overwrites the user settings. + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + PanelLayout::AGENT.write_to(settings); + }); + }); + + let layout = AgentSettings::get_layout(cx); + assert!(matches!(layout, WindowLayout::Agent(_))); + + // Restore the original custom layout. + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + original_user_layout.write_to(settings); + }); + }); + + // Should be back to the same custom layout. + let restored = AgentSettings::get_layout(cx); + let WindowLayout::Custom(restored_user_layout) = restored else { + panic!("expected Custom, got {:?}", restored); + }; + assert_eq!(restored_user_layout.agent_dock, Some(DockPosition::Right)); + assert_eq!( + restored_user_layout.project_panel_dock, + Some(DockSide::Right) + ); + assert_eq!(restored_user_layout.outline_panel_dock, None); + } + + #[gpui::test] + async fn test_set_layout_minimal_diff(cx: &mut TestAppContext) { + let fs = fs::FakeFs::new(cx.background_executor.clone()); + fs.save( + paths::settings_file().as_path(), + &serde_json::json!({ + "agent": { "dock": "left" }, + "project_panel": { "dock": "left" } + }) + .to_string() + .into(), + Default::default(), + ) + .await + .unwrap(); + + cx.update(|cx| { + let store = SettingsStore::test(cx); + cx.set_global(store); + project::DisableAiSettings::register(cx); + AgentSettings::register(cx); + + // Apply the agent V2 defaults. + SettingsStore::update_global(cx, |store, cx| { + store.update_default_settings(cx, |defaults| { + defaults.agent.get_or_insert_default().dock = Some(DockPosition::Left); + defaults.project_panel.get_or_insert_default().dock = Some(DockSide::Right); + defaults.outline_panel.get_or_insert_default().dock = Some(DockSide::Right); + defaults.collaboration_panel.get_or_insert_default().dock = + Some(DockPosition::Right); + defaults.git_panel.get_or_insert_default().dock = Some(DockPosition::Right); + defaults.notification_panel.get_or_insert_default().button = Some(false); + }); + }); + + // User has agent=left (matches preset) and project_panel=left (does not) + SettingsStore::update_global(cx, |store, cx| { + store + .set_user_settings( + r#"{ + "agent": { "dock": "left" }, + "project_panel": { "dock": "left" } + }"#, + cx, + ) + .unwrap(); + }); + + let layout = AgentSettings::get_layout(cx); + assert!(matches!(layout, WindowLayout::Custom(_))); + + AgentSettings::set_layout(WindowLayout::agent(), fs.clone(), cx); + }); + + cx.run_until_parked(); + + let written = fs.load(paths::settings_file().as_path()).await.unwrap(); + cx.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.set_user_settings(&written, cx).unwrap(); + }); + + // The user settings should still have agent=left (preserved) + // and now project_panel=right (changed to match preset). + let store = cx.global::(); + let user_layout = store + .raw_user_settings() + .map(|u| PanelLayout::read_from(u.content.as_ref())) + .unwrap_or_default(); + + assert_eq!(user_layout.agent_dock, Some(DockPosition::Left)); + assert_eq!(user_layout.project_panel_dock, Some(DockSide::Right)); + // Other fields weren't in user settings and didn't need changing. + assert_eq!(user_layout.outline_panel_dock, None); + + // And the merged result should now match agent. + let layout = AgentSettings::get_layout(cx); + assert!(matches!(layout, WindowLayout::Agent(_))); + }); + } } diff --git a/crates/settings/src/settings_store.rs b/crates/settings/src/settings_store.rs index d157b29abd1958ae47fd340effa698b11e8e04af..f16f59390939171394684c9fc51e011a8f77a956 100644 --- a/crates/settings/src/settings_store.rs +++ b/crates/settings/src/settings_store.rs @@ -370,6 +370,10 @@ impl SettingsStore { setting_value.set_global_value(value); } + pub fn merged_settings(&self) -> &SettingsContent { + &self.merged_settings + } + /// Get the value of a setting. /// /// Panics if the given setting type has not been registered, or if there is no