From b1159465348bdbfc7d0a1208ad670124e693141a Mon Sep 17 00:00:00 2001 From: Ryan Ward Date: Wed, 2 Sep 2026 15:13:24 -0500 Subject: [PATCH] Reduce session logging and background churn --- Cargo.lock | 1 + Cargo.toml | 1 + app/src/ai/bedrock/client.rs | 7 - app/src/context_chips/current_prompt.rs | 28 +++- app/src/context_chips/prompt_snapshot.rs | 1 - app/src/editor/view/voice.rs | 5 +- app/src/lib.rs | 4 +- app/src/pane_group/pane/terminal_pane.rs | 12 +- app/src/settings/ai.rs | 18 ++- app/src/settings/ai_tests.rs | 3 + .../local_tty/terminal_view_adaptor.rs | 19 +-- .../terminal/local_tty/windows/environment.rs | 8 +- app/src/terminal/model/ansi/ansi_c_decoder.rs | 5 +- app/src/terminal/model/ansi/mod.rs | 12 +- app/src/terminal/model/blocks.rs | 1 - app/src/terminal/model/session.rs | 1 - app/src/workspace/global_actions.rs | 144 +++++++++++++----- app/src/workspace/mod.rs | 2 + crates/ai/Cargo.toml | 1 + .../ai/src/index/local_project_index/mod.rs | 66 ++++++-- crates/galaxy_logging/src/native.rs | 15 ++ .../src/windowing/winit/event_loop/mod.rs | 2 +- .../src/elements/gui/new_scrollable/mod.rs | 1 - .../src/elements/gui/resizable.rs | 5 +- .../src/elements/new_scrollable/mod.rs | 1 - .../galaxyui_core/src/elements/resizable.rs | 5 +- crates/jsonrpc/src/service.rs | 4 +- crates/settings/src/lib.rs | 17 +-- crates/settings/src/macros.rs | 6 +- 29 files changed, 268 insertions(+), 127 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ded9d2f8..7f337f4e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -229,6 +229,7 @@ dependencies = [ "dirs 6.0.0", "dunce", "filetime", + "fs4", "futures", "galaxy_core", "galaxy_graphql", diff --git a/Cargo.toml b/Cargo.toml index 0f2de452..c4a4b981 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -178,6 +178,7 @@ font-kit = { git = "https://github.com/warpdotdev/font-kit.git", rev = "a04b225e futures = { version = "0.3", features = ["executor", "thread-pool"] } futures-lite = "1.13.0" futures-util = { version = "0.3", default-features = false } +fs4 = "0.13.1" get-size = { version = "0.1.4", features = ["derive"] } globset = "0.4.18" gloo = { version = "0.11.0", default-features = false, features = [ diff --git a/app/src/ai/bedrock/client.rs b/app/src/ai/bedrock/client.rs index 165e2edc..a114c791 100644 --- a/app/src/ai/bedrock/client.rs +++ b/app/src/ai/bedrock/client.rs @@ -216,13 +216,6 @@ impl BedrockClient { tools.len() ); - log::info!( - "[bedrock] Sending request payload to Bedrock:\nSystem Prompt: {:?}\nMessages: {:#?}\nTools: {:#?}", - system_prompt, - messages, - tools - ); - let converted = build_converse_request( messages.clone(), system_prompt.clone(), diff --git a/app/src/context_chips/current_prompt.rs b/app/src/context_chips/current_prompt.rs index af89c7ce..218dba98 100644 --- a/app/src/context_chips/current_prompt.rs +++ b/app/src/context_chips/current_prompt.rs @@ -269,11 +269,17 @@ impl CurrentPrompt { if let Some(session) = session { let buffer_text = editor.as_ref(ctx).buffer_text(ctx); + let mut should_notify = false; for (kind, state) in me.states.iter_mut() { - state.should_render = - kind.should_render(&buffer_text, session.aliases()); + let should_render = kind.should_render(&buffer_text, session.aliases()); + if state.should_render != should_render { + state.should_render = should_render; + should_notify = true; + } + } + if should_notify { + ctx.notify(); } - ctx.notify(); } } } @@ -321,7 +327,10 @@ impl CurrentPrompt { } fn update_chip_value(&mut self, chip_kind: &ContextChipKind, value: Option) { - log::debug!("Updating prompt value of {chip_kind:?} to {value:?}"); + log::trace!( + "Updating prompt value of {chip_kind:?}; has_value={}", + value.is_some() + ); if let Some(state) = self.states.get_mut(chip_kind) { if state.last_computed_value != value { state.last_computed_value = value; @@ -332,14 +341,19 @@ impl CurrentPrompt { } fn update_on_click_value(&mut self, chip_kind: &ContextChipKind, value: Option>) { - log::debug!("Updating prompt on_click value of {chip_kind:?} to {value:?}"); + log::trace!( + "Updating prompt on_click value of {chip_kind:?}; item_count={}", + value.as_ref().map_or(0, Vec::len) + ); let filter_values = match chip_kind { ContextChipKind::ShellGitBranch => self.filter_git_branch_on_click_values(value), _ => value, }; if let Some(state) = self.states.get_mut(chip_kind) { - state.last_on_click_values = filter_values; - let _ = self.update_tx.try_send(()); + if state.last_on_click_values != filter_values { + state.last_on_click_values = filter_values; + let _ = self.update_tx.try_send(()); + } } } diff --git a/app/src/context_chips/prompt_snapshot.rs b/app/src/context_chips/prompt_snapshot.rs index 83913a5d..720c04bb 100644 --- a/app/src/context_chips/prompt_snapshot.rs +++ b/app/src/context_chips/prompt_snapshot.rs @@ -47,7 +47,6 @@ impl PromptSnapshot { }) .collect_vec(); - log::debug!("Current prompt snapshot: {chips:?}"); Self { chips, same_line_prompt_enabled: current_prompt.same_line_prompt_enabled(), diff --git a/app/src/editor/view/voice.rs b/app/src/editor/view/voice.rs index 9a90e203..d2f69015 100644 --- a/app/src/editor/view/voice.rs +++ b/app/src/editor/view/voice.rs @@ -492,7 +492,10 @@ impl EditorView { self.stop_transcribing_voice_input(ctx); match result { Ok(transcribe_response) => { - log::debug!("Transcribed voice input: {transcribe_response:?}"); + log::debug!( + "Transcribed voice input; characters={}", + transcribe_response.chars().count() + ); self.user_insert(&transcribe_response, ctx); } Err(e) => match e { diff --git a/app/src/lib.rs b/app/src/lib.rs index 8bc8635b..c4f81afe 100644 --- a/app/src/lib.rs +++ b/app/src/lib.rs @@ -2396,7 +2396,7 @@ pub(crate) fn app_callbacks( // Persist the final app state before tearing down the writer. // This ensures the latest session (tabs, CWD, conversations) is saved // even if the termination bypassed individual window-close events. - ctx.dispatch_global_action("workspace:save_app", &()); + workspace::save_app_urgently(ctx); NotebookManager::handle(ctx).update(ctx, |manager, ctx| { // Notebooks are only saved periodically, so ensure that any pending changes have @@ -2634,7 +2634,7 @@ pub(crate) fn app_callbacks( stack.handle_window_closed(window_data, ctx); }); } - ctx.dispatch_global_action("workspace:save_app", &()); + workspace::save_app_urgently(ctx); })), on_window_moved: Some(Box::new(move |ctx| { ctx.dispatch_global_action("workspace:save_app", &()); diff --git a/app/src/pane_group/pane/terminal_pane.rs b/app/src/pane_group/pane/terminal_pane.rs index 392b3708..9bea3c50 100644 --- a/app/src/pane_group/pane/terminal_pane.rs +++ b/app/src/pane_group/pane/terminal_pane.rs @@ -499,7 +499,7 @@ impl PaneContent for TerminalPane { let ambient_model = ambient_model.as_ref(app); let task_id = ambient_model.task_id(); - log::info!("[session-save] pane=viewer/ambient task_id={task_id:?}"); + log::trace!("[session-snapshot] pane=viewer/ambient task_id={task_id:?}"); return LeafContents::AmbientAgent(AmbientAgentPaneSnapshot { uuid: self.uuid.clone(), task_id, @@ -507,7 +507,7 @@ impl PaneContent for TerminalPane { } let cwd = view.pwd_if_local(app); - log::info!("[session-save] pane=viewer cwd={cwd:?} is_active={is_active}"); + log::trace!("[session-snapshot] pane=viewer cwd={cwd:?} is_active={is_active}"); LeafContents::Terminal(TerminalPaneSnapshot { uuid: self.uuid.clone(), cwd, @@ -533,14 +533,14 @@ impl PaneContent for TerminalPane { // can be restored via the ambient agent task if one exists. let task_id = view.model.lock().ambient_agent_task_id(); if task_id.is_some() { - log::info!("[session-save] pane=transcript/ambient task_id={task_id:?}"); + log::trace!("[session-snapshot] pane=transcript/ambient task_id={task_id:?}"); LeafContents::AmbientAgent(AmbientAgentPaneSnapshot { uuid: self.uuid.clone(), task_id, }) } else { let cwd = view.pwd_if_local(app); - log::info!("[session-save] pane=transcript cwd={cwd:?} is_active={is_active}"); + log::trace!("[session-snapshot] pane=transcript cwd={cwd:?} is_active={is_active}"); LeafContents::Terminal(TerminalPaneSnapshot { uuid: self.uuid.clone(), cwd, @@ -584,8 +584,8 @@ impl PaneContent for TerminalPane { }); let cwd = view.pwd_if_local(app); - log::info!( - "[session-save] pane=terminal cwd={cwd:?} is_active={is_active} \ + log::trace!( + "[session-snapshot] pane=terminal cwd={cwd:?} is_active={is_active} \ conversations={} active_conversation={active_conversation_id:?} \ has_shell_launch_data={} has_input_config=true", conversation_ids_to_restore.len(), diff --git a/app/src/settings/ai.rs b/app/src/settings/ai.rs index 51ddae8c..fbb8ce97 100644 --- a/app/src/settings/ai.rs +++ b/app/src/settings/ai.rs @@ -1000,7 +1000,7 @@ pub enum OpenAIProviderKind { /// /// Multiple providers can be configured simultaneously. Each provider has its own endpoint, /// credentials, and model list. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, schemars::JsonSchema)] +#[derive(Clone, Serialize, Deserialize, PartialEq, schemars::JsonSchema)] #[schemars(description = "Configuration for a direct model provider endpoint.")] pub struct OpenAIProviderConfig { #[serde(default)] @@ -1027,6 +1027,22 @@ pub struct OpenAIProviderConfig { pub models: Vec, } +impl std::fmt::Debug for OpenAIProviderConfig { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("OpenAIProviderConfig") + .field("kind", &self.kind) + .field("enabled", &self.enabled) + .field("name", &self.name) + .field("base_url", &self.base_url) + .field("api_key_configured", &self.api_key.is_some()) + .field("project_id", &self.project_id) + .field("location", &self.location) + .field("models", &self.models) + .finish() + } +} + impl settings_value::SettingsValue for OpenAIProviderConfig {} fn default_acp_agent_id() -> String { diff --git a/app/src/settings/ai_tests.rs b/app/src/settings/ai_tests.rs index d862f9c8..efafb541 100644 --- a/app/src/settings/ai_tests.rs +++ b/app/src/settings/ai_tests.rs @@ -391,6 +391,9 @@ fn native_provider_settings_roundtrip_with_vertex_configuration() { })) .expect("Native OpenAI provider settings should deserialize"); assert_eq!(native_openai.kind, OpenAIProviderKind::OpenAI); + let debug_output = format!("{native_openai:?}"); + assert!(!debug_output.contains("sk-test")); + assert!(debug_output.contains("api_key_configured: true")); } #[test] diff --git a/app/src/terminal/local_tty/terminal_view_adaptor.rs b/app/src/terminal/local_tty/terminal_view_adaptor.rs index 3d9a6e23..bc27f442 100644 --- a/app/src/terminal/local_tty/terminal_view_adaptor.rs +++ b/app/src/terminal/local_tty/terminal_view_adaptor.rs @@ -260,18 +260,19 @@ fn wire_up_terminal_view_session_sharing( if *SessionSettings::as_ref(ctx).honor_ps1 { return } + let Some(network) = session_sharer_clone.borrow().clone() else { + return; + }; let prompt_snapshot = current_prompt.read(ctx, |current_prompt, ctx| { PromptSnapshot::from_current_prompt(current_prompt, ctx) }); - if let Some(network) = session_sharer_clone.borrow().as_ref() { - let Ok(serialized_prompt) = serde_json::to_string(&prompt_snapshot) else { - log::error!("Failed to serialize prompt snapshot to send active prompt update to shared session server"); - return - }; - network.update(ctx, |network, _| { - network.send_active_prompt_update_if_changed(session_sharing_protocol::common::ActivePrompt::WarpPrompt(serialized_prompt)) - }); - } + let Ok(serialized_prompt) = serde_json::to_string(&prompt_snapshot) else { + log::error!("Failed to serialize prompt snapshot to send active prompt update to shared session server"); + return + }; + network.update(ctx, |network, _| { + network.send_active_prompt_update_if_changed(session_sharing_protocol::common::ActivePrompt::WarpPrompt(serialized_prompt)) + }); }); let session_sharer_clone = session_sharer.clone(); diff --git a/app/src/terminal/local_tty/windows/environment.rs b/app/src/terminal/local_tty/windows/environment.rs index 36082441..d6c83d4d 100644 --- a/app/src/terminal/local_tty/windows/environment.rs +++ b/app/src/terminal/local_tty/windows/environment.rs @@ -264,11 +264,11 @@ fn add_local_machine_env(env: &mut BTreeMap) { let Ok(value) = reg_value_to_string(&value, &name) else { safe_info!( safe: ("Unable to convert value for key {name:?}"), - full: ("Unable to convert value for key {name:?}: {:?}", value.bytes) + full: ("Unable to convert value for key {name:?}") ); continue; }; - log::trace!("adding SYS env: {name:?} = {value:?}"); + log::trace!("adding SYS env: {name:?}"); env.insert( map_key(name.clone().into()), EnvEntry { @@ -289,7 +289,7 @@ fn add_user_env(env: &mut BTreeMap) { let Ok(value) = reg_value_to_string(&value, &name) else { safe_info!( safe: ("Unable to convert value for key {name:?}"), - full: ("Unable to convert value for key {name:?}: {:?}", value.bytes) + full: ("Unable to convert value for key {name:?}") ); continue; }; @@ -309,7 +309,7 @@ fn add_user_env(env: &mut BTreeMap) { value }; - log::trace!("adding USER env: {name:?} = {value:?}"); + log::trace!("adding USER env: {name:?}"); env.insert( map_key(name.clone().into()), EnvEntry { diff --git a/app/src/terminal/model/ansi/ansi_c_decoder.rs b/app/src/terminal/model/ansi/ansi_c_decoder.rs index c198ee4c..d052151b 100644 --- a/app/src/terminal/model/ansi/ansi_c_decoder.rs +++ b/app/src/terminal/model/ansi/ansi_c_decoder.rs @@ -19,7 +19,10 @@ pub(super) fn parse_ansi_c_quoted_string(quoted_string: String) -> String { if quoted_string.trim().is_empty() { return quoted_string; } - log::debug!("Attempting to parse the following ANSI C escaped shell output: {quoted_string}"); + log::debug!( + "Attempting to parse ANSI-C escaped shell output; bytes={}", + quoted_string.len() + ); let Some(quoted_string_without_prefix) = quoted_string.strip_prefix("$\'") else { log::warn!("Tried to parse ANSI-C quoted string but $\' prefix was not present"); diff --git a/app/src/terminal/model/ansi/mod.rs b/app/src/terminal/model/ansi/mod.rs index 07278810..d70192e8 100644 --- a/app/src/terminal/model/ansi/mod.rs +++ b/app/src/terminal/model/ansi/mod.rs @@ -653,10 +653,7 @@ impl<'a, H: Handler + 'a, W: io::Write> Performer<'a, H, W> { fn handle_decoded_data(&mut self, decoded_data: Result, hex::FromHexError>) { match decoded_data { Ok(decoded_data) => { - safe_debug!( - safe: ("Decoded payload"), - full: ("Decoded payload string: {:?}", std::str::from_utf8(&decoded_data)) - ); + log::debug!("Decoded shell hook payload; bytes={}", decoded_data.len()); let hook = serde_json::from_slice::(&decoded_data); if let Ok(hook) = &hook { @@ -691,10 +688,7 @@ impl<'a, H: Handler + 'a, W: io::Write> Performer<'a, H, W> { return; } }; - safe_debug!( - safe: ("Decoded payload"), - full: ("Decoded payload string: {:?}", serde_json::to_string(&hook)) - ); + log::debug!("Decoded key-value shell hook payload"); self.handle_decoded_hook(Ok(hook)); } Some(&WARP_KV_ENTRY_BYTE) => { @@ -888,7 +882,7 @@ where .map(|parts| parts.join(";").trim().to_owned()); if let Ok(body) = body { if !body.is_empty() { - log::info!("Received OSC 9 notification: {}", body); + log::info!("Received OSC 9 notification; bytes={}", body.len()); self.handler.pluggable_notification(None, body); return; } diff --git a/app/src/terminal/model/blocks.rs b/app/src/terminal/model/blocks.rs index 44c893e4..c608b612 100644 --- a/app/src/terminal/model/blocks.rs +++ b/app/src/terminal/model/blocks.rs @@ -3058,7 +3058,6 @@ impl BlockList { if let Some(prompt_snapshot) = &block.prompt_snapshot { if let Ok(prompt_snapshot) = serde_json::from_str(prompt_snapshot) { - log::debug!("Restored prompt: {prompt_snapshot:?}"); self.active_block_mut().set_prompt_snapshot(prompt_snapshot); } } diff --git a/app/src/terminal/model/session.rs b/app/src/terminal/model/session.rs index b380da72..64e6f05c 100644 --- a/app/src/terminal/model/session.rs +++ b/app/src/terminal/model/session.rs @@ -356,7 +356,6 @@ impl Sessions { let session = Session::new(session_info.clone(), command_executor); log::info!("Shell is bootstrapped with session_id {:?}", session.id()); - log::debug!("Session details: {session:?}"); let session = Arc::new(session); self.sessions.insert(session.id(), session.clone()); diff --git a/app/src/workspace/global_actions.rs b/app/src/workspace/global_actions.rs index 285300f4..69d523af 100644 --- a/app/src/workspace/global_actions.rs +++ b/app/src/workspace/global_actions.rs @@ -1,14 +1,16 @@ use std::path::PathBuf; +use std::time::Duration; use ::settings::ToggleableSetting; use galaxy_core::execution_mode::AppExecutionMode; use galaxy_graphql::mutations::create_anonymous_user::AnonymousUserType; +use galaxyui::r#async::{SpawnedFutureHandle, Timer}; use galaxyui::windowing::WindowManager; -use galaxyui::{AppContext, SingletonEntity, TypedActionView}; +use galaxyui::{AppContext, Entity, ModelContext, SingletonEntity, TypedActionView}; use crate::ai::agent::conversation::AIConversationId; use crate::ai::agent::AIAgentExchangeId; -use crate::app_state::get_app_state; +use crate::app_state::{get_app_state, AppState}; use crate::network::NetworkStatus; use crate::persistence::ModelEvent; use crate::root_view::OpenPath; @@ -20,6 +22,102 @@ use crate::workspace::cross_window_tab_drag::CrossWindowTabDrag; use crate::workspace::{Workspace, WorkspaceAction}; use crate::{auth, GlobalResourceHandlesProvider}; +const SESSION_SAVE_DEBOUNCE: Duration = Duration::from_millis(250); + +pub(crate) struct SessionSaveCoordinator { + last_enqueued_state: Option, + pending_save: bool, + timer_generation: u64, + timer: Option, +} + +impl SessionSaveCoordinator { + pub(crate) fn new() -> Self { + Self { + last_enqueued_state: None, + pending_save: false, + timer_generation: 0, + timer: None, + } + } + + fn request_save(&mut self, ctx: &mut ModelContext) { + if !session_save_is_enabled(ctx) { + return; + } + + if self.timer.is_none() { + self.save_if_changed(ctx); + } else { + self.pending_save = true; + } + self.restart_timer(ctx); + } + + fn save_urgently(&mut self, ctx: &mut ModelContext) { + self.timer_generation = self.timer_generation.wrapping_add(1); + if let Some(timer) = self.timer.take() { + timer.abort(); + } + self.pending_save = false; + + if session_save_is_enabled(ctx) { + self.save_if_changed(ctx); + } + } + + fn restart_timer(&mut self, ctx: &mut ModelContext) { + if let Some(timer) = self.timer.take() { + timer.abort(); + } + self.timer_generation = self.timer_generation.wrapping_add(1); + let generation = self.timer_generation; + self.timer = Some(ctx.spawn( + async move { Timer::after(SESSION_SAVE_DEBOUNCE).await }, + move |coordinator, _, ctx| { + if coordinator.timer_generation != generation { + return; + } + coordinator.timer = None; + if std::mem::take(&mut coordinator.pending_save) { + coordinator.save_if_changed(ctx); + } + }, + )); + } + + fn save_if_changed(&mut self, ctx: &mut ModelContext) { + let Some(model_event_sender) = GlobalResourceHandlesProvider::as_ref(ctx) + .get() + .model_event_sender + .clone() + else { + return; + }; + let app_state = get_app_state(ctx); + if self.last_enqueued_state.as_ref() == Some(&app_state) { + return; + } + if let Err(err) = model_event_sender.send(ModelEvent::Snapshot(app_state.clone())) { + log::error!("Error trying to send model event {err:?}"); + return; + } + self.last_enqueued_state = Some(app_state); + } +} + +impl Entity for SessionSaveCoordinator { + type Event = (); +} + +impl SingletonEntity for SessionSaveCoordinator {} + +fn session_save_is_enabled(ctx: &AppContext) -> bool { + AppExecutionMode::as_ref(ctx).can_save_session() + && *GeneralSettings::as_ref(ctx).restore_session + && !CrossWindowTabDrag::as_ref(ctx).is_active() +} + /// Specifies where a forked conversation should be opened. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub enum ForkedConversationDestination { @@ -130,41 +228,15 @@ fn toggle_focus_reporting(_: &(), ctx: &mut AppContext) { } fn save_app(_: &(), ctx: &mut AppContext) { - if !AppExecutionMode::as_ref(ctx).can_save_session() { - return; - } + SessionSaveCoordinator::handle(ctx).update(ctx, |coordinator, ctx| { + coordinator.request_save(ctx); + }); +} - if !*GeneralSettings::as_ref(ctx).restore_session { - return; - } - - // While a cross-window tab drag is active, the dragged tab's pane group - // is in flight between source and preview windows and `get_app_state` - // would produce a snapshot with zero windows. Persisting that snapshot - // wipes the on-disk session via `save_app_state`'s delete-then-insert - // transaction. `save_app` fires from window move / focus / resize / - // close callbacks (see `app_callbacks` in `lib.rs`), all of which run - // during a drag, so we have to short-circuit at this boundary. The - // first save after the drag finalizes will rewrite the snapshot. - if CrossWindowTabDrag::as_ref(ctx).is_active() { - return; - } - - let Some(model_event_sender) = GlobalResourceHandlesProvider::as_ref(ctx) - .get() - .model_event_sender - .clone() - else { - return; - }; - - // Only compute the app state if we're definitely going to use it. - let app_state = get_app_state(ctx); - let event = ModelEvent::Snapshot(app_state); - - if let Err(err) = model_event_sender.send(event) { - log::error!("Error trying to send model event {err:?}"); - } +pub(crate) fn save_app_urgently(ctx: &mut AppContext) { + SessionSaveCoordinator::handle(ctx).update(ctx, |coordinator, ctx| { + coordinator.save_urgently(ctx); + }); } fn toggle_debug_network_status(_: &(), ctx: &mut AppContext) { diff --git a/app/src/workspace/mod.rs b/app/src/workspace/mod.rs index 60e3b0f7..1623a745 100644 --- a/app/src/workspace/mod.rs +++ b/app/src/workspace/mod.rs @@ -30,6 +30,7 @@ pub use action::{ }; pub use active_session::ActiveSession; use galaxy_core::context_flag::ContextFlag; +pub(crate) use global_actions::save_app_urgently; pub use global_actions::{ ForkAIConversationParams, ForkFromExchange, ForkedConversationDestination, }; @@ -78,6 +79,7 @@ use crate::workspace::view::{ pub fn init(app: &mut AppContext) { app.add_singleton_model(|_| WorkspaceRegistry::new()); app.add_singleton_model(|_| cross_window_tab_drag::CrossWindowTabDrag::new()); + app.add_singleton_model(|_| global_actions::SessionSaveCoordinator::new()); use galaxyui::keymap::macros::*; app.register_binding_validator::(is_binding_pty_compliant); diff --git a/crates/ai/Cargo.toml b/crates/ai/Cargo.toml index 1aedb50e..c0ae9ad0 100644 --- a/crates/ai/Cargo.toml +++ b/crates/ai/Cargo.toml @@ -49,6 +49,7 @@ strsim.workspace = true thiserror.workspace = true tokio = { workspace = true, features = ["rt"] } futures.workspace = true +fs4.workspace = true generic-array = "0.14.7" derivative.workspace = true galaxy_core.workspace = true diff --git a/crates/ai/src/index/local_project_index/mod.rs b/crates/ai/src/index/local_project_index/mod.rs index e2d3453d..5c267db5 100644 --- a/crates/ai/src/index/local_project_index/mod.rs +++ b/crates/ai/src/index/local_project_index/mod.rs @@ -6,12 +6,14 @@ #![cfg(all(feature = "local_fs", not(target_family = "wasm")))] use std::collections::{HashMap, HashSet}; -use std::fs; +use std::fs::{self, File, OpenOptions}; use std::path::{Path, PathBuf}; +use std::time::Duration; use anyhow::{Context, Result}; use galaxy_core::paths::state_dir; use galaxy_util::standardized_path::StandardizedPath; +use galaxyui_core::r#async::Timer; use galaxyui_core::{Entity, ModelContext, SingletonEntity}; use itertools::Itertools; use repo_metadata::{RepoMetadataEvent, RepositoryIdentifier}; @@ -29,6 +31,7 @@ const MAX_INDEXED_BODY_BYTES: usize = 256_000; const INDEX_DIRECTORY_NAME: &str = "local_project_indices"; const CURRENT_FILE_NAME: &str = "CURRENT"; const METADATA_FILE_NAME: &str = "metadata.json"; +const INDEX_LOCK_RETRY_INTERVAL: Duration = Duration::from_millis(50); // Field weights intentionally prioritize symbols and paths over implementation text. define_search_schema!( @@ -240,6 +243,7 @@ impl LocalProjectIndexManager { }); let should_rebuild = manager.pending_rebuilds.remove(&root_path); cleanup_old_generations(&manager.storage_root, &root_path); + drop(built_index.repository_lock); if should_rebuild { manager.start_rebuild(root_path, ctx); } @@ -286,7 +290,6 @@ impl LocalProjectIndexManager { }); } let should_rebuild = self.pending_rebuilds.remove(&root_path); - cleanup_old_generations(&self.storage_root, &root_path); if should_rebuild { self.start_rebuild(root_path, ctx); } @@ -350,7 +353,7 @@ impl LocalProjectIndexManager { /// Removes a local project index and its persisted generations. pub fn remove_index_for_path(&mut self, root_path: PathBuf, ctx: &mut ModelContext) { let root_path = dunce::canonicalize(&root_path).unwrap_or(root_path); - self.remove_index(&root_path); + self.remove_index(&root_path, ctx); ctx.emit(LocalProjectIndexEvent::IndexRemoved { root_path }); } @@ -374,7 +377,7 @@ impl LocalProjectIndexManager { let local_path = path.to_local_path_lossy(); let root_path = dunce::canonicalize(&local_path).unwrap_or(local_path); if self.statuses.contains_key(&root_path) { - self.remove_index(&root_path); + self.remove_index(&root_path, ctx); ctx.emit(LocalProjectIndexEvent::IndexRemoved { root_path }); } return; @@ -408,19 +411,30 @@ impl LocalProjectIndexManager { } } - fn remove_index(&mut self, root_path: &Path) { + fn remove_index(&mut self, root_path: &Path, ctx: &mut ModelContext) { let root_path = dunce::canonicalize(root_path).unwrap_or_else(|_| root_path.to_path_buf()); self.indices.remove(&root_path); self.statuses.remove(&root_path); self.pending_rebuilds.remove(&root_path); let epoch = self.rebuild_epochs.entry(root_path.clone()).or_default(); *epoch += 1; - let directory = repository_storage_directory(&self.storage_root, &root_path); - if let Err(error) = fs::remove_dir_all(directory) { - if error.kind() != std::io::ErrorKind::NotFound { - log::warn!("Failed to remove local project index: {error}"); - } - } + let storage_root = self.storage_root.clone(); + ctx.spawn( + async move { + let _repository_lock = acquire_repository_lock(&storage_root, &root_path).await?; + let directory = repository_storage_directory(&storage_root, &root_path); + match fs::remove_dir_all(directory) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(anyhow::Error::new(error)), + } + }, + |_, result, _| { + if let Err(error) = result { + log::warn!("Failed to remove local project index: {error:#}"); + } + }, + ); } fn restore_persisted_indices(&mut self) { @@ -503,6 +517,7 @@ struct BuiltLocalProjectIndex { searcher: SimpleFullTextSearcher, generation: String, generation_directory: PathBuf, + repository_lock: File, } async fn build_persisted_index( @@ -511,6 +526,10 @@ async fn build_persisted_index( ) -> std::result::Result<(PathBuf, BuiltLocalProjectIndex, usize), (PathBuf, anyhow::Error)> { let error_root_path = root_path.clone(); let result = async { + // A desktop app and one or more CLI processes can share this storage root. Keep the + // repository snapshot, generation publication, and cleanup in one exclusive section so + // one process cannot delete another process's in-progress Tantivy generation. + let repository_lock = acquire_repository_lock(&storage_root, &root_path).await?; let documents = build_documents(&root_path).await?; let index_directory = repository_storage_directory(&storage_root, &root_path); fs::create_dir_all(index_directory.join("generations"))?; @@ -565,6 +584,7 @@ async fn build_persisted_index( searcher, generation, generation_directory, + repository_lock, }, documents.len(), )) @@ -643,9 +663,31 @@ fn repository_storage_directory(storage_root: &Path, root_path: &Path) -> PathBu storage_root.join(format_storage_directory_name(root_path)) } +fn repository_lock_path(storage_root: &Path, root_path: &Path) -> PathBuf { + storage_root.join(format!("{}.lock", format_storage_directory_name(root_path))) +} + +async fn acquire_repository_lock(storage_root: &Path, root_path: &Path) -> Result { + fs::create_dir_all(storage_root)?; + let lock_path = repository_lock_path(storage_root, root_path); + let lock_file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .open(&lock_path) + .with_context(|| format!("Failed to open local index lock {}", lock_path.display()))?; + loop { + if fs4::fs_std::FileExt::try_lock_exclusive(&lock_file)? { + return Ok(lock_file); + } + Timer::after(INDEX_LOCK_RETRY_INTERVAL).await; + } +} + fn publish_generation(storage_root: &Path, root_path: &Path, generation: &str) -> Result<()> { let index_directory = repository_storage_directory(storage_root, root_path); - let temporary_current = index_directory.join(format!(".{CURRENT_FILE_NAME}.tmp")); + let temporary_current = + index_directory.join(format!(".{CURRENT_FILE_NAME}.{}.tmp", uuid::Uuid::new_v4())); fs::write(&temporary_current, generation.as_bytes())?; fs::rename(&temporary_current, index_directory.join(CURRENT_FILE_NAME))?; Ok(()) diff --git a/crates/galaxy_logging/src/native.rs b/crates/galaxy_logging/src/native.rs index c33bc025..d2d8498f 100644 --- a/crates/galaxy_logging/src/native.rs +++ b/crates/galaxy_logging/src/native.rs @@ -22,6 +22,10 @@ const CLI_LOG_SUBDIRECTORY: &str = "oz"; const SESSION_LOG_SUBDIRECTORY: &str = "session-logs"; const TEMP_LOG_FILE_SUFFIX: &str = "old.temp"; const INPUT_CLASSIFIER_LOG_TARGET: &str = "input_classifier"; +const FILE_WATCHER_LOG_TARGET: &str = "notify"; +const FILE_WATCHER_DEBOUNCER_LOG_TARGET: &str = "notify_debouncer_full"; +const GLOBSET_LOG_TARGET: &str = "globset"; +const IGNORE_WALKER_LOG_TARGET: &str = "ignore"; const TERMINAL_ANSI_HANDLER_LOG_TARGET: &str = "galaxy::terminal::model::grid::grid_handler::ansi_handler"; @@ -593,6 +597,17 @@ fn init_internal( // info/debug records. Keep initialization failures and classification errors, but omit the // noisy pre-submission decision trail from full-session logs. .filter(Some(INPUT_CLASSIFIER_LOG_TARGET), LevelFilter::Warn) + // notify logs every platform event and the debouncer logs every raw event at TRACE. A + // repository index can generate hundreds of thousands of these records, multiplying the + // underlying filesystem work with synchronous formatting and log-file writes. Keep + // watcher lifecycle information and all warnings while suppressing per-event payloads. + .filter(Some(FILE_WATCHER_LOG_TARGET), LevelFilter::Info) + .filter(Some(FILE_WATCHER_DEBOUNCER_LOG_TARGET), LevelFilter::Info) + // globset and ignore describe every compiled glob and opened ignore file at DEBUG. Those + // records are useful to their crate maintainers but scale with repository traversal and do + // not help diagnose Galaxy sessions. Preserve informational failures and summaries. + .filter(Some(GLOBSET_LOG_TARGET), LevelFilter::Info) + .filter(Some(IGNORE_WALKER_LOG_TARGET), LevelFilter::Info) // Since we always pair an insertion with a deletion to avoid duplicate, // tantivy will log a lot of warnings for deleting a non-existing doc. .filter(Some("tantivy"), LevelFilter::Error) diff --git a/crates/galaxyui/src/windowing/winit/event_loop/mod.rs b/crates/galaxyui/src/windowing/winit/event_loop/mod.rs index 308c4b79..48c59068 100644 --- a/crates/galaxyui/src/windowing/winit/event_loop/mod.rs +++ b/crates/galaxyui/src/windowing/winit/event_loop/mod.rs @@ -1866,7 +1866,7 @@ impl EventLoop { // will be dispatched to the active window as TypedCharacters/IME events. let proxy = self.proxy.clone(); let on_input = Box::new(move |input: SoftKeyboardInput| { - log::debug!("Soft keyboard callback received input: {:?}", input); + log::debug!("Soft keyboard callback received input"); if let Err(e) = proxy.send_event(CustomEvent::SoftKeyboardInput(input)) { log::error!("Failed to send SoftKeyboardInput event: {:?}", e); } diff --git a/crates/galaxyui_core/src/elements/gui/new_scrollable/mod.rs b/crates/galaxyui_core/src/elements/gui/new_scrollable/mod.rs index c3ed5041..ad190407 100644 --- a/crates/galaxyui_core/src/elements/gui/new_scrollable/mod.rs +++ b/crates/galaxyui_core/src/elements/gui/new_scrollable/mod.rs @@ -1471,7 +1471,6 @@ impl Element for NewScrollable { app: &AppContext, ) -> bool { let Some(z_index) = self.child_max_z_index else { - log::warn!("Tried to handle event in scrollable before the element is painted"); return false; }; diff --git a/crates/galaxyui_core/src/elements/gui/resizable.rs b/crates/galaxyui_core/src/elements/gui/resizable.rs index 4653e87e..27757b94 100644 --- a/crates/galaxyui_core/src/elements/gui/resizable.rs +++ b/crates/galaxyui_core/src/elements/gui/resizable.rs @@ -458,9 +458,8 @@ impl Element for Resizable { } crate::Event::MouseMoved { position, .. } => { // A mouse event over the dragbar should set the cursor - let Some(z_index) = self.z_index() else { - log::warn!("self.z_index() was None in `Resizable`"); - return false; + let Some(z_index) = self.dragbar.z_index else { + return child_handled; }; let hovering_dragbar = self.is_mouse_hovering_dragbar(ctx, *position); let was_already_hovering = diff --git a/crates/galaxyui_core/src/elements/new_scrollable/mod.rs b/crates/galaxyui_core/src/elements/new_scrollable/mod.rs index 71ae26ea..69ca1520 100644 --- a/crates/galaxyui_core/src/elements/new_scrollable/mod.rs +++ b/crates/galaxyui_core/src/elements/new_scrollable/mod.rs @@ -1471,7 +1471,6 @@ impl Element for NewScrollable { app: &AppContext, ) -> bool { let Some(z_index) = self.child_max_z_index else { - log::warn!("Tried to handle event in scrollable before the element is painted"); return false; }; diff --git a/crates/galaxyui_core/src/elements/resizable.rs b/crates/galaxyui_core/src/elements/resizable.rs index 4653e87e..27757b94 100644 --- a/crates/galaxyui_core/src/elements/resizable.rs +++ b/crates/galaxyui_core/src/elements/resizable.rs @@ -458,9 +458,8 @@ impl Element for Resizable { } crate::Event::MouseMoved { position, .. } => { // A mouse event over the dragbar should set the cursor - let Some(z_index) = self.z_index() else { - log::warn!("self.z_index() was None in `Resizable`"); - return false; + let Some(z_index) = self.dragbar.z_index else { + return child_handled; }; let hovering_dragbar = self.is_mouse_hovering_dragbar(ctx, *position); let was_already_hovering = diff --git a/crates/jsonrpc/src/service.rs b/crates/jsonrpc/src/service.rs index ea36936c..be51db2f 100644 --- a/crates/jsonrpc/src/service.rs +++ b/crates/jsonrpc/src/service.rs @@ -154,7 +154,7 @@ impl JsonRpcService { break; } - log::trace!("JSON-RPC: received message: {message}"); + log::trace!("JSON-RPC: received {} bytes", message.len()); if let Err(e) = Self::handle_message( &transport, &message, @@ -319,7 +319,7 @@ impl JsonRpcService { method: String, params: Value, ) -> Result { - log::trace!("Sending request {request_id}: {method}: {params}"); + log::trace!("Sending request {request_id}: {method}"); let request = Request { jsonrpc: JSON_RPC_VERSION, diff --git a/crates/settings/src/lib.rs b/crates/settings/src/lib.rs index a7384e2b..90190232 100644 --- a/crates/settings/src/lib.rs +++ b/crates/settings/src/lib.rs @@ -433,11 +433,7 @@ pub trait Setting { }; match ::from_file_value(&json_value) { Some(val) => { - log::debug!( - "Loaded {} from settings file; value: {:?}", - Self::setting_name(), - val - ); + log::debug!("Loaded {} from settings file", Self::setting_name()); return Some(val); } None => { @@ -453,11 +449,7 @@ pub trait Setting { match serde_json::from_str(&value) { Ok(val) => { - log::debug!( - "Loaded {} from user defaults; value: {:?}", - Self::setting_name(), - val - ); + log::debug!("Loaded {} from user defaults", Self::setting_name()); Some(val) } Err(err) => { @@ -516,10 +508,9 @@ pub trait Setting { if !stored_value_matches { log::debug!( - "Writing new value of {} to storage; key: {}; value: {:?}", + "Writing new value of {} to storage; key: {}", Self::setting_name(), - key, - value + key ); let _ = preferences.write_value_with_hierarchy( key, diff --git a/crates/settings/src/macros.rs b/crates/settings/src/macros.rs index 67700f73..1164ca15 100644 --- a/crates/settings/src/macros.rs +++ b/crates/settings/src/macros.rs @@ -254,11 +254,7 @@ macro_rules! define_setting { }, None => { let default_value = Self::default_value(); - log::debug!( - "Initializing {} to default value: {:?}", - Self::setting_name(), - default_value - ); + log::debug!("Initializing {} to its default value", Self::setting_name()); Self { inner: default_value, is_explicitly_set: false,