Compare commits

..
Author SHA1 Message Date
l0ng-ai 58afe0b154 fix(editor): colour PHP variables and function calls
PHP editors showed $variables and function calls in the plain text
colour, because the Language Defaults that PHP inherits leave both
uncoloured. The built-in schemes now give PHP its own colours for them
(PHP_VAR, PHP_FUNCTION_CALL): each scheme's instance-field purple and
function blue. An imported scheme's PHP_VAR / PHP_FUNCTION_CALL and the
user's PHP overrides still take precedence.
2026-11-05 23:07:58 +08:00
l0ng-aiandClaude Opus 5.5 b70c01d1d6 fix(tomcat): keep the web app's META-INF/context.xml in the generated descriptor (#1576)
Tomcat ignores an app's META-INF/context.xml once conf/Catalina/localhost has a descriptor for it, and the run configuration always wrote a bare <Context docBase reloadable/>, dropping the app's Resource/Environment/Valve/Realm entries. The descriptor now starts from the app's own context.xml (exploded directory or WAR, all three deployment kinds) with docBase and reloadable set by the IDE and path dropped; unreadable or non-<Context> files fall back to the bare descriptor.

Fixes #1514

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 22:53:08 +08:00
Jacob Kjeldahlandkjeldahl 653ebd578a fix(ruby): Ruby LSP no longer crash-loops on dead shims or a spaced gem home (#1534)
Ruby LSP showed Crashed and restarted for two reasons. A version-manager shim for ruby-lsp exists for every Ruby that ever had the gem, so when the active Ruby lacked it the shim exited on every start while the IDE counted the server as installed. And the IDE-managed copy lived in a gem home under Application Support, whose space broke ruby-lsp's re-exec through Bundler.

Shims that can't run the tool now count as missing. ruby-lsp is installed with the project's own Ruby (gem install in the project, so a version manager picks its Ruby). Its executable is looked up in that Ruby's gem bindir and user bin when they are off PATH (Homebrew), and those directories are added to the server's PATH.

Co-authored-by: kjeldahl <[email protected]>
2026-11-05 22:38:10 +08:00
l0ng-ai 6e6baf4284 fix(vcs): translate the question in the add/remove to Git confirmation (#1575)
The confirmation body joined the English question and the file list into one string, which never matched a catalog entry. The question is now translated on its own before the list is appended.
2026-11-05 22:32:09 +08:00
l0ng-ai de09ee0678 fix(windows): reopen every project window on startup (#1574)
"Reopen projects on startup" only saved window groups, so windows opened with New Window were lost. The saved session now lists every open project window (groups with their tabs and selection, single windows on their own), and the previous run's session is read before this run first overwrites it, so the startup project can't replace it before the other windows are restored.
2026-11-05 22:29:41 +08:00
l0ng-ai 292dc3cade fix(editor): log why an edit was rejected before the editor drops it (#1569)
When the backend rejects an edit for any reason other than a stale version, the editor replaces its unsaved text with the file's content, but the reason was only printed to the webview console. The backend now logs the rejected edit with its path, and the editor writes the sync error and the discarded edits to the log file, so reports of edits that roll back (#1530, #1522) can be diagnosed from the log.
2026-11-05 22:04:49 +08:00
29 changed files with 583 additions and 161 deletions

No files matched your search

+15
View File
@@ -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);
+5 -1
View File
@@ -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.
+85 -2
View File
@@ -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();
+99 -1
View File
@@ -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=&amp;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=&amp;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(),
+33
View File
@@ -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:?}");
}
}
+8
View File
@@ -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
+91 -2
View File
@@ -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 { .. }
+7 -1
View File
@@ -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",
+45
View File
@@ -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() {
+7 -1
View File
@@ -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())
+62
View File
@@ -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);
}
}
+1 -1
View File
@@ -111,7 +111,7 @@ for (const palette of palettes) {
);
await editor.locator(".cm-content").click();
await page.keyboard.press("Meta+a");
await expect(editor.locator(".cm-selectionBackground:visible").first()).toBeVisible();
await expect(editor.locator(".cm-selectionBackground").first()).toBeVisible();
await page.keyboard.press("ArrowRight");
await page.keyboard.press("Meta+f");
await page.getByRole("textbox", { name: "Find", exact: true }).fill("Order");
+2 -4
View File
@@ -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)
}
+64 -46
View File
@@ -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(())
+3 -1
View File
@@ -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)>>,
-2
View File
@@ -35,7 +35,6 @@ import {
} from "@codemirror/commands";
import { markdownImages } from "./markdownImages";
import { selectionMarks } from "./selectionMarks";
import { seamlessSelection } from "./selectionLayer";
import type { EditorExtras } from "@/platform/editorSettings";
import { openideaHighlightStyle } from "./highlight";
import {
@@ -507,7 +506,6 @@ export function caretExtension(prefs: EditorExtras): Extension {
a.smoothCaretMovement ? caretGlide : [],
drawSelection({ cursorBlinkRate: a.caretBlinking ? Math.max(100, Math.min(5000, a.caretBlinkPeriod)) * 2 : 0 }),
selectionMarks,
seamlessSelection(),
classes.length ? EditorView.editorAttributes.of({ class: classes.join(" ") }) : [],
];
}
-21
View File
@@ -1,21 +0,0 @@
import { describe, expect, it } from "vitest";
import { RectangleMarker } from "@codemirror/view";
import { snapMarkers } from "./selectionLayer";
describe("snapMarkers", () => {
it("makes stacked selection rectangles share their edge exactly", () => {
// Middle piece's bottom (top + height) and the last line's top differ by float error around x.25.
const middle = new RectangleMarker("cm-selectionBackground", 6, 26.249999999, 500, 22.000000002);
const last = new RectangleMarker("cm-selectionBackground", 6, 48.25, 40, 22);
const [a, b] = snapMarkers([middle, last], "cm-selectionBackground", 2) as [RectangleMarker, RectangleMarker];
expect(a.top + a.height).toBe(b.top);
});
it("snaps edges to device pixels and keeps horizontal geometry", () => {
const m = snapMarkers([new RectangleMarker("x", 6.3, 10.3, 100, 21.9)], "x", 2)[0]!;
expect(m.top).toBe(10.5);
expect(m.top + m.height).toBe(32);
expect(m.left).toBe(6.3);
expect(m.width).toBe(100);
});
});
-48
View File
@@ -1,48 +0,0 @@
import { EditorView, RectangleMarker, layer } from "@codemirror/view";
import type { Extension } from "@codemirror/state";
/** Rounds a CSS length to the nearest device pixel. */
export function snapToDevicePixel(v: number, dpr: number): number {
return Math.round(v * dpr) / dpr;
}
/**
* Re-emits selection rectangles with top and bottom edges snapped to device pixels. A multi-line
* selection is drawn as several stacked rectangles; their shared edge comes from separate float
* sums, and when it lands on a fractional pixel each side rounds differently and a 1px seam shows.
*/
export function snapMarkers(markers: readonly RectangleMarker[], className: string, dpr: number): RectangleMarker[] {
return markers.map((m) => {
const top = snapToDevicePixel(m.top, dpr);
const bottom = snapToDevicePixel(m.top + m.height, dpr);
return new RectangleMarker(className, m.left, top, m.width, bottom - top);
});
}
const CLASS = "cm-selectionBackground";
const snappedSelectionLayer = layer({
above: false,
class: "cm-selectionLayer",
mount: (dom) => dom.classList.add("cm-openidea-selectionLayer"),
markers(view) {
const dpr = window.devicePixelRatio || 1;
const out: RectangleMarker[] = [];
for (const r of view.state.selection.ranges)
if (!r.empty) out.push(...snapMarkers(RectangleMarker.forRange(view, CLASS, r), CLASS, dpr));
return out;
},
update: (u) => u.docChanged || u.selectionSet || u.viewportChanged,
});
/** Use with `drawSelection()`: replaces its selection layer, which is hidden. */
export function seamlessSelection(): Extension {
return [
snappedSelectionLayer,
EditorView.theme({
".cm-selectionLayer:not(.cm-openidea-selectionLayer)": {
display: "none",
},
}),
];
}
+2 -18
View File
@@ -177,17 +177,7 @@ export const openideaTheme = EditorView.theme({
"&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground": {
background: "var(--editor-selection)",
},
// Row backgrounds go on a pseudo-element under the selection layer: on the line itself they would hide it.
".cm-activeLine": { backgroundColor: "transparent", "--openidea-line-bg": "var(--editor-caret-row)" },
".cm-activeLine, .cm-openidea-lineBg": { position: "relative" },
".cm-activeLine::before, .cm-openidea-lineBg::before": {
content: '""',
position: "absolute",
inset: "0",
zIndex: "-1000",
background: "var(--openidea-line-bg)",
pointerEvents: "none",
},
".cm-activeLine": { backgroundColor: "var(--editor-caret-row)" },
".cm-selectionMatch": {
backgroundColor: "var(--bg-identifier-under-caret, color-mix(in srgb, var(--fg) 9%, transparent))",
},
@@ -273,13 +263,7 @@ export const openideaTheme = EditorView.theme({
".cm-foldPlaceholder:hover": { backgroundColor: "color-mix(in srgb, var(--fg-muted) 24%, transparent)" },
// Indent guides (cm/indentGuides.ts)
// Above the text so a selection, or a mark's background, never covers part of a guide.
".cm-openidea-guides": { position: "relative" },
".cm-openidea-guides::after": {
content: '""',
position: "absolute",
inset: "0",
pointerEvents: "none",
".cm-openidea-guides": {
backgroundImage:
"repeating-linear-gradient(to right, var(--editor-indent-guide) 0 1px, transparent 1px calc(var(--openidea-guide-step) * 1ch))",
backgroundSize: "calc(var(--openidea-guide-levels) * var(--openidea-guide-step) * 1ch) 100%",
+6 -3
View File
@@ -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,
);
+2 -1
View File
@@ -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);
});
@@ -84,7 +84,7 @@ class BreakpointMarker extends GutterMarker {
}
}
const bpLine = Decoration.line({ class: `${styles.bpLine} cm-openidea-lineBg` });
const bpLine = Decoration.line({ class: styles.bpLine });
const DISABLED_LOOKS = new Set<BreakpointLook>(["disabled", "muted", "methodDisabled", "fieldDisabled"]);
const setMarkers = StateEffect.define<RangeSet<BreakpointMarker>>();
@@ -126,11 +126,11 @@
/* Beat the caret row (`.cm-activeLine`), as IDEA does; the suspended line wins over a breakpoint. */
:global(.cm-editor) :global(.cm-line).bpLine {
--openidea-line-bg: var(--bg-breakpoint-line);
background: var(--bg-breakpoint-line);
}
:global(.cm-editor) :global(.cm-line).execLine {
--openidea-line-bg: var(--bg-debug-line);
background: var(--bg-debug-line);
}
.inlineValue {
+1 -1
View File
@@ -61,7 +61,7 @@ export function inlineValuesFor(text: string, values: Record<string, string>): s
function lineDecorations(state: EditorState, exec: Exec | null): DecorationSet {
if (!exec || exec.line >= state.doc.lines) return Decoration.none;
const line = state.doc.line(exec.line + 1);
return Decoration.set([Decoration.line({ class: `${styles.execLine} cm-openidea-lineBg` }).range(line.from)]);
return Decoration.set([Decoration.line({ class: styles.execLine }).range(line.from)]);
}
export function executionPoint(ctx: EditorDocContext): Extension {
-2
View File
@@ -13,7 +13,6 @@ import { bracketMatching, indentOnInput, syntaxHighlighting } from "@codemirror/
import { openideaTheme } from "@/editor/core/cm/theme";
import { openideaHighlightStyle } from "@/editor/core/cm/highlight";
import { selectionMarks } from "@/editor/core/cm/selectionMarks";
import { seamlessSelection } from "@/editor/core/cm/selectionLayer";
import { findLanguageDescription, loadSupport } from "@/editor/core/cm/languages";
import { tx } from "@/platform/i18n";
import type { NotebookSession } from "./session";
@@ -46,7 +45,6 @@ export function CellEditor({
lineNumbers(),
drawSelection(),
selectionMarks,
seamlessSelection(),
highlightActiveLine(),
highlightSpecialChars(),
bracketMatching(),
+8 -2
View File
@@ -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 }],
+16
View File
@@ -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";
+4
View File
@@ -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} 已解决",
+14
View File
@@ -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);
}