diff --git a/app/Cargo.toml b/app/Cargo.toml index 24c5c132..f1a4b6da 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -935,7 +935,7 @@ cloud_mode_input_v2 = ["cloud_mode"] [package.metadata.bundle.bin.galaxy-oss] category = "public.app-category.developer-tools" copyright = "© 2026, Samsung Electronics Co., Ltd." -identifier = "com.samsung.Galaxy" +identifier = "samsung.galaxy.GalaxyOss" name = "Galaxy" resources = ["assets/onboarding"] icon = ["channels/oss/icon/no-padding/512x512.png", "channels/oss/icon/no-padding/icon.ico"] @@ -944,7 +944,7 @@ short_description = "Galaxy - AI-powered terminal for development teams." [package.metadata.bundle.bin.galaxy-stable] category = "public.app-category.developer-tools" copyright = "© 2026, Samsung Electronics Co., Ltd." -identifier = "com.samsung.Galaxy-Stable" +identifier = "samsung.galaxy.GalaxyStable" name = "Galaxy" resources = ["assets/onboarding"] short_description = "Galaxy - AI-powered terminal, stable build." @@ -952,7 +952,7 @@ short_description = "Galaxy - AI-powered terminal, stable build." [package.metadata.bundle.bin.galaxy-preview] category = "public.app-category.developer-tools" copyright = "© 2026, Samsung Electronics Co., Ltd." -identifier = "com.samsung.Galaxy-Preview" +identifier = "samsung.galaxy.GalaxyPreview" name = "Galaxy Preview" resources = ["assets/onboarding"] short_description = "Galaxy - AI-powered terminal, preview build." @@ -960,7 +960,7 @@ short_description = "Galaxy - AI-powered terminal, preview build." [package.metadata.bundle.bin.galaxy-dev] category = "public.app-category.developer-tools" copyright = "© 2026, Samsung Electronics Co., Ltd." -identifier = "com.samsung.Galaxy-Dev" +identifier = "samsung.galaxy.GalaxyDev" name = "Galaxy Dev" resources = ["assets/onboarding"] short_description = "Galaxy - AI-powered terminal, developer build." @@ -968,7 +968,7 @@ short_description = "Galaxy - AI-powered terminal, developer build." [package.metadata.bundle.bin.galaxy-local] category = "public.app-category.developer-tools" copyright = "© 2026, Samsung Electronics Co., Ltd." -identifier = "com.samsung.Galaxy-Local" +identifier = "samsung.galaxy.GalaxyLocal" name = "Galaxy Local" resources = ["assets/onboarding"] short_description = "Galaxy - AI-powered terminal, local build." diff --git a/app/DockTilePlugin/Info.plist b/app/DockTilePlugin/Info.plist index b83b3c15..34ba292a 100644 --- a/app/DockTilePlugin/Info.plist +++ b/app/DockTilePlugin/Info.plist @@ -3,7 +3,7 @@ CFBundleIdentifier - com.samsung.GalaxyDockTilePlugin + samsung.galaxy.GalaxyDockTilePlugin CFBundleExecutable WarpDockTilePlugin CFBundleName diff --git a/app/src/ai/blocklist/block/status_bar.rs b/app/src/ai/blocklist/block/status_bar.rs index ad960315..efff0256 100644 --- a/app/src/ai/blocklist/block/status_bar.rs +++ b/app/src/ai/blocklist/block/status_bar.rs @@ -6,7 +6,8 @@ use super::{ view_impl::common::{ render_switch_control_to_user_button, render_warping_indicator, render_warping_indicator_base, ButtonProps, ForceRefreshButtonProps, MaybeShimmeringText, - WarpingIndicatorProps, WarpingProps, LOAD_OUTPUT_MESSAGE, WAITING_FOR_USER_INPUT_MESSAGE, + WarpingIndicatorProps, WarpingProps, random_load_output_message, + WAITING_FOR_USER_INPUT_MESSAGE, }, }; use crate::{ @@ -812,7 +813,7 @@ impl BlocklistAIStatusBar { ); let default_warping_text = fallback_warping_text .as_deref() - .unwrap_or(LOAD_OUTPUT_MESSAGE) + .unwrap_or(random_load_output_message()) .to_owned(); let secondary_element = if fallback_warping_text.is_some() { Some(render_fallback_explanation(model.as_ref(), app)) diff --git a/app/src/ai/blocklist/block/view_impl/common.rs b/app/src/ai/blocklist/block/view_impl/common.rs index eb69e6b4..61f3bc1e 100644 --- a/app/src/ai/blocklist/block/view_impl/common.rs +++ b/app/src/ai/blocklist/block/view_impl/common.rs @@ -129,7 +129,54 @@ const IMAGE_SOURCE_LINK_LINE_INDEX: usize = 1; const ERROR_APOLOGY_TEXT: &str = "I'm sorry, I couldn't complete that request."; const INTERNAL_WARP_ERROR: &str = "Internal Warp error."; -pub const LOAD_OUTPUT_MESSAGE: &str = "Warping..."; +const GALAXY_STATUS_MESSAGES: &[&str] = &[ + "Warping through the Galaxy...", + "Guarding the Galaxy...", + "Exploring the Galaxy...", + "Charting the Galaxy...", + "Traversing the Galaxy...", + "Navigating the Galaxy...", + "Scanning the Galaxy...", + "Hitchhiking across the Galaxy...", + "Assembling the Galaxy...", + "Aligning stars in the Galaxy...", + "Consulting the Galaxy...", + "Galaxy brain activated...", + "Orbiting the Galaxy...", + "Bending the Galaxy...", + "Summoning the Galaxy...", + "Decoding the Galaxy...", + "Harnessing Galaxy power...", + "Riding the Galaxy stream...", + "Tuning into the Galaxy...", + "Surfing the Galaxy waves...", + "Mapping the Galaxy...", + "Galaxy engines engaged...", + "Weaving through the Galaxy...", + "Channeling the Galaxy...", + "Phasing through the Galaxy...", + "Galaxy hyperdrive online...", + "Syncing with the Galaxy...", + "Galaxy core spinning up...", + "Drifting through the Galaxy...", + "Galaxy warp field stable...", + "Tapping into Galaxy energy...", + "Galaxy neurons firing...", + "Quantum leaping the Galaxy...", + "Galaxy thrusters ignited...", + "Calculating Galaxy trajectory...", + "Galaxy flux capacitor charged...", + "Piercing the Galaxy veil...", + "Galaxy signal acquired...", + "Folding Galaxy spacetime...", + "Galaxy coordinates locked...", +]; + +pub fn random_load_output_message() -> &'static str { + use rand::Rng; + let idx = rand::thread_rng().gen_range(0..GALAXY_STATUS_MESSAGES.len()); + GALAXY_STATUS_MESSAGES[idx] +} pub const LOAD_OUTPUT_MESSAGE_FOR_ADJUSTING: &str = "Adjusting tasks..."; pub const LOAD_OUTPUT_MESSAGE_FOR_PASSIVE_CODE_GEN: &str = "Generating fix..."; pub const LOAD_OUTPUT_MESSAGE_FOR_CREATING_DIFF: &str = "Creating diff..."; diff --git a/app/src/bin/local.rs b/app/src/bin/local.rs index adb173ee..36bd5517 100644 --- a/app/src/bin/local.rs +++ b/app/src/bin/local.rs @@ -46,7 +46,7 @@ embed_plist::embed_info_plist_bytes!(r#" CFBundleExecutable galaxy-local CFBundleIdentifier - com.samsung.Galaxy-Local + samsung.galaxy.GalaxyLocal CFBundleInfoDictionaryVersion 6.0 CFBundleName diff --git a/app/src/bin/oss.rs b/app/src/bin/oss.rs index a50181e7..8d70dfbf 100644 --- a/app/src/bin/oss.rs +++ b/app/src/bin/oss.rs @@ -43,7 +43,7 @@ embed_plist::embed_info_plist_bytes!(r#" CFBundleExecutable galaxy-oss CFBundleIdentifier - com.samsung.Galaxy + samsung.galaxy.GalaxyOss CFBundleInfoDictionaryVersion 6.0 CFBundleName diff --git a/app/src/code/completion.rs b/app/src/code/completion.rs new file mode 100644 index 00000000..980f80d0 --- /dev/null +++ b/app/src/code/completion.rs @@ -0,0 +1,513 @@ +use std::time::Duration; + +use futures::stream::AbortHandle; +use galaxy_core::features::FeatureFlag; +use galaxy_core::ui::appearance::Appearance; +use galaxy_core::ui::theme::color::internal_colors; +use galaxyui::elements::{ + Border, ChildAnchor, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Flex, + MainAxisSize, OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, Radius, + Shrinkable, Text, +}; +use galaxyui::{AppContext, Element, SingletonEntity, ViewContext}; +use lsp::{CompletionItemData, CompletionKind, CompletionResult, CompletionTrigger}; +use pathfinder_geometry::vector::Vector2F; +use string_offset::CharOffset; +use vec1::Vec1; + +use super::local_code_editor::LocalCodeEditorView; + +pub const COMPLETION_DEBOUNCE_PERIOD: Duration = Duration::from_millis(50); +const COMPLETION_MENU_MAX_HEIGHT: f32 = 220.; +const COMPLETION_MENU_WIDTH: f32 = 340.; +const MAX_VISIBLE_ITEMS: usize = 10; + +/// State machine for the completion feature. +pub(super) enum CompletionState { + /// No completion session active. + Idle, + /// Waiting for the LSP response. + Requesting { + abort_handle: AbortHandle, + trigger_offset: CharOffset, + }, + /// Completion menu is visible with results. + Showing { + items: Vec, + filtered_indices: Vec, + selected_index: usize, + trigger_offset: CharOffset, + is_incomplete: bool, + }, +} + +impl Default for CompletionState { + fn default() -> Self { + Self::Idle + } +} + +impl CompletionState { + pub fn is_showing(&self) -> bool { + matches!(self, Self::Showing { .. }) + } + + pub fn dismiss(&mut self) -> bool { + if matches!(self, Self::Idle) { + return false; + } + if let Self::Requesting { abort_handle, .. } = self { + abort_handle.abort(); + } + *self = Self::Idle; + true + } + + pub fn selected_item(&self) -> Option<&CompletionItemData> { + match self { + Self::Showing { + items, + filtered_indices, + selected_index, + .. + } => filtered_indices + .get(*selected_index) + .and_then(|&idx| items.get(idx)), + _ => None, + } + } + + pub fn move_selection(&mut self, delta: i32) { + if let Self::Showing { + filtered_indices, + selected_index, + .. + } = self + { + if filtered_indices.is_empty() { + return; + } + let len = filtered_indices.len() as i32; + let new_idx = (*selected_index as i32 + delta).rem_euclid(len); + *selected_index = new_idx as usize; + } + } + + pub fn filter(&mut self, query: &str) { + if let Self::Showing { + items, + filtered_indices, + selected_index, + .. + } = self + { + if query.is_empty() { + *filtered_indices = (0..items.len()).collect(); + } else { + let query_lower = query.to_lowercase(); + *filtered_indices = items + .iter() + .enumerate() + .filter(|(_, item)| { + let filter_text = item.effective_filter_text().to_lowercase(); + fuzzy_match(&filter_text, &query_lower) + }) + .map(|(idx, _)| idx) + .collect(); + } + if *selected_index >= filtered_indices.len() { + *selected_index = 0; + } + } + } +} + +fn fuzzy_match(target: &str, query: &str) -> bool { + let mut target_chars = target.chars(); + for query_char in query.chars() { + loop { + match target_chars.next() { + Some(tc) if tc == query_char => break, + Some(_) => continue, + None => return false, + } + } + } + true +} + +impl LocalCodeEditorView { + pub(super) fn is_completion_enabled() -> bool { + FeatureFlag::LspCompletion.is_enabled() + } + + /// Handle a user typing event — potentially trigger completion. + pub(super) fn on_content_changed_for_completion(&mut self, ctx: &mut ViewContext) { + if !Self::is_completion_enabled() { + return; + } + + if self.lsp_server.is_none() { + return; + } + + let cursor_offset = self.editor().as_ref(ctx).cursor_head_offset(ctx); + if cursor_offset == CharOffset::from(0) { + return; + } + + let char_before = self + .editor() + .as_ref(ctx) + .char_at(cursor_offset - CharOffset::from(1), ctx); + + let trigger = match char_before { + Some('.') => Some(CompletionTrigger::TriggerCharacter(".".into())), + Some(':') => { + if cursor_offset >= CharOffset::from(2) { + let prev_char = self + .editor() + .as_ref(ctx) + .char_at(cursor_offset - CharOffset::from(2), ctx); + if prev_char == Some(':') { + Some(CompletionTrigger::TriggerCharacter("::".into())) + } else { + None + } + } else { + None + } + } + Some(c) if c.is_alphanumeric() || c == '_' => { + let _ = self.completion_debounce_tx.try_send(cursor_offset); + return; + } + _ => { + if self.completion_state.dismiss() { + ctx.notify(); + } + return; + } + }; + + if let Some(trigger) = trigger { + self.request_completion(cursor_offset, trigger, ctx); + } + } + + /// Request completions from the LSP server. + pub(super) fn request_completion( + &mut self, + trigger_offset: CharOffset, + trigger: CompletionTrigger, + ctx: &mut ViewContext, + ) { + if !Self::is_completion_enabled() { + return; + } + + let Some(file_path) = self.file_path() else { + return; + }; + + let Some(lsp_server) = &self.lsp_server else { + return; + }; + + let lsp_position = self + .editor() + .as_ref(ctx) + .offset_to_lsp_position(trigger_offset, ctx); + + let future = match lsp_server + .as_ref(ctx) + .completion(file_path.to_path_buf(), lsp_position, trigger) + { + Ok(future) => future, + Err(e) => { + log::warn!("Failed to call lsp.completion: {e}"); + return; + } + }; + + self.completion_state.dismiss(); + + let abort_handle = ctx + .spawn(future, move |me, result, ctx| { + me.handle_completion_response(result, trigger_offset, ctx); + }) + .abort_handle(); + + self.completion_state = CompletionState::Requesting { + abort_handle, + trigger_offset, + }; + } + + /// Handle the debounced completion trigger (from typing). + pub(super) fn request_completion_debounced( + &mut self, + offset: CharOffset, + ctx: &mut ViewContext, + ) { + if let CompletionState::Showing { trigger_offset, .. } = &self.completion_state { + let query = self.get_completion_filter_query(*trigger_offset, ctx); + self.completion_state.filter(&query); + ctx.notify(); + return; + } + + self.request_completion(offset, CompletionTrigger::Invoked, ctx); + } + + fn handle_completion_response( + &mut self, + result: anyhow::Result>, + trigger_offset: CharOffset, + ctx: &mut ViewContext, + ) { + let completion_result = match result { + Ok(Some(result)) if !result.items.is_empty() => result, + _ => { + self.completion_state = CompletionState::Idle; + ctx.notify(); + return; + } + }; + + let filtered_indices: Vec = (0..completion_result.items.len()).collect(); + + self.completion_state = CompletionState::Showing { + items: completion_result.items, + filtered_indices, + selected_index: 0, + trigger_offset, + is_incomplete: completion_result.is_incomplete, + }; + + let query = self.get_completion_filter_query(trigger_offset, ctx); + self.completion_state.filter(&query); + + ctx.notify(); + } + + fn get_completion_filter_query( + &self, + trigger_offset: CharOffset, + ctx: &ViewContext, + ) -> String { + let editor = self.editor().as_ref(ctx); + let cursor_offset = editor.cursor_head_offset(ctx); + if cursor_offset <= trigger_offset { + return String::new(); + } + + // Access the buffer to get text in range + editor + .buffer_text_in_range(trigger_offset..cursor_offset, ctx) + .unwrap_or_default() + } + + /// Confirm the currently selected completion item. + pub(super) fn confirm_completion(&mut self, ctx: &mut ViewContext) -> bool { + let (insert_text, text_edit_range, trigger_offset) = match &self.completion_state { + CompletionState::Showing { + items, + filtered_indices, + selected_index, + trigger_offset, + .. + } => { + let Some(&idx) = filtered_indices.get(*selected_index) else { + return false; + }; + let item = &items[idx]; + ( + item.insert_text.clone(), + item.text_edit_range.clone(), + *trigger_offset, + ) + } + _ => return false, + }; + + self.completion_state = CompletionState::Idle; + + self.editor.update(ctx, |editor, ctx| { + let edit_range = if let Some(range) = text_edit_range { + let start = editor.lsp_location_to_offset(&range.start, ctx); + let end = editor.lsp_location_to_offset(&range.end, ctx); + start..end + } else { + let cursor = editor.cursor_head_offset(ctx); + trigger_offset..cursor + }; + + if let Ok(edits) = Vec1::try_from_vec(vec![(insert_text, edit_range)]) { + editor.apply_edits(edits, ctx); + } + }); + + ctx.notify(); + true + } + + /// Render the completion menu overlay. + pub(super) fn render_completion_menu(&self, app: &AppContext) -> Option> { + let CompletionState::Showing { + items, + filtered_indices, + selected_index, + .. + } = &self.completion_state + else { + return None; + }; + + if filtered_indices.is_empty() { + return None; + } + + let appearance = Appearance::as_ref(app); + let theme = appearance.theme(); + let visible_count = filtered_indices.len().min(MAX_VISIBLE_ITEMS); + + let mut content_column = Flex::column(); + + for (display_idx, &item_idx) in + filtered_indices.iter().enumerate().take(visible_count) + { + let item = &items[item_idx]; + let is_selected = display_idx == *selected_index; + content_column.add_child(render_completion_item(item, is_selected, appearance)); + } + + let constrained_content = ConstrainedBox::new(content_column.finish()) + .with_width(COMPLETION_MENU_WIDTH) + .with_max_height(COMPLETION_MENU_MAX_HEIGHT) + .finish(); + + let menu = Container::new(constrained_content) + .with_background(theme.background()) + .with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.))) + .with_border(Border::all(1.).with_border_fill(internal_colors::neutral_4(theme))) + .finish(); + + Some(menu) + } + + /// Compute the positioning for the completion menu (below cursor). + pub(super) fn completion_menu_positioning( + &self, + app: &AppContext, + ) -> Option { + let trigger_offset = match &self.completion_state { + CompletionState::Showing { trigger_offset, .. } => *trigger_offset, + _ => return None, + }; + + let bounds = self + .editor() + .as_ref(app) + .character_bounds_in_viewport(trigger_offset, app)?; + + Some(OffsetPositioning::offset_from_parent( + Vector2F::new(bounds.origin_x(), bounds.max_y()), + ParentOffsetBounds::ParentByPosition, + ParentAnchor::TopLeft, + ChildAnchor::TopLeft, + )) + } +} + +fn render_completion_item( + item: &CompletionItemData, + is_selected: bool, + appearance: &Appearance, +) -> Box { + let theme = appearance.theme(); + + let icon_text: &'static str = match item.kind { + Some(CompletionKind::Function) | Some(CompletionKind::Method) => "fn", + Some(CompletionKind::Variable) => "var", + Some(CompletionKind::Field) | Some(CompletionKind::Property) => "fld", + Some(CompletionKind::Class) | Some(CompletionKind::Struct) => "str", + Some(CompletionKind::Interface) => "ifc", + Some(CompletionKind::Module) => "mod", + Some(CompletionKind::Enum) => "enm", + Some(CompletionKind::EnumMember) => "emb", + Some(CompletionKind::Constant) => "cst", + Some(CompletionKind::Keyword) => "kw", + Some(CompletionKind::Snippet) => "snp", + Some(CompletionKind::TypeParameter) => "typ", + _ => " ", + }; + + let label = item.label.clone(); + let detail = item.detail.clone(); + + let mut row = Flex::row() + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_main_axis_size(MainAxisSize::Min); + + row.add_child( + Container::new( + Text::new_inline( + icon_text, + appearance.monospace_font_family(), + appearance.monospace_font_size() * 0.85, + ) + .with_color(theme.disabled_ui_text_color().into()) + .finish(), + ) + .with_padding_left(4.) + .with_padding_right(6.) + .finish(), + ); + + row.add_child( + Shrinkable::new( + 1., + Text::new( + label, + appearance.monospace_font_family(), + appearance.monospace_font_size(), + ) + .with_color(theme.active_ui_text_color().into()) + .finish(), + ) + .finish(), + ); + + if let Some(detail) = detail { + row.add_child( + Container::new( + Shrinkable::new( + 2., + Text::new( + detail, + appearance.monospace_font_family(), + appearance.monospace_font_size() * 0.85, + ) + .with_color(theme.disabled_ui_text_color().into()) + .finish(), + ) + .finish(), + ) + .with_padding_left(8.) + .finish(), + ); + } + + let mut container = Container::new(row.finish()) + .with_vertical_padding(3.) + .with_horizontal_padding(4.); + + if is_selected { + container = container.with_background(internal_colors::neutral_2(theme)); + } else { + container = container.with_background(theme.background()); + } + + container.finish() +} diff --git a/app/src/code/editor/view.rs b/app/src/code/editor/view.rs index 948cefd1..4b2cd0ca 100644 --- a/app/src/code/editor/view.rs +++ b/app/src/code/editor/view.rs @@ -1660,6 +1660,16 @@ impl CodeEditorView { self.model.as_ref(ctx).selections(ctx).first().head } + /// Returns text in the given character offset range from the buffer. + pub fn buffer_text_in_range( + &self, + range: Range, + ctx: &AppContext, + ) -> Option { + let buffer = self.model.as_ref(ctx).buffer().as_ref(ctx); + Some(buffer.text_in_range(range).into_string()) + } + pub fn hovered_symbol_range<'a>( &'a self, ctx: &'a AppContext, diff --git a/app/src/code/local_code_editor.rs b/app/src/code/local_code_editor.rs index 25d1201f..00e6b244 100644 --- a/app/src/code/local_code_editor.rs +++ b/app/src/code/local_code_editor.rs @@ -90,6 +90,7 @@ use super::editor::{ scroll::{ScrollPosition, ScrollTrigger}, view::{CodeEditorEvent, CodeEditorView}, }; +use super::completion::{CompletionState, COMPLETION_DEBOUNCE_PERIOD}; use super::find_references_view::{FindReferencesView, FindReferencesViewEvent}; use super::language_server_extension::ProcessedDiagnostic; use super::lsp_telemetry::LspTelemetryEvent; @@ -303,6 +304,10 @@ pub struct LocalCodeEditorView { pub(super) diagnostic_decorations: Vec, /// View for the find references feature. find_references_view: Option>, + /// State for the LSP completion menu. + pub(super) completion_state: CompletionState, + /// Channel for debouncing completion requests triggered by typing. + pub(super) completion_debounce_tx: async_channel::Sender, } impl LocalCodeEditorView { @@ -337,6 +342,9 @@ impl LocalCodeEditorView { if origin.from_user() { me.was_edited = true; ctx.emit(LocalCodeEditorEvent::UserEdited); + + // Trigger completion on user typing + me.on_content_changed_for_completion(ctx); } } CodeEditorEvent::VimEscapeInNormalMode => { @@ -473,6 +481,14 @@ impl LocalCodeEditorView { |_, _| {}, ); + // Set up debounce for completion requests (shorter period than hover) + let (completion_debounce_tx, completion_debounce_rx) = async_channel::unbounded(); + ctx.spawn_stream_local( + debounce(COMPLETION_DEBOUNCE_PERIOD, completion_debounce_rx), + |me, offset, ctx| me.request_completion_debounced(offset, ctx), + |_, _| {}, + ); + let model = Self { editor, diff_type, @@ -495,6 +511,8 @@ impl LocalCodeEditorView { processed_diagnostics: Vec::new(), diagnostic_decorations: Vec::new(), find_references_view: None, + completion_state: CompletionState::default(), + completion_debounce_tx, }; if let Some(display_mode) = display_mode { @@ -1915,7 +1933,8 @@ impl LocalCodeEditorView { fn dismiss_lsp_overlays(&mut self, ctx: &mut ViewContext) -> bool { let had_refs = self.close_find_references_card(ctx); let had_hover = self.lsp_hover_state.clear(); - had_refs || had_hover + let had_completion = self.completion_state.dismiss(); + had_refs || had_hover || had_completion } /// Perform goto definition at the cursor position and navigate directly. @@ -2181,6 +2200,14 @@ impl View for LocalCodeEditorView { } } + // Render completion menu if active + if let (Some(completion_menu), Some(positioning)) = ( + self.render_completion_menu(app), + self.completion_menu_positioning(app), + ) { + stack.add_positioned_overlay_child(completion_menu, positioning); + } + // Render LSP hover tooltip if available (render last so it appears on top) if let (Some(hover_tooltip), Some(positioning)) = ( self.render_hover_tooltip(app), diff --git a/app/src/code/mod.rs b/app/src/code/mod.rs index e9347a2c..67d4690a 100644 --- a/app/src/code/mod.rs +++ b/app/src/code/mod.rs @@ -6,6 +6,8 @@ use std::any::Any; use std::fmt::Debug; use std::ops::AddAssign; +#[cfg(not(target_family = "wasm"))] +pub mod completion; #[cfg(not(target_family = "wasm"))] pub mod find_references_view; #[cfg(not(target_family = "wasm"))] diff --git a/crates/galaxy_core/src/channel/state.rs b/crates/galaxy_core/src/channel/state.rs index 43d6deee..344ddfd6 100644 --- a/crates/galaxy_core/src/channel/state.rs +++ b/crates/galaxy_core/src/channel/state.rs @@ -37,7 +37,7 @@ pub struct ChannelState { impl ChannelState { pub fn init() -> Self { let channel = Channel::Oss; - let app_id = AppId::new("dev", "warp", "WarpOss"); + let app_id = AppId::new("samsung", "galaxy", "GalaxyOss"); Self { channel, additional_features: Default::default(), @@ -379,13 +379,13 @@ impl ChannelState { pub fn url_scheme() -> &'static str { match Self::channel() { - Channel::Stable => "warp", - Channel::Preview => "warppreview", - Channel::Dev => "warpdev", + Channel::Stable => "galaxy", + Channel::Preview => "galaxypreview", + Channel::Dev => "galaxydev", // Dummy value--integration tests shouldn't support URL schemes. - Channel::Integration => "warpintegration", - Channel::Local => "warplocal", - Channel::Oss => "warposs", + Channel::Integration => "galaxyintegration", + Channel::Local => "galaxylocal", + Channel::Oss => "galaxyoss", } } } diff --git a/crates/lsp/src/command_builder.rs b/crates/lsp/src/command_builder.rs index c854b0c9..0f88fe4d 100644 --- a/crates/lsp/src/command_builder.rs +++ b/crates/lsp/src/command_builder.rs @@ -48,4 +48,37 @@ impl CommandBuilder { } cmd } + + /// Attempts to capture PATH from the user's login shell. + /// This is a fallback for when the PATH passed in from the terminal session + /// doesn't include paths set by tools like nvm, pyenv, rbenv, etc. that + /// modify PATH in shell rc files. + #[cfg(not(target_arch = "wasm32"))] + pub async fn capture_interactive_shell_path() -> Option { + let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string()); + + let output = Command::new(&shell) + .args(["-i", "-l", "-c", "echo $PATH"]) + .output() + .await + .ok()?; + + if !output.status.success() { + log::warn!( + "Failed to capture PATH from interactive shell ({}): {}", + shell, + String::from_utf8_lossy(&output.stderr) + ); + return None; + } + + let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if path.is_empty() { + log::warn!("Interactive shell returned empty PATH"); + return None; + } + + log::info!("Captured PATH from interactive shell ({shell})"); + Some(path) + } } diff --git a/crates/lsp/src/config.rs b/crates/lsp/src/config.rs index 03cb6e07..7988d2be 100644 --- a/crates/lsp/src/config.rs +++ b/crates/lsp/src/config.rs @@ -167,25 +167,24 @@ impl LspServerConfig { /// and working on PATH, we use that. Otherwise, we fall back to our custom installation. #[cfg(not(target_arch = "wasm32"))] pub(crate) async fn command_and_params(self) -> Result { - // PATH takes precedence - only use custom installation if not working on PATH - let executor = crate::CommandBuilder::new(self.path_env_var.clone()); + // Resolve the effective PATH for this server. We try multiple sources: + // 1. The PATH passed from the terminal session + // 2. PATH captured from the user's interactive login shell (picks up nvm, fnm, volta, etc.) + let executor = self.resolve_effective_executor().await; + let is_working_on_path = self .server_type .is_working_on_path(&executor, self.client.clone()) .await; + let custom_binary_config = if is_working_on_path { - // Binary works on PATH, don't use custom installation None } else { - // Not working on PATH, check for custom installation self.server_type .find_installed_binary_config(executor.path_env_var()) .await }; - // Bail early with a clear error instead of attempting to spawn a - // binary that doesn't exist (which would fail with a confusing - // "No such file or directory" OS error). if !is_working_on_path && custom_binary_config.is_none() { anyhow::bail!( "{} is not installed. Binary was not found on PATH and no custom installation exists", @@ -212,6 +211,39 @@ impl LspServerConfig { Ok(ResolvedLspCommand { command, params }) } + /// Resolves the best available PATH for running LSP-related commands. + /// Tries the session PATH first, then falls back to capturing PATH from + /// the user's interactive login shell (which includes nvm, fnm, volta, pyenv, etc.). + #[cfg(not(target_arch = "wasm32"))] + async fn resolve_effective_executor(&self) -> crate::CommandBuilder { + // If we have a session PATH, check if it can at least find common tools + if let Some(ref path) = self.path_env_var { + if !path.is_empty() { + // Quick sanity check: can this PATH find the LSP binary or node? + let executor = crate::CommandBuilder::new(Some(path.clone())); + let works = self + .server_type + .is_working_on_path(&executor, self.client.clone()) + .await; + if works { + return executor; + } + } + } + + // Session PATH didn't work — try interactive shell PATH + if let Some(shell_path) = crate::CommandBuilder::capture_interactive_shell_path().await { + log::info!( + "Using interactive shell PATH for {} (session PATH insufficient)", + self.server_type.binary_name() + ); + return crate::CommandBuilder::new(Some(shell_path)); + } + + // Fall back to whatever we have + crate::CommandBuilder::new(self.path_env_var.clone()) + } + pub(crate) fn server_type(&self) -> LSPServerType { self.server_type } diff --git a/crates/lsp/src/servers/typescript_language_server.rs b/crates/lsp/src/servers/typescript_language_server.rs index b751b9f8..c196ab79 100644 --- a/crates/lsp/src/servers/typescript_language_server.rs +++ b/crates/lsp/src/servers/typescript_language_server.rs @@ -9,8 +9,6 @@ use async_trait::async_trait; #[cfg(feature = "local_fs")] use anyhow::Context; -#[cfg(feature = "local_fs")] -use command::r#async::Command; #[cfg_attr(not(feature = "local_fs"), allow(dead_code))] pub struct TypeScriptLanguageServerCandidate { @@ -18,88 +16,31 @@ pub struct TypeScriptLanguageServerCandidate { } impl TypeScriptLanguageServerCandidate { - /// Path to the new langserver JS file (v4.0.0+) relative to the install directory. - #[cfg(feature = "local_fs")] - const NEW_SERVER_PATH: &str = "node_modules/typescript-language-server/lib/cli.mjs"; - - /// Path to the old langserver JS file (pre-4.0.0) relative to the install directory. - #[cfg(feature = "local_fs")] - const OLD_SERVER_PATH: &str = "node_modules/typescript-language-server/lib/cli.js"; - pub fn new(client: Arc) -> Self { Self { client } } - /// Finds the configuration for running typescript-language-server from our custom installation. - /// - /// Instead of running the wrapper script (which has a shebang requiring node in PATH), - /// we run node directly with the CLI JS file. This is the same pattern used by Zed. - /// - /// # Arguments - /// * `path_env_var` - The PATH environment variable to use when checking for system node. + /// Returns a CustomBinaryConfig that runs the server via npx. + /// npx handles downloading/caching the package automatically. #[cfg(feature = "local_fs")] pub async fn find_installed_binary_config( path_env_var: Option<&str>, ) -> Option { - let install_dir = galaxy_core::paths::data_dir().join("typescript-language-server"); - - // Check for the JS file - prefer new path (cli.mjs) over old path (cli.js) - let server_js = { - let new_path = install_dir.join(Self::NEW_SERVER_PATH); - if new_path.is_file() { - new_path - } else { - let old_path = install_dir.join(Self::OLD_SERVER_PATH); - if old_path.is_file() { - old_path - } else { - log::info!( - "typescript-language-server JS file not found at {} or {}", - new_path.display(), - old_path.display() - ); - return None; - } - } - }; - - // Try to find a working node binary - first custom, then system - let node_binary = node_runtime::find_working_node_binary(path_env_var).await?; - - // Verify the installation works by running `node cli.mjs --version` - let mut cmd = Command::new(&node_binary); - // Propagate PATH so "node" (bare name) resolves when using system node - if let Some(path) = path_env_var { - cmd.env("PATH", path); - } - cmd.arg(&server_js).arg("--version"); - match cmd.output().await { - Ok(output) if output.status.success() => { - let version = String::from_utf8_lossy(&output.stdout); - log::info!( - "Verified typescript-language-server installation: {}", - version.trim() - ); - } - Ok(output) => { - log::warn!( - "typescript-language-server version check failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - return None; - } - Err(e) => { - log::warn!( - "Failed to run typescript-language-server version check: {}", - e - ); - return None; - } + // npx is available wherever npm/node is — verify it exists + let path_env = path_env_var?; + let mut cmd = command::r#async::Command::new("npx"); + cmd.env("PATH", path_env); + cmd.arg("--version"); + let output = cmd.output().await.ok()?; + if !output.status.success() { + return None; } + // Use npx to run typescript-language-server + // --yes skips the install prompt, npx handles download/caching Some(CustomBinaryConfig { - binary_path: node_binary, - prepend_args: vec![server_js.to_string_lossy().to_string()], + binary_path: "npx".into(), + prepend_args: vec!["--yes".into(), "typescript-language-server".into()], }) } } @@ -108,13 +49,13 @@ impl TypeScriptLanguageServerCandidate { #[cfg(feature = "local_fs")] impl LanguageServerCandidate for TypeScriptLanguageServerCandidate { async fn should_suggest_for_repo(&self, path: &Path, _executor: &CommandBuilder) -> bool { - // Check for common JavaScript/TypeScript project indicators path.join("package.json").exists() || path.join("tsconfig.json").exists() || path.join("jsconfig.json").exists() } async fn is_installed_in_data_dir(&self, executor: &CommandBuilder) -> bool { + // npx is our "installed" state — if npx is available, we can run the server Self::find_installed_binary_config(executor.path_env_var()) .await .is_some() @@ -132,74 +73,23 @@ impl LanguageServerCandidate for TypeScriptLanguageServerCandidate { async fn install( &self, - metadata: LanguageServerMetadata, + _metadata: LanguageServerMetadata, executor: &CommandBuilder, ) -> anyhow::Result<()> { - log::info!( - "Installing typescript-language-server version {}", - metadata.version - ); - - let install_dir = galaxy_core::paths::data_dir().join("typescript-language-server"); - - // Create the installation directory - async_fs::create_dir_all(&install_dir) + // With npx, there's nothing to install — npx downloads on first use. + // Just verify that npx is available. + let output = executor + .command("npx") + .arg("--version") + .output() .await - .context("Failed to create typescript-language-server installation directory")?; - - // First, check if system node is available and meets requirements - let use_system_node = match executor.path_env_var() { - Some(path) => node_runtime::detect_system_node(path).await.is_ok(), - None => false, - }; - - let custom_node_paths = if use_system_node { - log::info!("Using system Node.js for typescript-language-server installation"); - None - } else { - log::info!("System Node.js not found or too old, installing custom Node.js"); - node_runtime::install_npm(&self.client).await?; - Some(( - node_runtime::node_binary_path()?, - node_runtime::npm_binary_path()?, - )) - }; - - // Install typescript-language-server and typescript using npm - // typescript is a peer dependency required for the language server to work - log::info!( - "Installing typescript-language-server@{} using npm", - metadata.version - ); - - // Build the npm install command: - // - System node: run `npm` directly (it's on PATH) - // - Custom node: run `node ` to avoid relying on shebang resolution - let mut cmd = if let Some((node_path, npm_path)) = &custom_node_paths { - let mut c = executor.command(node_path); - c.arg(npm_path); - c - } else { - executor.command("npm") - }; - - cmd.arg("install") - .arg("--ignore-scripts") - .arg(format!("typescript-language-server@{}", metadata.version)) - .arg("typescript") - .current_dir(&install_dir); - - let output = cmd.output().await.context("Failed to run npm install")?; + .context("npx not found — is Node.js installed?")?; if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - anyhow::bail!( - "Failed to install typescript-language-server via npm: {}", - stderr - ); + anyhow::bail!("npx is not working. Ensure Node.js and npm are installed."); } - log::info!("typescript-language-server installed successfully"); + log::info!("typescript-language-server will run via npx (no pre-install needed)"); Ok(()) } @@ -211,7 +101,7 @@ impl LanguageServerCandidate for TypeScriptLanguageServerCandidate { Ok(LanguageServerMetadata { version, - url: None, // npm packages don't have direct download URLs + url: None, digest: None, }) } diff --git a/scripts/reset-galaxy.sh b/scripts/reset-galaxy.sh new file mode 100755 index 00000000..f295527b --- /dev/null +++ b/scripts/reset-galaxy.sh @@ -0,0 +1,52 @@ +#!/bin/bash +# Resets all Galaxy app state to a fresh install. +# Run this before launching galaxy-oss if you want a clean slate. + +set -e + +echo "Resetting Galaxy to fresh state..." + +# Home config/data +rm -rf ~/.galaxy + +# macOS Application Support (session history, chat history, workspace state) +rm -rf ~/Library/Application\ Support/com.samsung.Galaxy +rm -rf ~/Library/Application\ Support/com.samsung.Galaxy-Stable +rm -rf ~/Library/Application\ Support/com.samsung.GalaxyAI +rm -rf ~/Library/Application\ Support/samsung.galaxy.GalaxyOss +rm -rf ~/Library/Application\ Support/samsung.galaxy.GalaxyStable +rm -rf ~/Library/Application\ Support/samsung.galaxy.GalaxyDev +rm -rf ~/Library/Application\ Support/samsung.galaxy.GalaxyLocal +rm -rf ~/Library/Application\ Support/samsung.galaxy.GalaxyPreview + +# macOS Saved Application State (window restoration) +rm -rf ~/Library/Saved\ Application\ State/com.samsung.Galaxy.savedState +rm -rf ~/Library/Saved\ Application\ State/com.samsung.Galaxy-Stable.savedState +rm -rf ~/Library/Saved\ Application\ State/com.samsung.GalaxyAI.savedState +rm -rf ~/Library/Saved\ Application\ State/samsung.galaxy.GalaxyOss.savedState +rm -rf ~/Library/Saved\ Application\ State/samsung.galaxy.GalaxyStable.savedState +rm -rf ~/Library/Saved\ Application\ State/samsung.galaxy.GalaxyDev.savedState +rm -rf ~/Library/Saved\ Application\ State/samsung.galaxy.GalaxyLocal.savedState + +# macOS Caches +rm -rf ~/Library/Caches/com.samsung.Galaxy +rm -rf ~/Library/Caches/com.samsung.Galaxy-Stable +rm -rf ~/Library/Caches/com.samsung.GalaxyAI +rm -rf ~/Library/Caches/samsung.galaxy.GalaxyOss +rm -rf ~/Library/Caches/samsung.galaxy.GalaxyStable +rm -rf ~/Library/Caches/samsung.galaxy.GalaxyDev +rm -rf ~/Library/Caches/samsung.galaxy.GalaxyLocal + +# Logs +rm -f ~/Library/Logs/galaxy*.log* + +# macOS Preferences +defaults delete com.samsung.Galaxy 2>/dev/null || true +defaults delete com.samsung.Galaxy-Stable 2>/dev/null || true +defaults delete com.samsung.GalaxyAI 2>/dev/null || true +defaults delete samsung.galaxy.GalaxyOss 2>/dev/null || true +defaults delete samsung.galaxy.GalaxyStable 2>/dev/null || true +defaults delete samsung.galaxy.GalaxyDev 2>/dev/null || true +defaults delete samsung.galaxy.GalaxyLocal 2>/dev/null || true + +echo "Done. Galaxy will start fresh on next launch."