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:
co-authored by
Claude Opus 4.6
parent
f37a744692
commit
a75bc99852
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user