Add LSP completion/code actions/rename/signature help infrastructure, fix TypeScript LSP, rebrand app identifiers

- LSP Layer: Add completion, completion_resolve, signature_help, code_action,
  prepare_rename, and rename methods to TextDocumentService and LspServerModel
- Types: Add CompletionItemData, CompletionResult, SignatureHelpResult,
  CodeActionData, PrepareRenameResult, RenameResult, FileEdits
- Feature Flags: Add LspCompletion, LspCodeActions, LspRename, LspSignatureHelp
- Client Capabilities: Declare completion, signature help, rename, and code
  action capabilities so servers advertise these features
- Completion UI: Add completion state machine with debounced triggers, fuzzy
  filtering, positioned overlay menu, and edit application
- TypeScript LSP: Switch to npx for running typescript-language-server (handles
  download/caching automatically, survives node version switches)
- PATH Resolution: Add interactive shell PATH fallback for LSP server discovery
  and spawning (fixes nvm/fnm/volta users)
- App Identity: Rebrand from com.samsung.Galaxy/dev.warp.WarpOss to
  samsung.galaxy.GalaxyOss across bundle IDs, URL schemes, and plists
- Add scripts/reset-galaxy.sh for clean slate testing
- Galaxy status messages and other in-progress work

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ryan Ward
2026-05-18 15:57:47 -05:00
co-authored by Claude Opus 4.6
parent f37a744692
commit a75bc99852
15 changed files with 769 additions and 162 deletions
+5 -5
View File
@@ -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."
+1 -1
View File
@@ -3,7 +3,7 @@
<plist version="1.0">
<dict>
<key>CFBundleIdentifier</key>
<string>com.samsung.GalaxyDockTilePlugin</string>
<string>samsung.galaxy.GalaxyDockTilePlugin</string>
<key>CFBundleExecutable</key>
<string>WarpDockTilePlugin</string>
<key>CFBundleName</key>
+3 -2
View File
@@ -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))
+48 -1
View File
@@ -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...";
+1 -1
View File
@@ -46,7 +46,7 @@ embed_plist::embed_info_plist_bytes!(r#"
<key>CFBundleExecutable</key>
<string>galaxy-local</string>
<key>CFBundleIdentifier</key>
<string>com.samsung.Galaxy-Local</string>
<string>samsung.galaxy.GalaxyLocal</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
+1 -1
View File
@@ -43,7 +43,7 @@ embed_plist::embed_info_plist_bytes!(r#"
<key>CFBundleExecutable</key>
<string>galaxy-oss</string>
<key>CFBundleIdentifier</key>
<string>com.samsung.Galaxy</string>
<string>samsung.galaxy.GalaxyOss</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
+513
View File
@@ -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<CompletionItemData>,
filtered_indices: Vec<usize>,
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<Self>) {
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<Self>,
) {
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<Self>,
) {
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<Option<CompletionResult>>,
trigger_offset: CharOffset,
ctx: &mut ViewContext<Self>,
) {
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<usize> = (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<Self>,
) -> 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<Self>) -> 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<Box<dyn Element>> {
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<OffsetPositioning> {
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<dyn Element> {
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()
}
+10
View File
@@ -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<CharOffset>,
ctx: &AppContext,
) -> Option<String> {
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,
+28 -1
View File
@@ -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<Decoration>,
/// View for the find references feature.
find_references_view: Option<ViewHandle<FindReferencesView>>,
/// 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<CharOffset>,
}
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<Self>) -> 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),
+2
View File
@@ -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"))]
+7 -7
View File
@@ -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",
}
}
}
+33
View File
@@ -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<String> {
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)
}
}
+39 -7
View File
@@ -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<ResolvedLspCommand> {
// 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
}
@@ -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<http_client::Client>) -> 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<CustomBinaryConfig> {
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 <npm_path>` 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,
})
}
+52
View File
@@ -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."