mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 17:22:55 -10:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58afe0b154 | ||
|
|
b70c01d1d6 | ||
|
|
653ebd578a | ||
|
|
6e6baf4284 | ||
|
|
de09ee0678 | ||
|
|
292dc3cade |
No files matched your search
@@ -30,6 +30,21 @@ pub fn search_path() -> OsString {
|
||||
std::env::join_paths(dirs).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Ruby code printing where `gem install` puts executables: the Ruby's own `Gem.bindir`, which
|
||||
/// Homebrew's Ruby keeps off `PATH`, and the `--user-install` one.
|
||||
pub const RUBY_GEM_BIN_SCRIPT: &str = "puts Gem.bindir, File.join(Gem.user_dir, 'bin')";
|
||||
|
||||
/// The directories [`RUBY_GEM_BIN_SCRIPT`] printed that exist.
|
||||
pub fn gem_bin_dirs(output: &str) -> Vec<PathBuf> {
|
||||
let mut dirs: Vec<PathBuf> = Vec::new();
|
||||
for dir in output.lines().map(str::trim).map(PathBuf::from) {
|
||||
if dir.is_absolute() && dir.is_dir() && !dirs.contains(&dir) {
|
||||
dirs.push(dir);
|
||||
}
|
||||
}
|
||||
dirs
|
||||
}
|
||||
|
||||
/// Resolves a command on the shared path, or an explicit path relative to `root`.
|
||||
pub fn find_command(command: &str, search: &OsStr, root: &Path) -> Option<PathBuf> {
|
||||
let path = Path::new(command);
|
||||
|
||||
@@ -24,7 +24,11 @@ fn tools(host: &Host, window: &str) -> IdeResult<Tools> {
|
||||
.collect();
|
||||
// Godot is usually an app bundle / Flatpak outside the search path.
|
||||
let found = ide_sdk::godot::find(None).map(|p| (ide_sdk::godot::COMMAND.to_owned(), p));
|
||||
Ok(Tools::new(&data).with_custom_paths(custom).with_found_paths(found.into_iter().collect()))
|
||||
let work_dir = (!root.as_os_str().is_empty()).then_some(root);
|
||||
Ok(Tools::new(&data)
|
||||
.with_custom_paths(custom)
|
||||
.with_found_paths(found.into_iter().collect())
|
||||
.with_work_dir(work_dir))
|
||||
}
|
||||
|
||||
/// The language definitions plus the servers defined in the window's settings.
|
||||
|
||||
@@ -4,7 +4,7 @@ use ide_core::CancellationToken;
|
||||
use ide_core::tool_path::search_path;
|
||||
use ide_jsonrpc::{Handler, Peer};
|
||||
use ide_language::LanguageServerConfig;
|
||||
use std::ffi::OsString;
|
||||
use std::ffi::{OsStr, OsString};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -100,7 +100,8 @@ impl Launcher for ProcessLauncher {
|
||||
// A plugin's server: the approved program, unchanged since.
|
||||
Some(pin) => Some(pin.verify().map_err(LaunchError::Failed)?.to_path_buf()),
|
||||
None => ide_core::tool_path::find_command(&config.command, &search, root)
|
||||
.and_then(|b| resolve_rustup_proxy(&config.command, b)),
|
||||
.and_then(|b| resolve_rustup_proxy(&config.command, b))
|
||||
.and_then(|b| reject_dead_shim(b, &search, root)),
|
||||
};
|
||||
let mut env: Vec<(String, String)> = vec![("PATH".into(), search.to_string_lossy().into_owned())];
|
||||
env.extend(config.env.iter().filter(|(k, _)| *k != "PATH").map(|(k, v)| (k.clone(), v.clone())));
|
||||
@@ -136,6 +137,60 @@ fn server_search_path(config: &LanguageServerConfig) -> OsString {
|
||||
config.env.get("PATH").map(OsString::from).unwrap_or_else(search_path)
|
||||
}
|
||||
|
||||
/// What a version manager's shim says when the active version has no such tool.
|
||||
const DEAD_SHIM_MESSAGES: &[&str] =
|
||||
&["no version is set for", "no preset version", "command not found", "exists in these", "is not installed"];
|
||||
|
||||
/// `~/.local/share/mise/shims/ruby-lsp` exists for every version of the runtime that ever
|
||||
/// had the tool, but exits with an error when the version active in `root` lacks it. A
|
||||
/// server that dies like that on every start is missing (an install hint), not crashing.
|
||||
/// Anything but a clear "not installed" answer from the shim counts as working.
|
||||
fn reject_dead_shim(binary: PathBuf, search: &OsStr, root: &Path) -> Option<PathBuf> {
|
||||
if !binary.parent().is_some_and(|d| d.file_name().is_some_and(|n| n == "shims")) {
|
||||
return Some(binary);
|
||||
}
|
||||
let Ok(mut child) = ide_core::process::prepare(&mut std::process::Command::new(&binary))
|
||||
.arg("--version")
|
||||
.current_dir(root)
|
||||
.env("PATH", search)
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
else {
|
||||
return Some(binary);
|
||||
};
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||
let status = loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => break status,
|
||||
Ok(None) if std::time::Instant::now() < deadline => {
|
||||
std::thread::sleep(std::time::Duration::from_millis(20))
|
||||
}
|
||||
// Slow or unwaitable: let the real start decide.
|
||||
_ => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Some(binary);
|
||||
}
|
||||
}
|
||||
};
|
||||
if status.success() {
|
||||
return Some(binary);
|
||||
}
|
||||
let mut stderr = String::new();
|
||||
if let Some(mut pipe) = child.stderr.take() {
|
||||
let _ = std::io::Read::read_to_string(&mut pipe, &mut stderr);
|
||||
}
|
||||
let stderr = stderr.to_lowercase();
|
||||
if DEAD_SHIM_MESSAGES.iter().any(|m| stderr.contains(m)) {
|
||||
tracing::info!("{} is a version-manager shim without the tool: {}", binary.display(), stderr.trim());
|
||||
None
|
||||
} else {
|
||||
Some(binary)
|
||||
}
|
||||
}
|
||||
|
||||
/// `~/.cargo/bin/<tool>` is usually a rustup proxy that fails when the active
|
||||
/// toolchain lacks the component (common for rust-analyzer). In that case use
|
||||
/// the newest installed toolchain that ships the binary; with none, the tool is
|
||||
@@ -219,6 +274,34 @@ mod tests {
|
||||
std::fs::remove_dir_all(&tmp).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn shims_without_the_tool_are_missing() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let shims = tmp.path().join("mise/shims");
|
||||
std::fs::create_dir_all(&shims).unwrap();
|
||||
let script = |name: &str, body: &str| {
|
||||
let p = shims.join(name);
|
||||
std::fs::write(&p, format!("#!/bin/sh\n{body}\n")).unwrap();
|
||||
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
p
|
||||
};
|
||||
let search = OsString::from("/usr/bin:/bin");
|
||||
let dead = script("ruby-lsp", "echo 'mise ERROR No version is set for shim: ruby-lsp' >&2; exit 1");
|
||||
assert_eq!(reject_dead_shim(dead, &search, tmp.path()), None);
|
||||
let works = script("good", "echo 1.0");
|
||||
assert_eq!(reject_dead_shim(works.clone(), &search, tmp.path()), Some(works));
|
||||
// A tool with no `--version` that fails for its own reasons still starts.
|
||||
let odd = script("odd", "echo 'usage: odd' >&2; exit 2");
|
||||
assert_eq!(reject_dead_shim(odd.clone(), &search, tmp.path()), Some(odd));
|
||||
// Only shims are probed.
|
||||
let plain = tmp.path().join("ruby-lsp");
|
||||
std::fs::write(&plain, "#!/bin/sh\necho 'No version is set for' >&2; exit 1\n").unwrap();
|
||||
std::fs::set_permissions(&plain, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
assert_eq!(reject_dead_shim(plain.clone(), &search, tmp.path()), Some(plain));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marked_root_needs_a_marker() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -71,6 +71,62 @@ pub fn context_xml(doc_base: &Path, reload: bool) -> String {
|
||||
format!("<Context docBase=\"{}\" reloadable=\"{reload}\" />\n", escape(&doc_base.to_string_lossy()))
|
||||
}
|
||||
|
||||
/// The web app's own `META-INF/context.xml`, from an exploded directory or a WAR.
|
||||
fn app_context_xml(doc_base: &Path) -> Option<String> {
|
||||
if doc_base.is_dir() {
|
||||
return fs::read_to_string(doc_base.join("META-INF/context.xml")).ok();
|
||||
}
|
||||
let mut zip = zip::ZipArchive::new(fs::File::open(doc_base).ok()?).ok()?;
|
||||
let mut text = String::new();
|
||||
zip.by_name("META-INF/context.xml").ok()?.read_to_string(&mut text).ok()?;
|
||||
Some(text)
|
||||
}
|
||||
|
||||
/// The context descriptor for `conf/Catalina/localhost`. Tomcat ignores the app's
|
||||
/// `META-INF/context.xml` once a descriptor exists there, so its content (`<Resource>`,
|
||||
/// `<Environment>`, `<Valve>`…) is carried over, with `docBase` and `reloadable` set by the IDE.
|
||||
pub fn deployed_context_xml(doc_base: &Path, reload: bool) -> String {
|
||||
let Some(app) = app_context_xml(doc_base) else { return context_xml(doc_base, reload) };
|
||||
merge_context_xml(&app, doc_base, reload).unwrap_or_else(|e| {
|
||||
tracing::warn!("ignoring the web app's META-INF/context.xml: {e}");
|
||||
context_xml(doc_base, reload)
|
||||
})
|
||||
}
|
||||
|
||||
fn merge_context_xml(app: &str, doc_base: &Path, reload: bool) -> IdeResult<String> {
|
||||
let mut reader = Reader::from_str(app);
|
||||
let mut writer = Writer::new(Vec::new());
|
||||
let mut root = false;
|
||||
loop {
|
||||
let event = reader.read_event().map_err(invalid)?;
|
||||
let event = match event {
|
||||
Event::Eof => break,
|
||||
Event::Start(ref tag) | Event::Empty(ref tag) if !root => {
|
||||
if tag.name().as_ref() != "Context" {
|
||||
return Err(invalid("the root element is not <Context>"));
|
||||
}
|
||||
root = true;
|
||||
let mut new = BytesStart::new("Context");
|
||||
for a in tag.attributes() {
|
||||
let a = a.map_err(invalid)?;
|
||||
if !matches!(a.key.as_ref(), "docBase" | "reloadable" | "path") {
|
||||
new.push_attribute(a);
|
||||
}
|
||||
}
|
||||
new.push_attribute(("docBase", doc_base.to_string_lossy().as_ref()));
|
||||
new.push_attribute(("reloadable", if reload { "true" } else { "false" }));
|
||||
if matches!(event, Event::Empty(_)) { Event::Empty(new) } else { Event::Start(new) }
|
||||
}
|
||||
e => e,
|
||||
};
|
||||
writer.write_event(event).map_err(invalid)?;
|
||||
}
|
||||
if !root {
|
||||
return Err(invalid("no <Context> element"));
|
||||
}
|
||||
String::from_utf8(writer.into_inner()).map_err(invalid)
|
||||
}
|
||||
|
||||
pub fn patch_server_xml(xml: &str, http: u16, shutdown: u16, secret: &str) -> IdeResult<String> {
|
||||
let mut reader = Reader::from_str(xml);
|
||||
let mut writer = Writer::new(Vec::new());
|
||||
@@ -442,7 +498,7 @@ pub fn prepare(root: &Path, cache: &Path, config: &mut RunConfig) -> IdeResult<(
|
||||
let server = patch_server_xml(&fs::read_to_string(home.join("conf/server.xml"))?, port, shutdown, &secret)?;
|
||||
fs::write(base.join("conf/server.xml"), server)?;
|
||||
let debug = config.extras.params.get("tomcatDebug").is_some_and(|v| v == "true");
|
||||
fs::write(contexts.join(format!("{context}.xml")), context_xml(&doc, !debug))?;
|
||||
fs::write(contexts.join(format!("{context}.xml")), deployed_context_xml(&doc, !debug))?;
|
||||
config.env.insert("CATALINA_HOME".into(), home.to_string_lossy().into_owned());
|
||||
config.env.insert("CATALINA_BASE".into(), base.to_string_lossy().into_owned());
|
||||
if let Some(jre) = config.extras.params.get("jre").filter(|s| !s.trim().is_empty()) {
|
||||
@@ -501,6 +557,48 @@ mod tests {
|
||||
}
|
||||
zip.finish().unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn keeps_the_apps_own_context_descriptor() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let app = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Context path=\"/old\" docBase=\"old\" reloadable=\"false\" antiResourceLocking=\"true\">\n <!-- JNDI -->\n <Resource name=\"jdbc/x\" auth=\"Container\" type=\"javax.sql.DataSource\" url=\"jdbc:h2:mem:a;b=&c\"/>\n <Environment name=\"mode\" value=\"dev\" type=\"java.lang.String\"/>\n</Context>\n";
|
||||
let dir = tmp.path().join("web app");
|
||||
fs::create_dir_all(dir.join("META-INF")).unwrap();
|
||||
fs::write(dir.join("META-INF/context.xml"), app).unwrap();
|
||||
let war = tmp.path().join("app.war");
|
||||
archive(&war, &[("META-INF/context.xml", app), ("index.jsp", "")]);
|
||||
for doc in [&dir, &war] {
|
||||
let xml = deployed_context_xml(doc, true);
|
||||
let doc_base = escape(&doc.to_string_lossy());
|
||||
assert!(
|
||||
xml.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Context antiResourceLocking=\"true\""),
|
||||
"{xml}"
|
||||
);
|
||||
assert!(xml.contains(&format!("docBase=\"{doc_base}\" reloadable=\"true\">")), "{xml}");
|
||||
assert!(!xml.contains("/old") && !xml.contains("\"old\"") && !xml.contains("\"false\""), "{xml}");
|
||||
assert!(xml.contains("<!-- JNDI -->"));
|
||||
assert!(xml.contains("<Resource name=\"jdbc/x\" auth=\"Container\" type=\"javax.sql.DataSource\" url=\"jdbc:h2:mem:a;b=&c\"/>"));
|
||||
assert!(xml.contains("<Environment name=\"mode\""));
|
||||
assert!(xml.trim_end().ends_with("</Context>"));
|
||||
}
|
||||
|
||||
fs::write(dir.join("META-INF/context.xml"), "<Context/>").unwrap();
|
||||
let doc_base = escape(&dir.to_string_lossy());
|
||||
assert_eq!(
|
||||
deployed_context_xml(&dir, false),
|
||||
format!("<Context docBase=\"{doc_base}\" reloadable=\"false\"/>")
|
||||
);
|
||||
// Unreadable or not a context: the IDE's own descriptor, as before.
|
||||
for broken in ["<Context><Resource></Context>", "<Server/>", ""] {
|
||||
fs::write(dir.join("META-INF/context.xml"), broken).unwrap();
|
||||
assert_eq!(deployed_context_xml(&dir, false), context_xml(&dir, false), "{broken}");
|
||||
}
|
||||
let plain = tmp.path().join("plain");
|
||||
fs::create_dir_all(&plain).unwrap();
|
||||
assert_eq!(deployed_context_xml(&plain, true), context_xml(&plain, true));
|
||||
let bare = tmp.path().join("bare.war");
|
||||
archive(&bare, &[("index.jsp", "")]);
|
||||
assert_eq!(deployed_context_xml(&bare, true), context_xml(&bare, true));
|
||||
}
|
||||
fn config() -> RunConfig {
|
||||
RunConfig {
|
||||
id: "local:demo".into(),
|
||||
|
||||
@@ -691,6 +691,13 @@ pub fn configure_server(cfg: &mut LanguageServerConfig, root: &Path, server_root
|
||||
path_dirs.push(dir);
|
||||
}
|
||||
|
||||
// Gems install to the Ruby's bindir, which is not always on `PATH` (Homebrew).
|
||||
if name == "ruby-lsp"
|
||||
&& let Some(ruby) = ruby_interpreter(root, s)
|
||||
{
|
||||
path_dirs.extend(crate::ruby::gem_bin_dirs(&ruby, server_root));
|
||||
}
|
||||
|
||||
if !path_dirs.is_empty() {
|
||||
let dirs: Vec<&Path> = path_dirs.iter().map(PathBuf::as_path).collect();
|
||||
cfg.env.insert("PATH".into(), path_with(&dirs));
|
||||
@@ -1301,3 +1308,29 @@ mod maven_linkage_tests {
|
||||
assert!(serde_json::to_value(cfg.initialization_options).unwrap().get("projectConfigurations").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod ruby_lsp_tests {
|
||||
use super::*;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
#[test]
|
||||
fn ruby_lsp_is_found_in_the_rubys_gem_bindir_when_that_is_off_path() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let bindir = tmp.path().join("gems/bin");
|
||||
std::fs::create_dir_all(&bindir).unwrap();
|
||||
// Homebrew's Ruby: `Gem.bindir` is not on `PATH`.
|
||||
let ruby = tmp.path().join("ruby");
|
||||
std::fs::write(&ruby, format!("#!/bin/sh\necho '{}'\necho '/nonexistent/user/bin'\n", bindir.display()))
|
||||
.unwrap();
|
||||
std::fs::set_permissions(&ruby, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let mut s = Settings::default();
|
||||
s.languages.ruby.interpreter = ruby.to_string_lossy().into_owned();
|
||||
let mut cfg = custom_server("ruby-lsp", &LanguageServerSettings::default());
|
||||
configure_server(&mut cfg, tmp.path(), tmp.path(), &s);
|
||||
let path = cfg.env.get("PATH").expect("PATH set");
|
||||
let dirs: Vec<PathBuf> = std::env::split_paths(path).collect();
|
||||
assert!(dirs.contains(&bindir), "{dirs:?}");
|
||||
assert!(!dirs.iter().any(|d| d.starts_with("/nonexistent")), "{dirs:?}");
|
||||
}
|
||||
}
|
||||
@@ -140,6 +140,14 @@ pub fn bin_dir_of(ruby: &Path) -> Option<PathBuf> {
|
||||
ruby.parent().map(Path::to_path_buf)
|
||||
}
|
||||
|
||||
/// Where `ruby` installs gem executables (`Gem.bindir`, the `--user-install` one) that exist, asked
|
||||
/// of the Ruby that version managers select in `root`: Homebrew's keeps its bindir off `PATH`.
|
||||
pub fn gem_bin_dirs(ruby: &Path, root: &Path) -> Vec<PathBuf> {
|
||||
query_in(ruby, &["-e", ide_core::tool_path::RUBY_GEM_BIN_SCRIPT], Some(root), &[], QUERY_TIMEOUT)
|
||||
.map(|out| ide_core::tool_path::gem_bin_dirs(&out))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
type NamedRoots = Vec<(String, PathBuf)>;
|
||||
|
||||
/// Standard-library / site directories (`ruby/3.3.0`) and the gems Bundler resolves
|
||||
|
||||
@@ -154,7 +154,11 @@ fn managed_bin_dir(root: &Path, spec: &ToolSpec, platform: Platform) -> Option<P
|
||||
Method::PipVenv { .. } => {
|
||||
Some(root.join(spec.name).join("venv").join(if platform.os == Os::Windows { "Scripts" } else { "bin" }))
|
||||
}
|
||||
Method::Bundled { .. } | Method::Rustup { .. } | Method::PipUser { .. } | Method::PipTarget { .. } => None,
|
||||
Method::Bundled { .. }
|
||||
| Method::Rustup { .. }
|
||||
| Method::PipUser { .. }
|
||||
| Method::PipTarget { .. }
|
||||
| Method::GemActive { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -429,6 +433,9 @@ pub struct Tools {
|
||||
/// Executables found outside the search path (app bundles), by command.
|
||||
found: BTreeMap<String, PathBuf>,
|
||||
version_timeout: Duration,
|
||||
/// The project the tools are used for: tools that install into "the active runtime" (gems) run here,
|
||||
/// so a version manager picks the project's Ruby.
|
||||
work_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Tools {
|
||||
@@ -450,6 +457,7 @@ impl Tools {
|
||||
custom: BTreeMap::new(),
|
||||
found: BTreeMap::new(),
|
||||
version_timeout: Duration::from_secs(4),
|
||||
work_dir: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -465,10 +473,25 @@ impl Tools {
|
||||
self
|
||||
}
|
||||
|
||||
/// Runs per-project installs (see [`registry::Method::GemActive`]) in `dir`.
|
||||
pub fn with_work_dir(mut self, dir: Option<PathBuf>) -> Self {
|
||||
self.work_dir = dir.filter(|d| d.is_dir());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn root(&self) -> &Path {
|
||||
&self.root
|
||||
}
|
||||
|
||||
/// [`Tools::command`] in the project directory.
|
||||
fn command_in_project(&self, program: impl AsRef<std::ffi::OsStr>) -> Command {
|
||||
let mut cmd = self.command(program);
|
||||
if let Some(dir) = &self.work_dir {
|
||||
cmd.current_dir(dir);
|
||||
}
|
||||
cmd
|
||||
}
|
||||
|
||||
fn which(&self, cmd: &str) -> Option<PathBuf> {
|
||||
ide_core::tool_path::find_command(cmd, &self.system_path, &self.root)
|
||||
}
|
||||
@@ -614,7 +637,9 @@ impl Tools {
|
||||
"npm or pnpm is required: install Node.js (https://nodejs.org)",
|
||||
),
|
||||
Method::Go { .. } => need(self.which("go").is_some(), "Go is required (https://go.dev/dl)"),
|
||||
Method::Gem { .. } => need(self.which("gem").is_some(), "Ruby is required (https://www.ruby-lang.org)"),
|
||||
Method::Gem { .. } | Method::GemActive { .. } => {
|
||||
need(self.which("gem").is_some(), "Ruby is required (https://www.ruby-lang.org)")
|
||||
}
|
||||
Method::DotnetTool { .. } => {
|
||||
need(self.which("dotnet").is_some(), ".NET SDK is required (https://dotnet.microsoft.com/download)")
|
||||
}
|
||||
@@ -772,6 +797,22 @@ impl Tools {
|
||||
_ => (ToolStatus::Missing, None, None),
|
||||
}
|
||||
}
|
||||
Method::GemActive { gem } => {
|
||||
// Installed for the Ruby active in the project, not merely a shim of some other version's.
|
||||
let Some(gem_cmd) = self.which("gem") else { return (ToolStatus::Missing, None, None) };
|
||||
let mut c = self.command_in_project(&gem_cmd);
|
||||
c.args(["list", "--exact", gem]);
|
||||
match run_quiet(c, Duration::from_secs(10)) {
|
||||
Some((true, out)) if out.contains(&format!("{gem} (")) => {
|
||||
// Homebrew's Ruby keeps its gem bindir off PATH: ask the Ruby where it is.
|
||||
match self.gem_executable(main) {
|
||||
Some(path) => (ToolStatus::OnPath, Some(path), parse_version(&out)),
|
||||
None => (ToolStatus::Missing, None, None),
|
||||
}
|
||||
}
|
||||
_ => (ToolStatus::Missing, None, None),
|
||||
}
|
||||
}
|
||||
_ => match self.which(main) {
|
||||
Some(path) => {
|
||||
let version = self.query_version(spec, &path);
|
||||
@@ -868,6 +909,7 @@ impl Tools {
|
||||
Method::Npm { packages } => self.install_npm(packages, cancel, emit)?,
|
||||
Method::Go { module } => self.install_go(spec, module, cancel, emit)?,
|
||||
Method::Gem { gem } => self.install_gem(spec, gem, cancel, emit)?,
|
||||
Method::GemActive { gem } => self.install_gem_active(gem, cancel, emit)?,
|
||||
Method::DotnetTool { package } => self.install_dotnet_tool(spec, package, cancel, emit)?,
|
||||
Method::PipVenv { package } => self.install_venv(spec, package, cancel, emit)?,
|
||||
Method::PipUser { package, .. } => self.install_pip_user(package, cancel, emit)?,
|
||||
@@ -953,6 +995,41 @@ impl Tools {
|
||||
self.write_manifest(spec.name, &Manifest { version, source: Some(module.to_string()) })
|
||||
}
|
||||
|
||||
/// Where the project's Ruby installs gem executables (`Gem.bindir`, `Gem.user_dir`'s `bin`).
|
||||
fn gem_bin_dirs(&self) -> Vec<PathBuf> {
|
||||
let Some(ruby) = self.which("ruby") else { return Vec::new() };
|
||||
let mut c = self.command_in_project(ruby);
|
||||
c.args(["-e", ide_core::tool_path::RUBY_GEM_BIN_SCRIPT]);
|
||||
match run_quiet(c, Duration::from_secs(10)) {
|
||||
Some((true, out)) => ide_core::tool_path::gem_bin_dirs(&out),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// An installed gem's executable: on the search path, else in one of the Ruby's gem bin directories.
|
||||
fn gem_executable(&self, cmd: &str) -> Option<PathBuf> {
|
||||
self.which(cmd).or_else(|| self.gem_bin_dirs().iter().find_map(|d| self.exe_in(d, cmd)))
|
||||
}
|
||||
|
||||
/// `gem install` into the active Ruby, then makes version-manager shims see the new executable.
|
||||
fn install_gem_active(&self, gem: &str, cancel: &CancellationToken, emit: Emit) -> IdeResult<()> {
|
||||
let mut c = self.command_in_project(self.which("gem").expect("checked"));
|
||||
c.args(["install", "--no-document", gem]);
|
||||
run_streamed(c, cancel, emit)?;
|
||||
// mise / asdf / rbenv list executables in shims written at install time; a gem installed
|
||||
// afterwards has none until they are regenerated. Best effort: the tool may not be installed.
|
||||
for (manager, args) in
|
||||
[("mise", &["reshim"][..]), ("asdf", &["reshim", "ruby"][..]), ("rbenv", &["rehash"][..])]
|
||||
{
|
||||
if let Some(path) = self.which(manager) {
|
||||
let mut c = self.command_in_project(path);
|
||||
c.args(args);
|
||||
let _ = run_quiet(c, Duration::from_secs(30));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn install_gem(&self, spec: &ToolSpec, gem: &str, cancel: &CancellationToken, emit: Emit) -> IdeResult<()> {
|
||||
let dir = self.root.join(spec.name);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
@@ -1310,6 +1387,18 @@ impl Tools {
|
||||
run_streamed(c, &cancel, &quiet)?;
|
||||
}
|
||||
}
|
||||
Method::GemActive { gem } => {
|
||||
if let Some(gem_cmd) = self.which("gem") {
|
||||
let mut c = self.command_in_project(&gem_cmd);
|
||||
c.args(["uninstall", "--all", "--executables", "--ignore-dependencies", gem]);
|
||||
run_streamed(c, &cancel, &quiet)?;
|
||||
}
|
||||
// A copy an older version installed under the tools directory.
|
||||
let dir = self.root.join(spec.name);
|
||||
if dir.exists() {
|
||||
std::fs::remove_dir_all(&dir)?;
|
||||
}
|
||||
}
|
||||
Method::Go { .. }
|
||||
| Method::Gem { .. }
|
||||
| Method::DotnetTool { .. }
|
||||
|
||||
@@ -49,6 +49,11 @@ pub enum Method {
|
||||
PipTarget { package: &'static str, module: &'static str },
|
||||
/// `gem install` into `<tools>/<name>/gems`, launched through `GEM_HOME` wrappers in `bin/`.
|
||||
Gem { gem: &'static str },
|
||||
/// `gem install <gem>` into the active Ruby (the project's, installed from a project window),
|
||||
/// launched through that Ruby's own executables. For tools that re-launch themselves under
|
||||
/// Bundler (ruby-lsp), which breaks in a gem home under a path with spaces
|
||||
/// (`~/Library/Application Support`: Bundler `exec`s a lone path string, and Ruby splits it).
|
||||
GemActive { gem: &'static str },
|
||||
/// `dotnet tool install --tool-path <tools>/<name>/tool <package>`, launched
|
||||
/// through `DOTNET_ROOT` wrappers in `bin/`.
|
||||
DotnetTool { package: &'static str },
|
||||
@@ -64,6 +69,7 @@ impl Method {
|
||||
Method::Npm { .. } => "npm",
|
||||
Method::Go { .. } => "go install",
|
||||
Method::Gem { .. } => "gem",
|
||||
Method::GemActive { .. } => "gem (project Ruby)",
|
||||
Method::DotnetTool { .. } => "dotnet tool",
|
||||
Method::PipVenv { .. } => "pip (virtualenv)",
|
||||
Method::PipUser { .. } => "pip --user",
|
||||
@@ -940,7 +946,7 @@ pub static TOOLS: &[ToolSpec] = &[
|
||||
display: "Ruby LSP (Shopify)",
|
||||
kind: LanguageServer,
|
||||
commands: &["ruby-lsp"],
|
||||
method: Method::Gem { gem: "ruby-lsp" },
|
||||
method: Method::GemActive { gem: "ruby-lsp" },
|
||||
fallback: None,
|
||||
version: V,
|
||||
homepage: "https://shopify.github.io/ruby-lsp",
|
||||
|
||||
@@ -590,6 +590,51 @@ printf '#!/bin/sh\necho "$GEM_HOME"\n' > "$bin/solargraph"; /bin/chmod +x "$bin/
|
||||
assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), env.root.join("solargraph/gems").to_string_lossy());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn ruby_lsp_installs_into_the_project_ruby_not_a_private_gem_home() {
|
||||
let env = Env::new();
|
||||
let project = env._tmp.path().join("project");
|
||||
fs::create_dir_all(&project).unwrap();
|
||||
let marker = env._tmp.path().join("installed");
|
||||
// Homebrew's Ruby: the gem bindir is not on the search path; only the Ruby knows it.
|
||||
let bindir = env._tmp.path().join("gems/bin");
|
||||
fs::create_dir_all(&bindir).unwrap();
|
||||
env.script(&env.sys, "ruby", &format!("echo '{}'", bindir.display()));
|
||||
// `gem list --exact ruby-lsp` knows the gem once `gem install ruby-lsp` ran, in the cwd it was given.
|
||||
env.script(
|
||||
&env.sys,
|
||||
"gem",
|
||||
&format!(
|
||||
r#"case "$1" in
|
||||
install) echo "$PWD $@" > '{m}'; printf '#!/bin/sh\n' > '{b}/ruby-lsp'; /bin/chmod +x '{b}/ruby-lsp' ;;
|
||||
list) [ -f '{m}' ] && echo "ruby-lsp (0.26.11)" ;;
|
||||
uninstall) /bin/rm -f '{m}' ;;
|
||||
esac"#,
|
||||
m = marker.display(),
|
||||
b = bindir.display()
|
||||
),
|
||||
);
|
||||
let tools = env.tools().with_work_dir(Some(project.clone()));
|
||||
assert_eq!(find(&tools.list(&langs()), "ruby-lsp").status, ToolStatus::Missing);
|
||||
let info = tools.install("ruby-lsp", &langs(), &CancellationToken::new(), &|_| {}).unwrap();
|
||||
assert_eq!(info.status, ToolStatus::OnPath);
|
||||
assert_eq!(info.version.as_deref(), Some("0.26.11"));
|
||||
// Found in the Ruby's own bindir, not reported as the `gem` command.
|
||||
assert_eq!(info.path.as_deref(), Some(bindir.join("ruby-lsp").as_path()));
|
||||
// Run in the project (so a version manager picks its Ruby), into the Ruby's own gem home.
|
||||
let log = fs::read_to_string(&marker).unwrap();
|
||||
assert!(log.starts_with(&*project.canonicalize().unwrap().to_string_lossy()), "{log}");
|
||||
assert!(log.contains("install --no-document ruby-lsp") && !log.contains("--install-dir"), "{log}");
|
||||
// No private gem home, no wrapper directory on the search path.
|
||||
assert!(!env.root.join("ruby-lsp").exists());
|
||||
// Uninstalling removes the gem, and an older managed copy.
|
||||
fs::create_dir_all(env.root.join("ruby-lsp/gems")).unwrap();
|
||||
let info = tools.uninstall("ruby-lsp", &langs()).unwrap();
|
||||
assert_eq!(info.status, ToolStatus::Missing);
|
||||
assert!(!env.root.join("ruby-lsp").exists());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn dotnet_tool_install_into_tool_path() {
|
||||
|
||||
@@ -537,9 +537,15 @@ impl Documents {
|
||||
pub fn edit(&self, doc: DocId, base_version: u32, changes: ChangeSet) -> IdeResult<u32> {
|
||||
self.with_doc(doc, |st| {
|
||||
if st.read_only {
|
||||
tracing::warn!("edit rejected: {} is read-only", st.path.display());
|
||||
return Err(read_only_error(&st.path));
|
||||
}
|
||||
let (applied, edits) = st.doc.apply_detailed(base_version, &changes)?;
|
||||
let (applied, edits) = st.doc.apply_detailed(base_version, &changes).inspect_err(|e| {
|
||||
// A stale version is routine (the UI rebases); anything else makes the editor drop the edit.
|
||||
if e.kind != ErrorKind::OutOfSync {
|
||||
tracing::warn!("edit of {} rejected: {e}", st.path.display());
|
||||
}
|
||||
})?;
|
||||
self.set_dirty(doc, st, true);
|
||||
self.notify_changed(doc, st, &applied, &edits);
|
||||
Ok(st.doc.version())
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Window grouping is independent of a window's project or workspace.
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Platform adapters supply the system's tab preference and active project state.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -117,6 +118,43 @@ impl WindowGroups {
|
||||
}
|
||||
}
|
||||
|
||||
/// One entry of the window session reopened at the next start (`window-groups.json`): a
|
||||
/// window group, or a single project window (one project).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SavedWindows {
|
||||
pub projects: Vec<PathBuf>,
|
||||
/// Index into `projects` of the group's selected tab.
|
||||
pub selected: usize,
|
||||
}
|
||||
|
||||
/// The project windows open now, to reopen at the next start: every group with its selected
|
||||
/// tab, then each window outside a group, in the order of `windows`. `project_of` gives a
|
||||
/// window's project (`None` for the welcome screen or LightEdit). `None` while a grouped
|
||||
/// window has no project yet (a restored member still opening): keep the previous session.
|
||||
pub fn session(
|
||||
groups: &[WindowGroup],
|
||||
windows: &[String],
|
||||
project_of: impl Fn(&str) -> Option<PathBuf>,
|
||||
) -> Option<Vec<SavedWindows>> {
|
||||
let mut saved = Vec::new();
|
||||
for group in groups {
|
||||
let projects: Option<Vec<PathBuf>> = group.members.iter().map(|label| project_of(label)).collect();
|
||||
let selected = group.members.iter().position(|l| l == &group.selected).unwrap_or(0);
|
||||
saved.push(SavedWindows { projects: projects?, selected });
|
||||
}
|
||||
for label in windows {
|
||||
if groups.iter().any(|g| g.members.contains(label)) {
|
||||
continue;
|
||||
}
|
||||
if let Some(project) = project_of(label)
|
||||
&& !saved.iter().any(|s| s.projects.contains(&project))
|
||||
{
|
||||
saved.push(SavedWindows { projects: vec![project], selected: 0 });
|
||||
}
|
||||
}
|
||||
Some(saved)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -186,4 +224,28 @@ mod tests {
|
||||
groups.reorder("a", members(&["d", "c", "b", "a"])).unwrap();
|
||||
assert_eq!(groups.group("a").unwrap().members[0], "d");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_session_keeps_groups_and_every_ungrouped_project_window() {
|
||||
let groups = vec![WindowGroup { members: members(&["project-1", "project-2"]), selected: "project-2".into() }];
|
||||
let windows = members(&["main", "project-1", "project-2", "project-3", "project-4"]);
|
||||
let project_of = |label: &str| match label {
|
||||
"main" => Some(PathBuf::from("/a")),
|
||||
"project-1" => Some(PathBuf::from("/b")),
|
||||
"project-2" => Some(PathBuf::from("/c")),
|
||||
"project-3" => Some(PathBuf::from("/d")),
|
||||
// The welcome screen or LightEdit.
|
||||
_ => None,
|
||||
};
|
||||
assert_eq!(
|
||||
session(&groups, &windows, project_of).unwrap(),
|
||||
[
|
||||
SavedWindows { projects: vec!["/b".into(), "/c".into()], selected: 1 },
|
||||
SavedWindows { projects: vec!["/a".into()], selected: 0 },
|
||||
SavedWindows { projects: vec!["/d".into()], selected: 0 },
|
||||
]
|
||||
);
|
||||
// A grouped window still opening its project: keep the previous session.
|
||||
assert_eq!(session(&groups, &windows, |l| (l != "project-2").then(|| PathBuf::from(l))), None);
|
||||
}
|
||||
}
|
||||
@@ -85,10 +85,8 @@ pub async fn open_project(window: Window, path: PathBuf) -> IdeResult<ProjectInf
|
||||
let info = project_info(&project);
|
||||
let _ = window.set_title(&info.name);
|
||||
window.state::<AppState>().sink.emit(ide_core::IdeEvent::WindowGroupsChanged);
|
||||
let grouped = window.state::<crate::windows::WindowsState>().groups.lock().group(window.label()).is_some();
|
||||
if grouped {
|
||||
crate::window_groups::persist(window.app_handle()).await?;
|
||||
}
|
||||
// Every project window is part of the session reopened at the next start.
|
||||
crate::window_groups::persist(window.app_handle()).await?;
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
|
||||
@@ -433,44 +433,36 @@ fn close_next(app: &AppHandle) {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct SavedGroup {
|
||||
projects: Vec<PathBuf>,
|
||||
selected: usize,
|
||||
/// Window labels in creation order: `main`, then `project-1`, `project-2`, ….
|
||||
fn labels_in_order(app: &AppHandle) -> Vec<String> {
|
||||
let mut labels: Vec<String> = app.webview_windows().into_keys().collect();
|
||||
labels.sort_by_key(|l| (l != "main", l.rsplit('-').next().and_then(|n| n.parse::<u32>().ok()), l.clone()));
|
||||
labels
|
||||
}
|
||||
|
||||
/// Saves the open project windows (groups and single windows) for "Reopen projects on startup".
|
||||
/// Nothing is written while quitting, so the windows open at quit are the ones reopened.
|
||||
pub async fn persist(app: &AppHandle) -> IdeResult<()> {
|
||||
let state = app.state::<WindowsState>();
|
||||
let _write = state.groups_persist.lock().await;
|
||||
let mut previous = state.groups_persist.lock().await;
|
||||
if state.quitting() {
|
||||
return Ok(());
|
||||
}
|
||||
let groups = state.groups.lock().groups().to_vec();
|
||||
let host = app.state::<AppState>();
|
||||
let saved: Vec<_> = groups
|
||||
.iter()
|
||||
.filter_map(|group| {
|
||||
let projects: Vec<_> = group
|
||||
.members
|
||||
.iter()
|
||||
.filter_map(|label| {
|
||||
host.project(label)
|
||||
.ok()
|
||||
.map(|p| p.root.clone())
|
||||
.or_else(|| state.pending(label).and_then(|l| l.project).map(Into::into))
|
||||
})
|
||||
.collect();
|
||||
(projects.len() == group.members.len()).then(|| SavedGroup {
|
||||
projects,
|
||||
selected: group.members.iter().position(|l| l == &group.selected).unwrap_or(0),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
// A restored member may have taken its launch but not finished opening yet.
|
||||
if saved.len() != groups.len() {
|
||||
return Ok(());
|
||||
}
|
||||
let path = host.config_dir.join("window-groups.json");
|
||||
if previous.is_none() {
|
||||
*previous = Some(read_session(path.clone()).await);
|
||||
}
|
||||
let groups = state.groups.lock().groups().to_vec();
|
||||
let windows = labels_in_order(app);
|
||||
let project_of = |label: &str| match host.project(label) {
|
||||
Ok(p) => (!p.light).then(|| p.root.clone()),
|
||||
// A restored window may have taken its launch but not finished opening yet.
|
||||
Err(_) => state.pending(label).and_then(|l| l.project).map(Into::into),
|
||||
};
|
||||
let Some(saved) = ide_workspace::window_groups::session(&groups, &windows, project_of) else {
|
||||
return Ok(());
|
||||
};
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let bytes = serde_json::to_vec(&saved).map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
ide_vfs::write_atomic(&path, &bytes).map(|_| ())
|
||||
@@ -479,6 +471,22 @@ pub async fn persist(app: &AppHandle) -> IdeResult<()> {
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
}
|
||||
|
||||
/// The saved session; empty when there is none or it can't be read.
|
||||
async fn read_session(path: PathBuf) -> Vec<ide_workspace::window_groups::SavedWindows> {
|
||||
let read = tauri::async_runtime::spawn_blocking(move || match std::fs::read(&path) {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).map_err(|e| format!("{}: {e}", path.display())),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
|
||||
Err(e) => Err(format!("{}: {e}", path.display())),
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|r| r);
|
||||
read.unwrap_or_else(|e| {
|
||||
tracing::warn!("cannot read the saved windows: {e}");
|
||||
Vec::new()
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn restore(app: &AppHandle, caller: &str) -> IdeResult<()> {
|
||||
if caller != "main" {
|
||||
return Ok(());
|
||||
@@ -489,41 +497,51 @@ pub async fn restore(app: &AppHandle, caller: &str) -> IdeResult<()> {
|
||||
if !project.settings.effective().general.reopen_last_project || state.groups_restored.swap(true, Ordering::SeqCst) {
|
||||
return Ok(());
|
||||
}
|
||||
let path = host.config_dir.join("window-groups.json");
|
||||
let saved: Vec<SavedGroup> = tauri::async_runtime::spawn_blocking(move || -> IdeResult<_> {
|
||||
match std::fs::read(path) {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).map_err(|e| IdeError::invalid(e.to_string())),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
|
||||
Err(e) => Err(e.into()),
|
||||
let saved = {
|
||||
let mut previous = state.groups_persist.lock().await;
|
||||
if previous.is_none() {
|
||||
*previous = Some(read_session(host.config_dir.join("window-groups.json")).await);
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))??;
|
||||
if let Some(group) = saved.iter().find(|g| g.projects.contains(&project.root)) {
|
||||
previous.clone().unwrap_or_default()
|
||||
};
|
||||
// The caller's own group first (it already shows one of its projects), then every other
|
||||
// group and single window that was open at quit. Missing folders are skipped.
|
||||
let order = saved
|
||||
.iter()
|
||||
.filter(|g| g.projects.contains(&project.root))
|
||||
.chain(saved.iter().filter(|g| !g.projects.contains(&project.root)));
|
||||
for group in order {
|
||||
let mut labels = Vec::new();
|
||||
let mut selected = caller.to_owned();
|
||||
let mut selected = None;
|
||||
for path in &group.projects {
|
||||
let label = if path == &project.root {
|
||||
Some(caller.to_owned())
|
||||
} else if path.is_dir() {
|
||||
let window = match windows::find_project_window(app, path) {
|
||||
Some(w) => w,
|
||||
None => windows::create_restored_window(app, path.clone())?,
|
||||
Some(w) => Ok(w),
|
||||
None => windows::create_restored_window(app, path.clone()),
|
||||
};
|
||||
Some(window.label().to_owned())
|
||||
match window {
|
||||
Ok(w) => Some(w.label().to_owned()),
|
||||
Err(e) => {
|
||||
tracing::warn!("cannot reopen {}: {e}", path.display());
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(label) = label {
|
||||
if group.projects.get(group.selected) == Some(path) {
|
||||
selected = label.clone();
|
||||
selected = Some(label.clone());
|
||||
}
|
||||
labels.push(label);
|
||||
}
|
||||
}
|
||||
if labels.len() > 1 {
|
||||
state.groups.lock().merge(labels, caller)?;
|
||||
select(app, caller, &selected)?;
|
||||
let selected = selected.unwrap_or_else(|| labels[0].clone());
|
||||
state.groups.lock().merge(labels, &selected)?;
|
||||
select(app, &selected, &selected)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -120,7 +120,9 @@ pub fn wsl_launch(path: &Path) -> Option<RemoteLaunch> {
|
||||
pub struct WindowsState {
|
||||
pub groups: Mutex<ide_workspace::window_groups::WindowGroups>,
|
||||
pub groups_restored: AtomicBool,
|
||||
pub groups_persist: tokio::sync::Mutex<()>,
|
||||
/// Serialises writes of the saved session. Holds the previous run's session once read: it is
|
||||
/// read before this run first overwrites the file, so restoring still sees it.
|
||||
pub groups_persist: tokio::sync::Mutex<Option<Vec<ide_workspace::window_groups::SavedWindows>>>,
|
||||
launches: Mutex<HashMap<String, Launch>>,
|
||||
/// Caret positions asked for on the command line, by canonical file path, until a window takes the file.
|
||||
positions: Mutex<HashMap<PathBuf, (u32, u32)>>,
|
||||
|
||||
@@ -62,6 +62,7 @@ import {
|
||||
import { setMethodLines, softWrapActive } from "../cm/prefs";
|
||||
import type { EditorSlot } from "./slot";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { logError } from "@/platform/errorLog";
|
||||
import { WorkspaceHistory } from "./workspaceHistory";
|
||||
|
||||
export type Banner = "binary" | "tooLarge" | "readOnly" | "library" | null;
|
||||
@@ -263,13 +264,15 @@ export class DocModel {
|
||||
},
|
||||
{
|
||||
applyRemote: (cs, grouped) => this.applyRemote(cs, grouped),
|
||||
onDiscarded: () =>
|
||||
onDiscarded: (reason) => {
|
||||
logError(`document ${this.path}: unsaved edits replaced by the file's content`, reason);
|
||||
manager.notify({
|
||||
level: "warning",
|
||||
title: "Editor resynchronised",
|
||||
body: tx`Some unsaved edits to ${this.path} could not be applied and were replaced by the file's current content.`,
|
||||
}),
|
||||
onError: (e) => console.warn(`document ${this.path}: sync error`, e),
|
||||
});
|
||||
},
|
||||
onError: (e) => logError(`document ${this.path}: sync error`, e),
|
||||
},
|
||||
manager.scheduler,
|
||||
);
|
||||
|
||||
@@ -226,7 +226,8 @@ describe("DocSync", () => {
|
||||
await server.processAll();
|
||||
await flushMicrotasks();
|
||||
expect(view.text.toString()).toBe("abc");
|
||||
expect(host.onDiscarded).toHaveBeenCalled();
|
||||
// The rejection reaches the host, which writes it to the log file.
|
||||
expect(host.onDiscarded).toHaveBeenCalledWith(expect.objectContaining({ kind: "permissionDenied" }));
|
||||
expect(sync.idle).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -44,14 +44,20 @@ async function confirmFiles(op: FileOperation["kind"], files: string[]): Promise
|
||||
const root = useVcs.getState().root ?? "";
|
||||
const add = op === "created";
|
||||
const n = files.length;
|
||||
const what = n === 1 ? "file" : `${n} files`;
|
||||
const list = files
|
||||
.slice(0, 10)
|
||||
.map((f) => relTo(root, f))
|
||||
.join("\n");
|
||||
const question = add
|
||||
? n === 1
|
||||
? tx("Do you want to add the following file to Git?")
|
||||
: tx`Do you want to add the following ${n} files to Git?`
|
||||
: n === 1
|
||||
? tx("Do you want to remove the following file from Git?")
|
||||
: tx`Do you want to remove the following ${n} files from Git?`;
|
||||
const r = await ask({
|
||||
title: add ? "Add File to Git" : "Remove File from Git",
|
||||
body: `${add ? `Do you want to add the following ${what} to Git?` : `Do you want to remove the following ${what} from Git?`}\n${list}${n > 10 ? "\n…" : ""}`,
|
||||
body: `${question}\n${list}${n > 10 ? "\n…" : ""}`,
|
||||
checkbox: "Don't ask again",
|
||||
width: 480,
|
||||
buttons: [{ id: "ok", label: add ? "Add" : "Remove", primary: true }],
|
||||
|
||||
@@ -14,6 +14,22 @@ describe("color schemes", () => {
|
||||
expect(schemeTokens("light").get("--bg-editor")).not.toMatch(/var\(/);
|
||||
});
|
||||
|
||||
it("gives PHP variables and function calls their own colours, as PhpStorm (#1532)", () => {
|
||||
expect(schemeTokens("dark").get("--syn-php-variable")).toBe("#c77dbb");
|
||||
expect(schemeTokens("dark").get("--syn-php-function-call")).toBe("#56a8f5");
|
||||
expect(schemeTokens("light").get("--syn-php-variable")).toBe("#871094");
|
||||
expect(schemeTokens("light").get("--syn-php-function-call")).toBe("#00627a");
|
||||
const css = readFileSync("src/styles/tokens.css", "utf8");
|
||||
expect(css).toMatch(/\.cm-editor\[data-language="PHP"\] \{\s*--syn-variable: var\(--syn-php-variable\);/);
|
||||
// An imported PHP_VAR still wins: language overrides are emitted for the same element, !important.
|
||||
const imported = schemeCss(
|
||||
"theme-default",
|
||||
{ dark: { "php.identifier": { foreground: "#48d8ee" } } },
|
||||
document.documentElement,
|
||||
);
|
||||
expect(imported).toMatch(/\.cm-editor\[data-language="PHP"\] \{\n {2}--syn-variable: #48d8ee !important;/);
|
||||
});
|
||||
|
||||
it("emits scheme and override declarations", () => {
|
||||
const root = document.documentElement;
|
||||
root.dataset.theme = "light";
|
||||
|
||||
@@ -6099,6 +6099,10 @@
|
||||
"Dropped {ref}": "已删除 {ref}",
|
||||
"Remove 1 file from Git": "从 Git 中移除 1 个文件",
|
||||
"Remove {n} files from Git": "从 Git 中移除 {n} 个文件",
|
||||
"Do you want to add the following file to Git?": "是否将以下文件添加到 Git?",
|
||||
"Do you want to add the following {n} files to Git?": "是否将以下 {n} 个文件添加到 Git?",
|
||||
"Do you want to remove the following file from Git?": "是否从 Git 中移除以下文件?",
|
||||
"Do you want to remove the following {n} files from Git?": "是否从 Git 中移除以下 {n} 个文件?",
|
||||
"Accept yours for {path}": "对 {path} 接受你的版本",
|
||||
"Accept theirs for {path}": "对 {path} 接受他们的版本",
|
||||
"{path} resolved": "{path} 已解决",
|
||||
|
||||
@@ -132,6 +132,9 @@ html {
|
||||
--syn-python-constant: var(--syn-constant);
|
||||
--syn-python-property: var(--syn-property);
|
||||
--syn-python-type-parameter: var(--syn-type);
|
||||
/* PhpStorm colours PHP variables and function calls on their own */
|
||||
--syn-php-variable: var(--syn-property);
|
||||
--syn-php-function-call: var(--syn-function);
|
||||
--syn-builtin: #8888c6;
|
||||
--syn-self: #94558d;
|
||||
--syn-keyword-arg: #aa4926;
|
||||
@@ -523,6 +526,8 @@ html {
|
||||
--syn-python-constant: var(--syn-constant);
|
||||
--syn-python-property: var(--syn-property);
|
||||
--syn-python-type-parameter: var(--syn-type);
|
||||
--syn-php-variable: var(--syn-property);
|
||||
--syn-php-function-call: var(--syn-function);
|
||||
--syn-builtin: #000080;
|
||||
--syn-self: #94558d;
|
||||
--syn-keyword-arg: #660099;
|
||||
@@ -789,6 +794,8 @@ html {
|
||||
--syn-python-constant: var(--syn-constant);
|
||||
--syn-python-property: var(--syn-property);
|
||||
--syn-python-type-parameter: var(--syn-type);
|
||||
--syn-php-variable: var(--syn-property);
|
||||
--syn-php-function-call: var(--syn-function);
|
||||
--syn-builtin: #b0b0f0;
|
||||
--syn-self: #d08ac8;
|
||||
--syn-keyword-arg: #e0805c;
|
||||
@@ -1140,6 +1147,8 @@ html {
|
||||
--syn-python-constant: var(--syn-constant);
|
||||
--syn-python-property: var(--syn-property);
|
||||
--syn-python-type-parameter: var(--syn-type);
|
||||
--syn-php-variable: var(--syn-property);
|
||||
--syn-php-function-call: var(--syn-function);
|
||||
--syn-builtin: var(--palette-cyan);
|
||||
--syn-self: var(--palette-red);
|
||||
--syn-keyword-arg: var(--palette-red);
|
||||
@@ -1429,3 +1438,8 @@ html {
|
||||
--db-connected: var(--success);
|
||||
--db-connected-ring: var(--success);
|
||||
}
|
||||
/* PHP editors: variables and function calls use the PHP colours (PHP_VAR / PHP_FUNCTION_CALL). */
|
||||
.cm-editor[data-language="PHP"] {
|
||||
--syn-variable: var(--syn-php-variable);
|
||||
--syn-function-call: var(--syn-php-function-call);
|
||||
}
|
||||
Reference in new issue
Block a user