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
132 changed files with 343 additions and 3164 deletions

No files matched your search

Generated
-1
View File
@@ -4121,7 +4121,6 @@ dependencies = [
name = "ide-build"
version = "0.1.37"
dependencies = [
"blake3",
"dirs 6.0.0",
"dunce",
"ide-core",
-1
View File
@@ -6,7 +6,6 @@ edition.workspace = true
license.workspace = true
[dependencies]
blake3.workspace = true
dunce.workspace = true
ide-core.workspace = true
zip.workspace = true
+6 -57
View File
@@ -2,49 +2,18 @@
use super::*;
use std::fs;
use std::hash::{Hash, Hasher};
use std::io::Read;
use std::time::SystemTime;
/// `(byte length, mtime, blake3 hex)`; the hash is recorded for files inside the project only.
type Stamp = (u64, SystemTime, Option<String>);
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct LaunchFingerprint {
version: u32,
parameters: String,
/// Per input: a [`Stamp`] for regular files, `None` for
/// directories and inputs that cannot be resolved. The content hash catches external
/// edits that preserve both length and mtime (timestamp-preserving tools, coarse
/// timestamp granularity), which `(length, mtime)` alone would miss. Only files inside
/// the project are hashed; toolchain inputs such as the JDK's `lib/modules` keep the stamp.
files: BTreeMap<PathBuf, Option<Stamp>>,
}
/// `blake3` hex of a file's bytes, streamed in chunks so memory stays bounded. An
/// unreadable file records a sentinel so one permission problem does not drop the whole
/// fingerprint (that would just force a normal rebuild).
fn content_hash(path: &Path) -> IdeResult<String> {
let mut file = match fs::File::open(path) {
Ok(file) => file,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok("missing".into()),
Err(e) => return Err(e.into()),
};
let mut hasher = blake3::Hasher::new();
let mut buffer = [0; 64 * 1024];
loop {
let count = file.read(&mut buffer)?;
if count == 0 {
break;
}
hasher.update(&buffer[..count]);
}
Ok(hasher.finalize().to_hex().to_string())
files: BTreeMap<PathBuf, Option<(u64, SystemTime)>>,
}
fn scan(
root: &Path,
path: &Path,
files: &mut BTreeMap<PathBuf, Option<Stamp>>,
files: &mut BTreeMap<PathBuf, Option<(u64, SystemTime)>>,
ancestors: &mut HashSet<PathBuf>,
cancel: &CancellationToken,
) -> IdeResult<()> {
@@ -67,12 +36,11 @@ fn scan(
// Directory mtimes change on irrelevant JDT writes. File paths detect additions/deletions.
files.insert(path.to_path_buf(), None);
for entry in fs::read_dir(path)? {
scan(root, &entry?.path(), files, ancestors, cancel)?;
scan(&entry?.path(), files, ancestors, cancel)?;
}
ancestors.remove(&canonical);
} else if meta.is_file() {
let hash = if path.starts_with(root) { Some(content_hash(path)?) } else { None };
files.insert(path.to_path_buf(), Some((meta.len(), meta.modified()?, hash)));
files.insert(path.to_path_buf(), Some((meta.len(), meta.modified()?)));
} else {
return Err(IdeError::internal("unsupported launch input file"));
}
@@ -177,11 +145,11 @@ pub fn launch_fingerprint(
.map_err(|e| IdeError::internal(e.to_string()))?;
let mut files = BTreeMap::new();
for path in inputs {
scan(root, &path, &mut files, &mut HashSet::new(), cancel)?;
scan(&path, &mut files, &mut HashSet::new(), cancel)?;
}
// Store project inputs relative to the project, including each file's relative path.
let files = files.into_iter().map(|(p, stamp)| (p.strip_prefix(root).unwrap_or(&p).to_path_buf(), stamp)).collect();
Ok(Some(LaunchFingerprint { version: 2, parameters, files }))
Ok(Some(LaunchFingerprint { version: 1, parameters, files }))
}
#[cfg(test)]
@@ -263,25 +231,6 @@ mod tests {
}
}
/// An external tool that keeps the byte length and the mtime (timestamp-preserving
/// writes, coarse-granularity filesystems) still changes the content: the fingerprint
/// must differ via the content hash, not via `(length, mtime)`.
#[test]
fn external_edit_preserving_length_and_mtime_still_invalidates() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let model = fixture(root);
let source = root.join("lib/src/main/kotlin/Channel.kt");
let before = fingerprint(root, &model);
assert!(before.is_some());
let mtime = fs::metadata(&source).unwrap().modified().unwrap();
// Same 5-byte length, different content.
write(root, "lib/src/main/kotlin/Channel.kt", "valve");
fs::File::options().write(true).open(&source).unwrap().set_modified(mtime).unwrap();
let after = fingerprint(root, &model);
assert_ne!(before, after, "a same-length, same-mtime content change must invalidate the fingerprint");
}
#[test]
fn profile_options_jdk_goal_and_wrapper_invalidate_without_using_class_outputs() {
let dir = tempfile::tempdir().unwrap();
+3 -32
View File
@@ -601,7 +601,7 @@ impl Default for FileSettings {
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct LanguagesSettings {
pub python: PythonSettings,
@@ -612,34 +612,11 @@ pub struct LanguagesSettings {
pub typescript: TypeScriptSettings,
pub prettier: ExternalFormatterSettings,
pub oxfmt: ExternalFormatterSettings,
pub ruff: ExternalFormatterSettings,
pub java: JavaSettings,
pub ruby: RubySettings,
pub markdown: MarkdownSettings,
}
impl Default for LanguagesSettings {
fn default() -> Self {
Self {
python: PythonSettings::default(),
node: NodeSettings::default(),
php: PhpSettings::default(),
rust: RustSettings::default(),
go: GoSettings::default(),
typescript: TypeScriptSettings::default(),
prettier: ExternalFormatterSettings::default(),
oxfmt: ExternalFormatterSettings::default(),
ruff: ExternalFormatterSettings {
languages: vec!["python".into()],
..ExternalFormatterSettings::default()
},
java: JavaSettings::default(),
ruby: RubySettings::default(),
markdown: MarkdownSettings::default(),
}
}
}
/// Languages & Frameworks › Markdown. Like IDEA's, it is application-level only.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
@@ -653,7 +630,6 @@ impl LanguagesSettings {
match tool {
ExternalFormatter::Prettier => &self.prettier,
ExternalFormatter::Oxfmt => &self.oxfmt,
ExternalFormatter::Ruff => &self.ruff,
}
}
}
@@ -664,7 +640,6 @@ impl LanguagesSettings {
pub enum ExternalFormatter {
Prettier,
Oxfmt,
Ruff,
}
impl ExternalFormatter {
@@ -673,19 +648,16 @@ impl ExternalFormatter {
match self {
Self::Prettier => "prettier",
Self::Oxfmt => "oxfmt",
Self::Ruff => "ruff",
}
}
}
/// An external formatter (Prettier, oxfmt, Ruff) and the languages it formats.
/// An external formatter (Prettier, oxfmt) and the languages it formats.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct ExternalFormatterSettings {
/// Empty path: project node_modules (Prettier/oxfmt), managed installation, then PATH.
/// Empty path: project node_modules, managed installation, then PATH.
pub path: String,
/// Ruff only: directory containing its configuration, or a configuration file.
pub configuration_directory: String,
pub languages: Vec<String>,
/// Prefer this formatter even when a language server offers formatting.
pub prefer: bool,
@@ -696,7 +668,6 @@ impl Default for ExternalFormatterSettings {
fn default() -> Self {
Self {
path: String::new(),
configuration_directory: String::new(),
languages: [
"markdown",
"yaml",
+1 -1
View File
@@ -143,7 +143,7 @@ fn nvm_bins(dir: &Path) -> Vec<PathBuf> {
}
/// `v22.10.1` as `[22, 10, 1]`: v22 is newer than v9, v20.10 newer than v20.9.
pub fn version_key(name: &str) -> Vec<u64> {
fn version_key(name: &str) -> Vec<u64> {
name.trim_start_matches('v').split('.').map(|n| n.parse().unwrap_or(0)).collect()
}
+16 -113
View File
@@ -3,7 +3,7 @@
use crate::Host;
use ide_core::event::NotifyLevel;
use ide_core::settings::{ExternalFormatter, LanguagesSettings};
use ide_core::{DocId, IdeError, IdeEvent, IdeResult, Pos, Range, TextEdit};
use ide_core::{DocId, IdeError, IdeEvent, IdeResult, Range, TextEdit};
use ide_language::LanguageConfig;
use ide_lsp::types::FormatOptions;
use ide_tools::{Tools, formatter};
@@ -11,27 +11,20 @@ use std::path::Path;
const OXFMT_CONFIGS: [&str; 4] = [".oxfmtrc.json", ".oxfmtrc.jsonc", "oxfmt.config.ts", "oxfmt.config.mts"];
fn supports_external_format(language: &LanguageConfig, tool: ExternalFormatter) -> bool {
match language.formatter.as_ref().map(|f| f.tool.as_str()) {
Some("prettier") => matches!(tool, ExternalFormatter::Prettier | ExternalFormatter::Oxfmt),
Some("ruff") => tool == ExternalFormatter::Ruff,
_ => false,
}
}
/// The external formatter of a file, for a language with an external formatter.
/// `forced` names the tool (Reformat with Prettier, oxfmt, or Ruff). Ruff is
/// the base formatter for Python; oxfmt can replace Prettier for its languages
/// when configured or selected. A tool that cannot format the language is
/// rejected even when explicitly requested.
/// `forced` names the tool (Reformat with Prettier/oxfmt). Otherwise oxfmt
/// applies when it is enabled for the language and either an oxfmt
/// configuration is found above the file or oxfmt is the selected provider;
/// else Prettier, when it is enabled. Turning Prettier off for a language
/// leaves it to the language server, not to oxfmt.
fn external_tool(
forced: Option<ExternalFormatter>,
language: Option<&LanguageConfig>,
file: &Path,
settings: &LanguagesSettings,
) -> IdeResult<Option<ExternalFormatter>> {
use ExternalFormatter::{Oxfmt, Prettier, Ruff};
let Some(language) = language.filter(|l| l.formatter.is_some()) else {
use ExternalFormatter::{Oxfmt, Prettier};
let Some(language) = language.filter(|l| l.formatter.as_ref().is_some_and(|f| f.tool == "prettier")) else {
return match forced {
Some(tool) => Err(IdeError::invalid(format!(
"{} cannot format {} files",
@@ -41,21 +34,10 @@ fn external_tool(
None => Ok(None),
};
};
if let Some(tool) = forced {
return if supports_external_format(language, tool) {
Ok(Some(tool))
} else {
Err(IdeError::invalid(format!(
"{} cannot format {} files",
formatter::display_name(tool.name()),
language.name
)))
};
if forced.is_some() {
return Ok(forced);
}
let enabled = |tool| settings.formatter(tool).languages.iter().any(|l| l == language.id.as_str());
if language.formatter.as_ref().is_some_and(|f| f.tool == "ruff") {
return Ok(enabled(Ruff).then_some(Ruff));
}
let oxfmt_config = || OXFMT_CONFIGS.iter().any(|name| formatter::find_up(file, Path::new(name)).is_some());
Ok(if enabled(Oxfmt) && (settings.oxfmt.prefer || oxfmt_config()) {
Some(Oxfmt)
@@ -64,34 +46,6 @@ fn external_tool(
})
}
fn codepoint_col(line: &str, utf16_col: u32) -> u32 {
let mut units = 0;
for (index, ch) in line.chars().enumerate() {
if units >= utf16_col {
return index as u32;
}
units += ch.len_utf16() as u32;
}
line.chars().count() as u32
}
fn format_range(text: &ide_text::Document, range: Range) -> formatter::FormatRange {
let position = |pos: Pos| {
let line = text.line(pos.line).unwrap_or_default();
(pos.line + 1, codepoint_col(&line, pos.col) + 1)
};
let (start_line, start_col) = position(range.start);
let (end_line, end_col) = position(range.end);
formatter::FormatRange {
start_offset: text.pos_to_offset(range.start),
end_offset: text.pos_to_offset(range.end),
start_line,
start_col,
end_line,
end_col,
}
}
pub async fn format(
host: &Host,
window: &str,
@@ -131,28 +85,13 @@ pub async fn format(
let custom = ide_sdk::apply::setting_path(&project.root, &tool_settings.path);
let tools = Tools::new(&host.local_data_dir);
let whole_selection = range.is_some() && !formatter::supports_range(tool, &config);
let project_root = project.root.clone();
let configuration_directory = settings.languages.ruff.configuration_directory.clone();
let edits: IdeResult<Vec<TextEdit>> = tokio::task::spawn_blocking(move || {
let text = ide_text::Document::new(&snap.text);
let range = range.map(|range| format_range(&text, range));
let config_file = if tool == "ruff" {
ide_sdk::apply::ruff_configuration(&project_root, &configuration_directory)?
} else {
None
};
let offsets = range.map(|r| (text.pos_to_offset(r.start), text.pos_to_offset(r.end)));
let mut command = tools.formatter_command(tool, &snap.path, custom.as_deref())?;
let dir = snap.path.parent().ok_or_else(|| IdeError::invalid(format!("{name}: file has no directory")))?;
command.current_dir(dir);
command.args(formatter::arguments(
tool,
&config,
&snap.path,
options.tab_size,
options.insert_spaces,
range,
config_file.as_deref(),
));
command.args(formatter::arguments(tool, &config, &snap.path, options.tab_size, options.insert_spaces, offsets));
let formatted = formatter::run(tool, command, &snap.text, &Default::default())?;
Ok(text
.diff(&formatted)
@@ -181,7 +120,7 @@ pub async fn format(
#[cfg(test)]
mod tests {
use super::*;
use ExternalFormatter::{Oxfmt, Prettier, Ruff};
use ExternalFormatter::{Oxfmt, Prettier};
use ide_core::LanguageId;
use ide_language::LanguageRegistry;
@@ -196,36 +135,17 @@ mod tests {
}
#[test]
fn default_settings_use_ruff_for_python_and_prettier_for_the_web() {
fn default_settings_give_every_external_formatter_language_to_prettier() {
let settings = LanguagesSettings::default();
let file = Path::new("/project/file");
for lang in LanguageRegistry::load(None).unwrap().all() {
let configured = match lang.formatter.as_ref().map(|f| f.tool.as_str()) {
Some("prettier") => Some(Prettier),
Some("ruff") => Some(Ruff),
_ => None,
};
let configured = lang.formatter.as_ref().is_some_and(|f| f.tool == "prettier");
let selected = external_tool(None, Some(&lang), file, &settings).unwrap();
assert_eq!(selected, configured, "{}", lang.id);
assert_eq!(selected, configured.then_some(Prettier), "{}", lang.id);
}
assert_eq!(external_tool(None, None, file, &settings).unwrap(), None);
}
#[test]
fn ruff_is_the_python_external_formatter_and_is_opt_out() {
let file = Path::new("/project/src/example.py");
let mut settings = LanguagesSettings::default();
assert_eq!(tool(None, "python", file, &settings).unwrap(), Some(Ruff));
assert_eq!(tool(Some(Ruff), "python", file, &settings).unwrap(), Some(Ruff));
settings.ruff.languages.clear();
assert_eq!(tool(None, "python", file, &settings).unwrap(), None);
assert_eq!(tool(Some(Ruff), "python", file, &settings).unwrap(), Some(Ruff));
let prettier = tool(Some(Prettier), "python", file, &settings).unwrap_err();
assert_eq!(prettier.message, "Prettier cannot format Python files");
let oxfmt = tool(Some(Oxfmt), "python", file, &settings).unwrap_err();
assert_eq!(oxfmt.message, "oxfmt cannot format Python files");
}
#[test]
fn oxfmt_config_selects_oxfmt_for_its_enabled_languages() {
let dir = tempfile::tempdir().unwrap();
@@ -272,21 +192,4 @@ mod tests {
let rust = tool(Some(Oxfmt), "rust", &dir.path().join("main.rs"), &settings).unwrap_err();
assert_eq!(rust.message, "oxfmt cannot format Rust files");
}
#[test]
fn ruff_ranges_are_one_based_unicode_columns() {
let text = ide_text::Document::new("😀x = 1\ny = 2\n");
let range = Range::new(Pos::new(0, 2), Pos::new(1, 2));
assert_eq!(
format_range(&text, range),
formatter::FormatRange {
start_offset: 2,
end_offset: 10,
start_line: 1,
start_col: 2,
end_line: 2,
end_col: 3,
}
);
}
}
-23
View File
@@ -242,29 +242,6 @@ mod tests {
assert!(build().is_err());
assert_eq!(fs::read_to_string(root.join("calls")).unwrap().lines().count(), 5);
}
/// An external tool (e.g. an AI code editor) rewrites a source file with the same byte
/// length and preserves its mtime. The `(length, mtime)` fingerprint reads that as
/// "unchanged" and would reuse the stale snapshot; the content hash must catch it.
#[test]
fn external_edit_preserving_length_and_mtime_rebuilds_the_snapshot() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let model = fixture(root);
let cache = root.join("cache");
let cancel = CancellationToken::new();
let mut session = Session::new(cache.clone());
let first = session.build(root, &model, root, false, None, &cancel).unwrap();
assert_eq!(fs::read_to_string(first.snapshot.join("0/Main.class")).unwrap(), "first");
let source = root.join("src/main/kotlin/Main.kt");
let mtime = fs::metadata(&source).unwrap().modified().unwrap();
fs::write(&source, "other").unwrap(); // 5 bytes, same length as "first"
fs::File::options().write(true).open(&source).unwrap().set_modified(mtime).unwrap();
let rebuilt = session.build(root, &model, root, false, None, &cancel).unwrap();
assert_ne!(rebuilt.snapshot, first.snapshot, "a same-length, same-mtime content change must rebuild");
assert_eq!(fs::read_to_string(rebuilt.snapshot.join("0/Main.class")).unwrap(), "other");
}
#[test]
fn session_drop_removes_superseded_snapshots_and_preserves_records_across_reopen() {
let dir = tempfile::tempdir().unwrap();
-9
View File
@@ -52,14 +52,6 @@ pub fn sdk_python_effective(host: &Host, window: &str) -> IdeResult<Option<ide_c
Ok(ide_sdk::apply::python_interpreter(&project.root, &project.settings.effective()).map(Into::into))
}
/// The PHP interpreter chosen in Settings › Languages & Frameworks › PHP; `None`: the `php` on `PATH`.
pub fn sdk_php_effective(host: &Host, window: &str) -> IdeResult<Option<ide_core::IpcPath>> {
host.require_trust(window)?;
let project = host.project(window)?;
let settings = project.settings.effective();
Ok(ide_sdk::apply::setting_path(&project.root, &settings.languages.php.interpreter).map(Into::into))
}
/// The PHP language level of the project `path` belongs to (a workspace member, by path, as
/// the language server configuration picks it).
#[derive(Clone, Debug, serde::Serialize, specta::Type)]
@@ -206,7 +198,6 @@ pub async fn sdk_python_pip(
pub(crate) fn register(d: &mut Dispatcher) {
d.add("sdk_detect", |c| async move { sdk_detect(&c.host, &c.window, c.arg("kind")?).await });
d.add("sdk_python_effective", |c| async move { sdk_python_effective(&c.host, &c.window) });
d.add("sdk_php_effective", |c| async move { sdk_php_effective(&c.host, &c.window) });
d.add(
"sdk_php_language_level",
|c| async move { sdk_php_language_level(&c.host, &c.window, c.arg("path")?).await },
-16
View File
@@ -1007,19 +1007,6 @@ pub async fn vcs_diff_revisions(
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.diff_revisions(&from, &to))).await
}
/// Added and deleted lines between two revisions. `paths` limits the diff; empty diffs every file.
pub async fn vcs_diff_numstat(
host: &Host,
window: &str,
repo: Option<PathBuf>,
from: String,
to: String,
paths: Vec<String>,
) -> IdeResult<Vec<FileLineStats>> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.diff_numstat(&from, &to, &paths))).await
}
/// The best common ancestor of `a` and `b` (`git merge-base`), if any: the New Pull
/// Request tab's three-dot diff.
pub async fn vcs_merge_base(
@@ -1370,9 +1357,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("vcs_diff_revisions", |c| async move {
vcs_diff_revisions(&c.host, &c.window, c.arg("repo")?, c.arg("from")?, c.arg("to")?).await
});
d.add("vcs_diff_numstat", |c| async move {
vcs_diff_numstat(&c.host, &c.window, c.arg("repo")?, c.arg("from")?, c.arg("to")?, c.arg("paths")?).await
});
d.add("vcs_merge_base", |c| async move {
vcs_merge_base(&c.host, &c.window, c.arg("repo")?, c.arg("a")?, c.arg("b")?).await
});
+3 -5
View File
@@ -306,9 +306,8 @@ impl PluginApi {
// allowed, and installing it later approves nothing by itself.
let pin = self.shared.pins.read().get(&(self.id().to_owned(), cap.clone())).cloned().ok_or_else(|| {
HostError::Failed(format!(
"{cap}: `{program}` was not found on PATH when this permission was allowed. \
Restarting doesn't approve a program installed later: once `{program}` is on PATH, \
clear {cap} in Settings › Plugins and select it again"
"{cap}: `{program}` was not found when this permission was allowed; \
install it, then allow {cap} again in Settings › Plugins"
))
})?;
pin.verify().map(Path::to_path_buf).map_err(|e| HostError::Denied(format!("{cap}: {e}")))
@@ -832,8 +831,7 @@ mod tests {
// Allowed while the program was missing: say how to fix it rather than "not granted".
let err = api.approved_program("lint").unwrap_err().to_string();
assert!(
err.contains("was not found on PATH when this permission was allowed")
&& err.contains("clear process:lint in Settings › Plugins and select it again"),
err.contains("was not found when this permission was allowed") && err.contains("allow process:lint again"),
"{err}"
);
-1
View File
@@ -338,7 +338,6 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "vcs_compare_scope"
| "vcs_compare_paths"
| "vcs_diff_revisions"
| "vcs_diff_numstat"
| "vcs_merge_base"
| "vcs_shelves"
| "vcs_shelf_patch"
-1
View File
@@ -533,7 +533,6 @@ mod tests {
"Julia",
"Kotlin",
"LiveScript",
"Log",
"Lua",
"mIRC",
"Mathematica",
-51
View File
@@ -7,7 +7,6 @@ use crate::{angular, godot, pinned, vue};
use crate::{bin_dir, exe, venv_of};
use ide_core::settings::{InspectionLevel, InspectionSettings, LanguageServerSettings, Settings};
use ide_core::version_managers::Tool;
use ide_core::{IdeError, IdeResult};
use ide_language::{LanguageConfig, LanguageServerConfig};
use ide_run::RunConfig;
use serde_json::{Value, json};
@@ -29,39 +28,6 @@ pub fn setting_path(root: &Path, s: &str) -> Option<PathBuf> {
Some(if p.is_absolute() { p } else { root.join(p) })
}
fn toml_has_table(file: &Path, table: &str) -> bool {
let Ok(text) = std::fs::read_to_string(file) else { return false };
let Ok(value) = toml::from_str::<toml::Value>(&text) else { return false };
table.split('.').try_fold(&value, |value, key| value.get(key)).is_some_and(toml::Value::is_table)
}
/// The Ruff configuration file selected by `languages.ruff.configurationDirectory`.
/// A file can be chosen directly; a directory resolves `ruff.toml`, `.ruff.toml`,
/// then a `pyproject.toml` containing `[tool.ruff]`.
pub fn ruff_configuration(root: &Path, configured: &str) -> IdeResult<Option<PathBuf>> {
let Some(path) = setting_path(root, configured) else { return Ok(None) };
if path.is_file() {
return Ok(Some(path));
}
if !path.is_dir() {
return Err(IdeError::invalid(format!("Ruff configuration path does not exist: {}", path.display())));
}
for name in ["ruff.toml", ".ruff.toml"] {
let file = path.join(name);
if file.is_file() {
return Ok(Some(file));
}
}
let pyproject = path.join("pyproject.toml");
if toml_has_table(&pyproject, "tool.ruff") {
return Ok(Some(pyproject));
}
Err(IdeError::invalid(format!(
"Ruff configuration directory has no ruff.toml, .ruff.toml, or pyproject.toml with [tool.ruff]: {}",
path.display()
)))
}
/// The project interpreter: the configured one, else a project virtualenv
/// (`.venv`, `venv`, …) in the project root, else the Python the project pins for mise / asdf.
pub fn python_interpreter(root: &Path, s: &Settings) -> Option<PathBuf> {
@@ -609,18 +575,6 @@ pub fn configure_server(cfg: &mut LanguageServerConfig, root: &Path, server_root
}
}
if name == "ruff" {
match ruff_configuration(root, &s.languages.ruff.configuration_directory) {
Ok(Some(path)) => {
let mut settings = cfg.settings.take().filter(Value::is_object).unwrap_or_else(|| json!({}));
settings["configuration"] = json!(path.to_string_lossy());
cfg.settings = Some(settings);
}
Ok(None) => {}
Err(e) => cfg.launch_error = Some(e.message),
}
}
if name == "rust-analyzer" {
let r = &s.languages.rust;
let extra_args: Vec<&str> = r.rustfmt_args.split_whitespace().collect();
@@ -765,11 +719,6 @@ pub fn language_servers(language: &str, defined: &[LanguageServerConfig], s: &Se
out.push(custom_server(name, ls));
}
}
if non_empty(&s.languages.ruff.configuration_directory).is_some() {
for cfg in out.iter_mut().filter(|cfg| cfg.name == "ruff") {
cfg.require_root_marker = false;
}
}
out
}
+4 -48
View File
@@ -282,38 +282,8 @@ pub(crate) fn php_source(path: &Path, home: Option<&Path>) -> &'static str {
}
}
/// A PHP CLI named with its version, as Debian / Ubuntu (`ppa:ondrej/php`) install them:
/// `php8.1`, `php81`. Not `php-cgi8.1`, `phpize8.1` or `phpdbg8.1`.
pub(crate) fn is_versioned_php_name(name: &str) -> bool {
let name = name.strip_suffix(".exe").unwrap_or(name);
let Some(version) = name.strip_prefix("php") else { return false };
let mut parts = version.splitn(2, '.');
let digits = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit());
parts.next().is_some_and(digits) && parts.next().is_none_or(digits)
}
/// The versioned PHP CLIs in `dirs`, in `PATH` order (sorted by name within a directory).
pub(crate) fn versioned_php_in(dirs: impl IntoIterator<Item = PathBuf>) -> Vec<PathBuf> {
let mut found = Vec::new();
for dir in dirs {
if version_managers::is_shims_dir(&dir) {
continue;
}
let mut names: Vec<String> = std::fs::read_dir(&dir)
.into_iter()
.flatten()
.flatten()
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|n| is_versioned_php_name(n))
.collect();
names.sort();
found.extend(names.into_iter().map(|n| dir.join(n)).filter(|p| p.is_file()));
}
found
}
/// `php` CLIs: every one on `PATH` (also versioned ones, `php8.1`), Homebrew's versioned
/// formulae, PHP version managers, Laravel Herd, and the usual Windows distributions (XAMPP, Laragon).
/// `php` CLIs: every one on `PATH`, Homebrew's versioned formulae, PHP version managers,
/// Laravel Herd, and the usual Windows distributions (XAMPP, Laragon).
fn php() -> Vec<Sdk> {
let mut found: Vec<(PathBuf, &'static str)> = Vec::new();
let home = home_dir();
@@ -324,11 +294,6 @@ fn php() -> Vec<Sdk> {
let source = php_source(&p, home.as_deref());
found.push((p, source));
}
// After the plain `php`: deduplication keeps `/usr/bin/php` over the `php8.3` it links to.
for p in versioned_php_in(std::env::split_paths(&search_path())) {
let source = php_source(&p, home.as_deref());
found.push((p, source));
}
let named = |dir: &Path, prefix: &str| -> Vec<PathBuf> {
subdirs(dir)
.into_iter()
@@ -367,7 +332,7 @@ fn php() -> Vec<Sdk> {
}
}
let found = dedupe_by_real_path(found);
let mut sdks = par_map(&found, |(path, source)| {
par_map(&found, |(path, source)| {
let version = query(path, &["-r", "echo PHP_VERSION;"], VERSION_TIMEOUT)?;
let version = version.trim().to_owned();
if version.is_empty() {
@@ -381,16 +346,7 @@ fn php() -> Vec<Sdk> {
home: path.parent().and_then(Path::parent).map(Into::into),
extra: None,
})
});
newest_first(&mut sdks);
sdks
}
/// Newest version first, number by number (8.3.12 before 8.3.9); stable, so on equal
/// versions the `PATH` install stays first.
pub(crate) fn newest_first(sdks: &mut [Sdk]) {
let key = |s: &Sdk| ide_core::tool_path::version_key(s.version.as_deref().unwrap_or(""));
sdks.sort_by_key(|s| std::cmp::Reverse(key(s)));
})
}
fn node() -> Vec<Sdk> {
+1 -77
View File
@@ -1,6 +1,6 @@
use crate::apply::{
apply_to_run_configs, configure_server, jdtls_jdk, maven_command, maven_options, project_jdk, python_interpreter,
ruff_configuration, terminal_activation, tsdk, without_derived,
terminal_activation, tsdk, without_derived,
};
use crate::python::parse_pip_list;
use crate::{bin_dir, exe, venv_of};
@@ -481,9 +481,6 @@ fn language_server_settings_disable_add_and_override() {
(ruff.command.as_str(), ruff.args.as_slice()),
("/opt/ruff", &["server".to_string(), "--preview".to_string()][..])
);
s.languages.ruff.configuration_directory = "ruff-config".into();
let defined = [server("ruff")];
assert!(!language_servers("python", &defined, &s)[0].require_root_marker);
s.language_servers.insert(
"rust-analyzer".into(),
@@ -526,38 +523,6 @@ fn language_server_settings_disable_add_and_override() {
assert_eq!(parsed.code_action_kinds, Some(vec!["refactor".to_string()]));
}
#[test]
fn ruff_configuration_directory_resolves_files_and_validates_pyproject() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let dir = root.join("shared ruff");
std::fs::create_dir_all(&dir).unwrap();
assert_eq!(ruff_configuration(root, "").unwrap(), None);
assert!(ruff_configuration(root, &dir.to_string_lossy()).unwrap_err().message.contains("has no"));
let ruff = dir.join("ruff.toml");
std::fs::write(&ruff, "line-length = 100\n").unwrap();
assert_eq!(ruff_configuration(root, &dir.to_string_lossy()).unwrap(), Some(ruff.clone()));
assert_eq!(ruff_configuration(root, &ruff.to_string_lossy()).unwrap(), Some(ruff));
std::fs::remove_file(dir.join("ruff.toml")).unwrap();
let pyproject = dir.join("pyproject.toml");
std::fs::write(&pyproject, "[project]\nname = \"x\"\n").unwrap();
assert!(ruff_configuration(root, &dir.to_string_lossy()).unwrap_err().message.contains("has no"));
std::fs::write(&pyproject, "[tool.ruff]\nline-length = 100\n").unwrap();
assert_eq!(ruff_configuration(root, &dir.to_string_lossy()).unwrap(), Some(pyproject.clone()));
let mut settings = Settings::default();
settings.languages.ruff.configuration_directory = dir.to_string_lossy().into_owned();
let mut cfg = server("ruff");
configure_server(&mut cfg, root, root, &settings);
let configured = dir.join("pyproject.toml").to_string_lossy().into_owned();
assert_eq!(
cfg.settings.as_ref().and_then(|s| s.get("configuration")).and_then(|v| v.as_str()),
Some(configured.as_str())
);
}
#[test]
fn jdtls_jdk_setting() {
if cfg!(windows) {
@@ -1322,50 +1287,9 @@ fn php_sources_and_selected_interpreter() {
assert_eq!(source("/opt/homebrew/opt/[email protected]/bin/php"), "homebrew");
assert_eq!(source("/home/me/bin/php"), "path");
use crate::discover::{is_versioned_php_name, newest_first, versioned_php_in};
let sdk = |path: &str, version: &str| crate::Sdk {
path: std::path::PathBuf::from(path).into(),
name: format!("PHP {version}"),
version: Some(version.into()),
source: "path".into(),
home: None,
extra: None,
};
let mut sdks = vec![
sdk("/opt/homebrew/bin/php", "8.4.1"),
sdk("/opt/homebrew/opt/[email protected]/bin/php", "8.1.31"),
sdk("/opt/homebrew/opt/[email protected]/bin/php", "8.2.20"),
sdk("/home/me/.phpbrew/php/php-8.3.9/bin/php", "8.3.9"),
sdk("/home/me/.phpbrew/php/php-8.3.12/bin/php", "8.3.12"),
sdk("/home/me/.phpbrew/php/php-7.4.33/bin/php", "7.4.33"),
sdk("/usr/bin/php", "8.4.1"),
];
newest_first(&mut sdks);
let order: Vec<_> = sdks.iter().map(|s| s.version.clone().unwrap()).collect();
assert_eq!(order, ["8.4.1", "8.4.1", "8.3.12", "8.3.9", "8.2.20", "8.1.31", "7.4.33"]);
// Stable: on equal versions the PATH install stays first.
assert_eq!(sdks[0].path.0, std::path::PathBuf::from("/opt/homebrew/bin/php"));
for name in ["php8.1", "php81", "php8", "php8.3.exe"] {
assert!(is_versioned_php_name(name), "{name}");
}
for name in ["php", "php-cgi8.1", "phpize8.1", "phpdbg8.1", "php-fpm8.1", "php8.", "php.8", "php8.1.2x", "phpunit"]
{
assert!(!is_versioned_php_name(name), "{name}");
}
if cfg!(windows) {
return; // Unix paths
}
let bins = tempfile::tempdir().unwrap();
for name in ["php", "php8.3", "php8.1", "php-cgi8.1", "phpize8.1", "phpdbg8.1"] {
std::fs::write(bins.path().join(name), "").unwrap();
}
std::fs::create_dir(bins.path().join("php9.0")).unwrap();
assert_eq!(
versioned_php_in([bins.path().to_path_buf()]),
vec![bins.path().join("php8.1"), bins.path().join("php8.3")]
);
let tmp = tempfile::tempdir().unwrap();
let mut s = Settings::default();
assert_eq!(crate::apply::php_dir(tmp.path(), &s), None);
-63
View File
@@ -120,7 +120,6 @@ impl TerminalManager {
for (k, v) in env {
cmd.env(k, v);
}
apply_powershell_env(&shell, &mut cmd);
let child = pair
.slave
@@ -634,18 +633,6 @@ fn terminal_env(user: &BTreeMap<String, String>) -> BTreeMap<String, String> {
env
}
/// Older pwsh versions can crash while formatting a localized update banner
/// (PowerShell/PowerShell#28131). Keep explicit preferences in the final environment.
fn apply_powershell_env(shell: &str, cmd: &mut CommandBuilder) {
let name = shell.rsplit(['/', '\\']).next().unwrap_or(shell);
if cfg!(windows)
&& (name.eq_ignore_ascii_case("pwsh") || name.eq_ignore_ascii_case("pwsh.exe"))
&& cmd.get_env("POWERSHELL_UPDATECHECK").is_none_or(|v| v.is_empty())
{
cmd.env("POWERSHELL_UPDATECHECK", "Off");
}
}
/// Git Bash's MSYSTEM profile on Windows, when nobody chose one. A bare
/// `bash.exe` would run with the `MSYS` default, whose PATH leaves `git` and
/// the mingw64 tools out unless `<Git>\cmd` happens to be inherited — the Git
@@ -951,56 +938,6 @@ mod tests {
mod shell_start_on_every_platform {
use super::*;
#[test]
fn powershell_update_notifications_default_to_off_on_windows() {
for shell in ["pwsh", "pwsh.exe", "PWSH.EXE", "C:\\Program Files\\PowerShell\\7\\PwSh.ExE", "C:/pwsh/pwsh.exe"]
{
for value in [None, Some("")] {
let mut cmd = CommandBuilder::new(shell);
cmd.env_clear();
if let Some(value) = value {
cmd.env("POWERSHELL_UPDATECHECK", value);
}
apply_powershell_env(shell, &mut cmd);
let want = if cfg!(windows) { Some("Off") } else { value };
assert_eq!(cmd.get_env("POWERSHELL_UPDATECHECK"), want.map(std::ffi::OsStr::new), "{shell}");
}
}
}
#[test]
fn powershell_update_notification_preferences_are_preserved() {
for value in ["Default", "LTS", "Off"] {
let mut cmd = CommandBuilder::new("pwsh.exe");
cmd.env_clear();
cmd.env("POWERSHELL_UPDATECHECK", value);
apply_powershell_env("pwsh.exe", &mut cmd);
assert_eq!(cmd.get_env("POWERSHELL_UPDATECHECK"), Some(std::ffi::OsStr::new(value)));
}
}
#[cfg(windows)]
#[test]
fn powershell_update_notification_preferences_ignore_key_case() {
let mut cmd = CommandBuilder::new("pwsh.exe");
cmd.env_clear();
cmd.env("POWERSHELL_UPDATECHECK", "Off");
cmd.env("powershell_updatecheck", "Default");
apply_powershell_env("pwsh.exe", &mut cmd);
assert_eq!(cmd.get_env("POWERSHELL_UPDATECHECK"), Some(std::ffi::OsStr::new("Default")));
assert_eq!(cmd.iter_full_env_as_str().count(), 1);
}
#[test]
fn other_shells_keep_their_update_notification_environment() {
for shell in ["powershell.exe", "cmd.exe", "wsl.exe", "bash.exe", "C:\\pwsh.exe\\other.exe", "other-pwsh.exe"] {
let mut cmd = CommandBuilder::new(shell);
cmd.env_clear();
apply_powershell_env(shell, &mut cmd);
assert_eq!(cmd.get_env("POWERSHELL_UPDATECHECK"), None, "{shell}");
}
}
#[test]
fn posix_shells_start_as_login_shells_everywhere() {
let (shell, args) = resolve_shell(Some("C:\\Program Files\\Git\\bin\\bash.exe"), &[]);
+12 -94
View File
@@ -8,18 +8,6 @@ use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Duration;
/// A selection to format. Offsets are UTF-16 code units for Prettier; the
/// one-based line and column values are Unicode code points for Ruff.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct FormatRange {
pub start_offset: u32,
pub end_offset: u32,
pub start_line: u32,
pub start_col: u32,
pub end_line: u32,
pub end_col: u32,
}
/// Walk from the file's directory upward, including workspace parents (monorepos).
pub fn find_up(file: &Path, relative: &Path) -> Option<PathBuf> {
file.parent()?.ancestors().map(|dir| dir.join(relative)).find(|p| p.is_file())
@@ -44,13 +32,9 @@ fn package_bin(package: &Path, tool: &str) -> IdeResult<PathBuf> {
Ok(path)
}
/// Prettier and Ruff format ranges for some languages (`config.range`); oxfmt formats whole files only.
/// Prettier formats a range for some languages (`config.range`); oxfmt formats whole files only.
pub fn supports_range(tool: &str, config: &FormatterConfig) -> bool {
matches!(tool, "prettier" | "ruff") && config.range
}
fn is_formatter(tool: &str) -> bool {
crate::registry::find(tool).is_some_and(|t| t.kind == crate::ToolKind::Formatter || t.name == "ruff")
tool == "prettier" && config.range
}
impl Tools {
@@ -60,7 +44,7 @@ impl Tools {
}
fn resolve_formatter_command(&self, tool: &str, file: &Path, custom: Option<&Path>) -> IdeResult<Command> {
if !is_formatter(tool) {
if crate::registry::find(tool).is_none_or(|t| t.kind != crate::ToolKind::Formatter) {
return Err(IdeError::unavailable(format!("Unknown external formatter: {tool}")));
}
let package = Path::new("node_modules").join(tool);
@@ -89,36 +73,18 @@ impl Tools {
}
}
/// Every tool resolves its configuration itself. Prettier's project configuration
/// overrides the editor indentation passed here; oxfmt and Ruff read their own
/// configuration files. Ruff also discovers the nearest `pyproject.toml`,
/// `ruff.toml`, or `.ruff.toml` from the stdin filename.
/// Both tools resolve their configuration and plugins themselves. Prettier's
/// project configuration overrides the editor indentation passed here; oxfmt has
/// no such flags and reads its configuration and `.editorconfig` only.
pub fn arguments(
tool: &str,
config: &FormatterConfig,
file: &Path,
tab_size: u32,
insert_spaces: bool,
range: Option<FormatRange>,
config_file: Option<&Path>,
range: Option<(u32, u32)>,
) -> Vec<OsString> {
let mut args: Vec<OsString> = Vec::new();
if tool == "ruff" {
args.push("format".into());
if let Some(config_file) = config_file {
args.extend(["--config".into(), config_file.as_os_str().to_owned()]);
}
args.extend(["--stdin-filename".into(), file.as_os_str().to_owned(), "-".into()]);
if supports_range(tool, config)
&& let Some(range) = range
{
args.extend([
"--range".into(),
format!("{}:{}-{}:{}", range.start_line, range.start_col, range.end_line, range.end_col).into(),
]);
}
return args;
}
if tool == "prettier" {
let parser = if config.parser == "markdown" && file.extension().is_some_and(|s| s == "mdx") {
"mdx"
@@ -141,14 +107,9 @@ pub fn arguments(
}
args.extend(["--stdin-filepath".into(), file.as_os_str().to_owned()]);
if supports_range(tool, config)
&& let Some(range) = range
&& let Some((start, end)) = range
{
args.extend([
"--range-start".into(),
range.start_offset.to_string().into(),
"--range-end".into(),
range.end_offset.to_string().into(),
]);
args.extend(["--range-start".into(), start.to_string().into(), "--range-end".into(), end.to_string().into()]);
}
// CLI defaults only find ignores in cwd; explicitly pass the nearest ancestor's files.
for name in [".prettierignore", ".gitignore"] {
@@ -220,15 +181,8 @@ mod tests {
("vue", true),
] {
let config = FormatterConfig { tool: "prettier".into(), parser: parser.into(), range: supports_range };
let range = Some(FormatRange {
start_offset: 3,
end_offset: 9,
start_line: 1,
start_col: 1,
end_line: 1,
end_col: 7,
});
let args = arguments("prettier", &config, Path::new("/project with spaces/file.md"), 4, false, range, None);
let args =
arguments("prettier", &config, Path::new("/project with spaces/file.md"), 4, false, Some((3, 9)));
let args: Vec<_> = args.iter().map(|a| a.to_string_lossy()).collect();
assert!(args.windows(2).any(|a| a == ["--config-precedence", "file-override"]));
assert!(args.windows(2).any(|a| a == ["--stdin-filepath", "/project with spaces/file.md"]));
@@ -263,9 +217,7 @@ mod tests {
);
let config = FormatterConfig { tool: "prettier".into(), parser: "typescript".into(), range: true };
let range =
Some(FormatRange { start_offset: 3, end_offset: 9, start_line: 1, start_col: 1, end_line: 1, end_col: 7 });
let args = arguments("oxfmt", &config, &file, 4, false, range, None);
let args = arguments("oxfmt", &config, &file, 4, false, Some((3, 9)));
let ignore = root.join(".prettierignore").into_os_string();
assert_eq!(
args[..4],
@@ -277,38 +229,4 @@ mod tests {
let unknown = tools.formatter_command("rust-analyzer", &file, None).unwrap_err();
assert!(unknown.message.contains("Unknown external formatter"), "{}", unknown.message);
}
#[test]
fn ruff_formats_stdin_and_uses_its_config_and_range_flags() {
let config = FormatterConfig { tool: "ruff".into(), parser: "python".into(), range: true };
let file = Path::new("/project with spaces/src/example.py");
let range = Some(FormatRange {
start_offset: 10,
end_offset: 20,
start_line: 2,
start_col: 5,
end_line: 4,
end_col: 1,
});
let config_file = Path::new("/shared/config/ruff.toml");
let args: Vec<_> = arguments("ruff", &config, file, 4, true, range, Some(config_file))
.into_iter()
.map(|arg| arg.to_string_lossy().into_owned())
.collect();
assert_eq!(
args,
[
"format",
"--config",
"/shared/config/ruff.toml",
"--stdin-filename",
"/project with spaces/src/example.py",
"-",
"--range",
"2:5-4:1",
]
);
assert!(supports_range("ruff", &config));
assert!(!args.iter().any(|arg| arg == "--stdin-filepath"));
}
}
+1 -9
View File
@@ -19,15 +19,7 @@ fn format(
};
let mut cmd = tools.formatter_command("prettier", path, None)?;
cmd.current_dir(path.parent().unwrap());
let range = range.map(|(start_offset, end_offset)| formatter::FormatRange {
start_offset,
end_offset,
start_line: 1,
start_col: 1,
end_line: 1,
end_col: 1,
});
cmd.args(formatter::arguments("prettier", &config, path, 4, true, range, None));
cmd.args(formatter::arguments("prettier", &config, path, 4, true, range));
formatter::run("prettier", cmd, text, &CancellationToken::new())
}
-67
View File
@@ -1,67 +0,0 @@
//! `cargo test -p ide-tools --test ruff -- --ignored --nocapture`
use ide_core::CancellationToken;
use ide_language::FormatterConfig;
use ide_tools::{Tools, formatter};
use std::path::Path;
fn format(
tools: &Tools,
path: &Path,
text: &str,
range: Option<formatter::FormatRange>,
config_file: Option<&Path>,
) -> ide_core::IdeResult<String> {
let config = FormatterConfig { tool: "ruff".into(), parser: "python".into(), range: true };
let mut cmd = tools.formatter_command("ruff", path, None)?;
cmd.current_dir(path.parent().unwrap());
cmd.args(formatter::arguments("ruff", &config, path, 4, true, range, config_file));
formatter::run("ruff", cmd, text, &CancellationToken::new())
}
#[test]
#[ignore = "requires ruff on PATH"]
fn real_ruff_reads_project_config_formats_stdin_and_supports_ranges() {
let temp = tempfile::tempdir().unwrap();
let tools = Tools::new(&temp.path().join("app-data"));
if tools.info("ruff", &[]).unwrap().status == ide_tools::ToolStatus::Missing {
eprintln!("SKIP real Ruff: not on PATH");
return;
}
let project = temp.path().join("project with spaces");
std::fs::create_dir_all(&project).unwrap();
std::fs::write(project.join("pyproject.toml"), "[tool.ruff.format]\nquote-style = \"single\"\n").unwrap();
let path = project.join("example.py");
let input = "def greet(name):\n return {\"message\": name}\n";
let output = format(&tools, &path, input, None, None).unwrap();
assert_eq!(output, "def greet(name):\n return {'message': name}\n");
assert!(!path.exists(), "formatting must never write the file");
let range = formatter::FormatRange {
start_offset: 0,
end_offset: 0,
start_line: 2,
start_col: 1,
end_line: 2,
end_col: 100,
};
let mixed = "def untouched( a,b ):\n return {\"message\": name}\n";
let output = format(&tools, &path, mixed, Some(range), None).unwrap();
assert!(output.starts_with("def untouched( a,b ):"), "{output}");
assert!(output.contains("return {'message': name}"), "{output}");
let shared = temp.path().join("shared config");
std::fs::create_dir_all(&shared).unwrap();
let shared_config = shared.join("ruff.toml");
std::fs::write(&shared_config, "[format]\nquote-style = \"double\"\n").unwrap();
std::fs::remove_file(project.join("pyproject.toml")).unwrap();
let output = format(&tools, &path, input, None, Some(&shared_config)).unwrap();
assert_eq!(output, "def greet(name):\n return {\"message\": name}\n");
let invalid = "def broken(:\n pass\n";
let error = format(&tools, &path, invalid, None, Some(&shared_config)).unwrap_err();
assert!(error.message.contains("Failed to parse"), "{}", error.message);
eprintln!(
"PASS: real Ruff config discovery, custom config file, stdin formatting, quote style, range, invalid syntax diagnostics, no disk writes"
);
}
-18
View File
@@ -302,18 +302,6 @@ pub struct ChangedFile {
pub old_path: Option<String>,
}
/// Added and deleted lines of one file between two revisions (`git diff --numstat`).
/// A rename keeps the counts of the content change; a pure rename is `0`/`0`.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct FileLineStats {
/// Path after the change (the only path, when the file was not renamed).
pub path: String,
pub old_path: Option<String>,
pub added: u32,
pub removed: u32,
}
/// A file changed across several commits: its content at `before` ↔ at `after`.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
@@ -1260,12 +1248,6 @@ impl Repo {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
ops::diff_revisions(self, from, to)
}
/// Added and deleted lines between `from` and `to`. `paths` limits the diff to those
/// repository-relative paths (both names of a rename); empty diffs every file.
pub fn diff_numstat(&self, from: &str, to: &str, paths: &[String]) -> IdeResult<Vec<FileLineStats>> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
ops::diff_numstat(self, from, to, paths)
}
/// Tracked files whose working-tree content differs from `rev`.
pub fn diff_with_worktree(&self, rev: &str) -> IdeResult<Vec<ChangedFile>> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
+1 -52
View File
@@ -3,7 +3,7 @@
use crate::cli::{self, run};
use crate::repo::{gx, resolve};
use crate::{ChangeKind, ChangedFile, FileLineStats, MergeSides, RebaseAction, RebaseEntry, Repo, ResetMode};
use crate::{ChangeKind, ChangedFile, MergeSides, RebaseAction, RebaseEntry, Repo, ResetMode};
use ide_core::{CancellationToken, IdeError, IdeResult};
/// The in-progress operation git can continue / abort / skip.
@@ -394,44 +394,6 @@ pub(crate) fn parse_numstat(text: &str) -> Vec<(String, u32, u32)> {
.collect()
}
/// Added and deleted lines between two revisions. `paths` limits the diff (pass both names of
/// a rename); an empty list, or one too long for the command line, diffs every file.
pub(crate) fn diff_numstat(r: &Repo, from: &str, to: &str, paths: &[String]) -> IdeResult<Vec<FileLineStats>> {
check_commits(&[from.to_string(), to.to_string()])?;
let mut args = vec!["diff", "--numstat", "-z", "-M", "--no-ext-diff", "--no-textconv", from, to];
// Windows command lines are short; past this, the unfiltered diff still contains these paths.
let limited = !paths.is_empty() && paths.iter().map(|p| p.len() + 1).sum::<usize>() <= 16_000;
if limited {
args.push("--");
args.extend(paths.iter().filter(|p| !p.is_empty()).map(String::as_str));
}
let out = run(r.root(), &args, None)?;
Ok(parse_numstat_z(&out.stdout_str()))
}
/// `git diff --numstat -z -M`: `added\tremoved\tpath\0`, or `added\tremoved\0old\0new\0` for a rename.
/// Binary files (`-`) count as 0.
pub(crate) fn parse_numstat_z(text: &str) -> Vec<FileLineStats> {
let mut parts = text.split('\0').filter(|s| !s.is_empty());
let mut out = Vec::new();
while let Some(rec) = parts.next() {
let mut fields = rec.splitn(3, '\t');
let added = fields.next().unwrap_or("0").parse().unwrap_or(0);
let removed = fields.next().unwrap_or("0").parse().unwrap_or(0);
let (path, old_path) = if let Some(path) = fields.next().filter(|p| !p.is_empty()) {
(path.to_owned(), None)
} else {
let (Some(old), Some(new)) = (parts.next(), parts.next()) else { break };
(new.to_owned(), Some(old.to_owned()))
};
if !path.is_empty() {
out.push(FileLineStats { path, old_path, added, removed });
}
}
out.sort_by(|a, b| a.path.cmp(&b.path).then(a.old_path.cmp(&b.old_path)));
out
}
/// Files that differ between two revisions (Update Project's "N files updated").
pub(crate) fn diff_revisions(r: &Repo, from: &str, to: &str) -> IdeResult<Vec<ChangedFile>> {
check_commits(&[from.to_string(), to.to_string()])?;
@@ -495,17 +457,4 @@ mod tests {
vec![("src/a.rs".to_owned(), 3, 1), ("img.png".to_owned(), 0, 0), ("dir/b c.txt".to_owned(), 0, 7)]
);
}
#[test]
fn numstat_z_parsing_counts_renames_and_binaries_as_zero() {
let text = "1\t0\timg.bin\u{0}2\t1\u{0}file.txt\u{0}renamed.txt\u{0}-\t-\tbin.dat\u{0}";
assert_eq!(
parse_numstat_z(text),
vec![
FileLineStats { path: "bin.dat".into(), old_path: None, added: 0, removed: 0 },
FileLineStats { path: "img.bin".into(), old_path: None, added: 1, removed: 0 },
FileLineStats { path: "renamed.txt".into(), old_path: Some("file.txt".into()), added: 2, removed: 1 },
]
);
}
}
-36
View File
@@ -594,42 +594,6 @@ fn commit_files_kinds() {
assert!(repo.commit_files("HEAD").unwrap().len() == 4);
}
/// Line counts follow the same first-parent diff as the commit's file list, including renames.
#[test]
fn diff_numstat_counts_added_deleted_and_renames() {
let fx = Fixture::new();
fx.write("keep.txt", "a\nb\nc\n");
fx.write("mod.txt", "m\n");
fx.write("old.txt", "a fairly long line of content\nanother line\nand a third\n");
let root = fx.commit_all("root");
fx.write("mod.txt", "m\nmore\n");
std::fs::remove_file(fx.path("keep.txt")).unwrap();
fx.write("fresh.txt", "n1\nn2\n");
fx.git(&["mv", "old.txt", "new.txt"]);
fx.write("new.txt", "a fairly long line of content\nanother line\nchanged\n");
let c = fx.commit_all("changes");
let repo = fx.repo();
let stats = repo.diff_numstat(&root, &c, &[]).unwrap();
let find = |p: &str| stats.iter().find(|f| f.path == p).unwrap_or_else(|| panic!("{p}: {stats:#?}"));
assert_eq!((find("mod.txt").added, find("mod.txt").removed), (1, 0));
assert_eq!((find("keep.txt").added, find("keep.txt").removed), (0, 3));
assert_eq!((find("fresh.txt").added, find("fresh.txt").removed), (2, 0));
assert_eq!(find("new.txt").old_path.as_deref(), Some("old.txt"));
assert_eq!((find("new.txt").added, find("new.txt").removed), (1, 1));
let only = repo.diff_numstat(&root, &c, &["mod.txt".into()]).unwrap();
assert_eq!(only.iter().map(|f| f.path.as_str()).collect::<Vec<_>>(), vec!["mod.txt"]);
// The log passes both names, which is what lets git still report the rename.
let renamed = repo.diff_numstat(&root, &c, &["old.txt".into(), "new.txt".into()]).unwrap();
assert_eq!(renamed.len(), 1, "{renamed:#?}");
assert_eq!(renamed[0].old_path.as_deref(), Some("old.txt"));
assert_eq!((renamed[0].added, renamed[0].removed), (1, 1));
let created = repo.diff_numstat("4b825dc642cb6eb9a060e54bf8d69288fbee4904", &root, &["mod.txt".into()]).unwrap();
assert_eq!((created[0].added, created[0].removed), (1, 0));
}
/// IDEA's multi-commit Git Log selection: per file, before the first commit touching it ↔
/// after the last one, contiguous or not.
#[test]
+4 -21
View File
@@ -868,18 +868,17 @@ impl Documents {
.then(|| st.path.clone())
})
.collect();
self.reload_from_disk(&paths, force_clean, true);
self.reload_from_disk(&paths, force_clean);
}
/// Re-reads from disk after an external change. Clean documents reload
/// silently; dirty ones are left alone and reported via `IdeEvent::Notify`.
/// Changes matching our own last save are ignored.
pub fn on_disk_changed(&self, paths: &[PathBuf]) {
self.reload_from_disk(paths, false, false);
self.reload_from_disk(paths, false);
}
/// `dedup`: blind scans skip files whose `(mtime, length)` stamp still matches.
fn reload_from_disk(&self, paths: &[PathBuf], force_clean: bool, dedup: bool) {
fn reload_from_disk(&self, paths: &[PathBuf], force_clean: bool) {
for path in paths {
let Some(id) = self.by_path(path) else { continue };
let result = self.with_doc(id, |st| {
@@ -888,23 +887,7 @@ impl Documents {
}
let Ok(meta) = std::fs::metadata(&st.path) else { return Ok(()) };
let mtime = ide_vfs::mtime_ms(&meta);
let unchanged = mtime == st.disk_mtime && meta.len() == st.disk_len;
// Blind scans trust the stamp. OS events are authoritative, but they also
// echo our own saves: a clean document whose stamp matches is re-checked by
// bytes — an external write that preserved both length and mtime differs
// from what saving this buffer would write, our own save's echo does not.
// Native-to-ascii properties documents and dirty documents keep the stamp
// dedup: the former's disk bytes legitimately differ from the buffer text,
// the latter warn once per disk state.
if unchanged
&& (!force_clean || st.dirty)
&& (dedup
|| st.dirty
|| st.native_to_ascii
|| ide_vfs::encode(&st.doc.text_for_save(), &st.encoding, st.has_bom)
.map(|bytes| bytes == std::fs::read(&st.path).unwrap_or_default())
.unwrap_or(true))
{
if mtime == st.disk_mtime && meta.len() == st.disk_len && (!force_clean || st.dirty) {
return Ok(());
}
if st.dirty {
-4
View File
@@ -479,10 +479,6 @@ mod tests {
let text = std::fs::read_to_string(added.join("settings.toml")).unwrap();
assert_eq!(text, "[languageServers.ruff]\nargs = [\"server\"]\ncommand = \"ruff\"\nlanguages = [\"python\"]\n");
let s =
store.set(SettingsScope::Global, "languages.ruff.configurationDirectory", json!("shared/ruff")).unwrap();
assert_eq!(s.languages.ruff.configuration_directory, "shared/ruff");
// Project layer is created on demand and wins.
let s = store.set(SettingsScope::Project, "editor.fontSize", json!(12)).unwrap();
assert_eq!(s.editor.font_size, 12.0);
-40
View File
@@ -403,46 +403,6 @@ fn external_changes() {
f.docs.on_disk_changed(&[f.root.join("other.txt")]);
}
/// An IDE operation that rewrites a file with the same length and mtime still refreshes
/// the clean buffer.
#[test]
fn own_disk_change_preserving_length_and_mtime_reloads_a_clean_document() {
let f = fixture();
let path = f.root.join("stamp.txt");
fs::write(&path, "first").unwrap();
let id = f.docs.open(&path).unwrap().id;
let mtime = fs::metadata(&path).unwrap().modified().unwrap();
fs::write(&path, "other").unwrap();
fs::File::options().write(true).open(&path).unwrap().set_modified(mtime).unwrap();
f.docs.sync_after_disk_change();
assert_eq!(f.docs.snapshot(id).unwrap().text, "other");
}
/// An external write that keeps the byte length and the mtime (timestamp-preserving
/// tools, coarse-granularity filesystems) leaves the `(mtime, length)` stamp equal:
/// the OS event is still authoritative, so a clean document reloads.
#[test]
fn external_change_preserving_length_and_mtime_reloads_a_clean_document() {
let f = fixture();
let path = f.root.join("stamp.txt");
fs::write(&path, "first").unwrap(); // 5 bytes
let id = f.docs.open(&path).unwrap().id;
let mtime = fs::metadata(&path).unwrap().modified().unwrap();
fs::write(&path, "other").unwrap(); // 5 bytes, different content
fs::File::options().write(true).open(&path).unwrap().set_modified(mtime).unwrap();
f.docs.on_disk_changed(std::slice::from_ref(&path));
let snap = f.docs.snapshot(id).unwrap();
assert_eq!(snap.text, "other");
assert!(!snap.dirty);
// The reload recorded the new stamp: repeating the same event changes nothing.
let before = f.events.lock().len();
f.docs.on_disk_changed(std::slice::from_ref(&path));
assert_eq!(f.events.lock().len(), before);
assert_eq!(f.docs.snapshot(id).unwrap().text, "other");
}
#[test]
fn encodings_and_read_only() {
let f = fixture();
+2 -9
View File
@@ -324,7 +324,7 @@ a token.
<!-- BEGIN GENERATED TOKEN REFERENCE: pnpm gen:theme-docs -->
242 themeable tokens: 121 in the UI theme file, 121 in the editor colour scheme file.
235 themeable tokens: 121 in the UI theme file, 114 in the editor colour scheme file.
### Surfaces (UI theme)
@@ -436,18 +436,11 @@ a token.
| `--syn-constant` | `#c77dbb` | `#871094` |
| `--syn-python-local-variable` | `var(--syn-variable)` | `var(--syn-variable)` |
| `--syn-python-global-variable` | `var(--syn-global)` | `var(--syn-global)` |
| `--syn-python-instance-attribute` | `var(--syn-variable)` | `var(--syn-variable)` |
| `--syn-python-instance-attribute` | `var(--syn-property)` | `var(--syn-property)` |
| `--syn-python-class-attribute` | `var(--syn-property)` | `var(--syn-property)` |
| `--syn-python-constant` | `var(--syn-constant)` | `var(--syn-constant)` |
| `--syn-python-property` | `var(--syn-property)` | `var(--syn-property)` |
| `--syn-python-type-parameter` | `var(--syn-type)` | `var(--syn-type)` |
| `--syn-python-builtin` | `var(--syn-builtin)` | `var(--syn-builtin)` |
| `--syn-python-predefined-definition` | `var(--syn-predefined)` | `var(--syn-predefined)` |
| `--syn-python-predefined-usage` | `var(--syn-predefined)` | `var(--syn-predefined)` |
| `--syn-python-fstring` | `#a5c261` | `var(--syn-string)` |
| `--syn-python-bytes` | `#a5c261` | `var(--syn-string)` |
| `--syn-php-variable` | `var(--syn-property)` | `var(--syn-property)` |
| `--syn-php-function-call` | `var(--syn-function)` | `var(--syn-function)` |
| `--syn-builtin` | `#8888c6` | `#000080` |
| `--syn-self` | `#94558d` | `#94558d` |
| `--syn-keyword-arg` | `#aa4926` | `#660099` |
-70
View File
@@ -1,70 +0,0 @@
/**
* Create New Branch: spaces typed or pasted into the name become separators on the fly, as in
* IDEA, so the name stays valid. Git is stubbed at the IPC layer.
*/
import type { Page } from "@playwright/test";
import { expect, ipcCalls, stubDesktop, test } from "./fixtures";
const ROOT = "/dev/acme-api";
async function openNewBranchDialog(page: Page) {
await stubDesktop(page);
await page.evaluate(async (root) => {
const h = window.__ipc.handlers;
h.vcs_roots = () => [{ root, operation: null, head: "main", headCommit: "1111111" }];
h.vcs_branch_info = () => ({
head: "main",
headCommit: "1111111",
upstream: null,
ahead: 0,
behind: 0,
operation: null,
});
h.vcs_status = () => [];
h.vcs_create_branch = () => null;
const vcsModule = "/src/features/vcs/store.ts";
const { useVcs, refreshNow } = await import(/* @vite-ignore */ vcsModule);
useVcs.setState({ available: true, root, repo: root, branchesLoaded: false, stashesLoaded: false });
await refreshNow();
const actionsModule = "/src/platform/actions.ts";
const { executeAction } = await import(/* @vite-ignore */ actionsModule);
void executeAction("vcs.newBranch");
}, ROOT);
const dialog = page.getByRole("dialog", { name: "Create New Branch" });
const input = dialog.getByRole("textbox", { name: "New branch name:" });
await expect(input).toBeVisible();
return { dialog, input };
}
test("a typed space becomes a separator and Create stays enabled", async ({ workbench: page }) => {
const { dialog, input } = await openNewBranchDialog(page);
await input.fill("");
await input.pressSequentially("fix login redirect");
await expect(input).toHaveValue("fix-login-redirect");
await expect(dialog.getByText("Branch name contains invalid characters")).toHaveCount(0);
await expect(dialog.getByRole("button", { name: "Create", exact: true })).toBeEnabled();
await dialog.getByRole("button", { name: "Create", exact: true }).click();
const [call] = await ipcCalls(page, "vcs_create_branch");
expect(call).toMatchObject({ name: "fix-login-redirect" });
});
test("a space typed in the middle keeps the caret after it", async ({ workbench: page }) => {
const { input } = await openNewBranchDialog(page);
await input.fill("ab");
await input.evaluate((el: HTMLInputElement) => el.setSelectionRange(1, 1));
await page.keyboard.press("Space");
await expect(input).toHaveValue("a-b");
await page.keyboard.type("x");
await expect(input).toHaveValue("a-xb");
});
test("pasted text has its spaces replaced", async ({ workbench: page }) => {
const { dialog, input } = await openNewBranchDialog(page);
await input.fill("");
await input.evaluate((el: HTMLInputElement) => {
el.focus();
document.execCommand("insertText", false, "feature/add new thing");
});
await expect(input).toHaveValue("feature/add-new-thing");
await expect(dialog.getByRole("button", { name: "Create", exact: true })).toBeEnabled();
});
-9
View File
@@ -26,9 +26,6 @@ const attributes = {
"PY.SELF_PARAMETER": { FOREGROUND: "aa7788" },
"PY.BUILTIN_NAME": { FOREGROUND: "bb8899" },
"PY.KEYWORD_ARGUMENT": { FOREGROUND: "cc99aa" },
"PY.STRING.F": { FOREGROUND: "aa8811" },
"PY.PREDEFINED_DEFINITION": { FOREGROUND: "bb9922" },
"PY.PREDEFINED_USAGE": { FOREGROUND: "ccaa33" },
OPENIDEA_pyLocalVariable: { FOREGROUND: "aa1101" },
OPENIDEA_pyGlobalVariable: { FOREGROUND: "aa2202" },
OPENIDEA_pyInstanceAttribute: { FOREGROUND: "aa3303" },
@@ -55,8 +52,6 @@ new EditorView({
parent: document.body,
state: EditorState.create({
doc: `MAX_SIZE: Final = 10
type Alias = list[int]
fstr = f"value={1}"
name = "global"
T = TypeVar("T")
@@ -74,13 +69,9 @@ class Foo[T]:
self.instance_attr = value
print(local, MAX_SIZE, name, T)
self.run(whatever=1) # Comment
print(self.__doc__)
@property
def size(self) -> int:
return self.instance_attr
def unused(param):
return 1
`,
extensions: [
support!,
+2 -10
View File
@@ -7,11 +7,8 @@ test("Python syntax supplements retain PyCharm colours without a language server
await expect(page.locator(".cm-content")).toBeVisible();
await page.screenshot({ path: info.outputPath("before.png") });
await page.goto("/e2e/pythonHighlight.html");
await expect(page.locator(".cm-sem-python-definition").filter({ hasText: /^outer$/ })).toHaveText("outer");
await expect(page.locator(".cm-sem-python-definition").filter({ hasText: /^outer$/ })).toHaveCSS(
"color",
"rgb(34, 68, 102)",
);
await expect(page.locator(".cm-sem-python-definition")).toHaveText("outer");
await expect(page.locator(".cm-sem-python-definition")).toHaveCSS("color", "rgb(34, 68, 102)");
await expect(page.locator(".cm-sem-python-nested")).toHaveCSS("color", "rgb(51, 85, 119)");
await expect(page.locator(".cm-sem-python-docstring")).toHaveCSS("color", "rgb(102, 136, 170)");
await expect(page.locator(".cm-sem-python-docstring")).toHaveCSS("text-decoration-line", "none");
@@ -49,11 +46,6 @@ test("Python syntax supplements retain PyCharm colours without a language server
await expect(page.locator(".cm-sem-python-constant").first()).toHaveCSS("color", "rgb(170, 85, 5)");
await expect(page.locator(".cm-sem-python-property").first()).toHaveCSS("color", "rgb(170, 102, 6)");
await expect(page.locator(".cm-sem-python-type-parameter").first()).toHaveCSS("color", "rgb(170, 119, 7)");
await expect(page.locator(".cm-sem-python-fstring").first()).toHaveCSS("color", "rgb(170, 136, 17)");
await expect(page.locator(".cm-sem-python-predefined-definition")).toHaveCSS("color", "rgb(187, 153, 34)");
await expect(page.locator(".cm-sem-python-predefined-usage")).toHaveCSS("color", "rgb(204, 170, 51)");
await expect(page.locator(".cm-sem-keyword").filter({ hasText: /^type$/ })).toHaveCSS("color", "rgb(17, 34, 51)");
await expect(page.locator(".cm-sem-unused")).toHaveCSS("color", "rgb(111, 115, 122)");
expect(errors).toEqual([]);
await page.screenshot({ path: info.outputPath("python-highlighting.png") });
});
+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");
-1
View File
@@ -2,4 +2,3 @@ id = "log"
name = "Log"
extensions = ["log"]
indent = { tab-size = 4, insert-spaces = true }
ui-mode = "Log"
-1
View File
@@ -1,6 +1,5 @@
id = "python"
name = "Python"
formatter = { tool = "ruff", parser = "python", range = true }
extensions = ["py", "pyi", "pyw"]
filenames = ["SConstruct", "SConscript"]
first-line = "^#!.*\\bpython[0-9.]*\\b"
@@ -24,18 +24,11 @@
"--syn-constant": "#e58fa3",
"--syn-python-local-variable": "#d9dfe8",
"--syn-python-global-variable": "#d9dfe8",
"--syn-python-instance-attribute": "#d9dfe8",
"--syn-python-instance-attribute": "#b9a3f0",
"--syn-python-class-attribute": "#b9a3f0",
"--syn-python-constant": "#e58fa3",
"--syn-python-property": "#b9a3f0",
"--syn-python-type-parameter": "#8fd4c9",
"--syn-python-builtin": "#6fc3df",
"--syn-python-predefined-definition": "#c4a0f2",
"--syn-python-predefined-usage": "#c4a0f2",
"--syn-python-fstring": "#9ccc7a",
"--syn-python-bytes": "#9ccc7a",
"--syn-php-variable": "#b9a3f0",
"--syn-php-function-call": "#f2d07a",
"--syn-builtin": "#6fc3df",
"--syn-self": "#d6849c",
"--syn-keyword-arg": "#c4a0f2",
-2
View File
@@ -309,7 +309,6 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
vcs::vcs_compare_scope,
vcs::vcs_compare_paths,
vcs::vcs_diff_revisions,
vcs::vcs_diff_numstat,
vcs::vcs_merge_base,
vcs::vcs_create_patch,
vcs::vcs_patch_preview,
@@ -398,7 +397,6 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
tools::jdk_download,
sdk::sdk_detect,
sdk::sdk_python_effective,
sdk::sdk_php_effective,
sdk::sdk_php_language_level,
sdk::sdk_project_libraries,
sdk::sdk_locate_tools,
-7
View File
@@ -24,13 +24,6 @@ pub fn sdk_python_effective(window: Window, state: State<'_, AppState>) -> IdeRe
ide_host::ops::sdk::sdk_python_effective(&state.host, window.label())
}
/// The PHP interpreter chosen in Settings › Languages & Frameworks › PHP; `None`: the `php` on `PATH`.
#[tauri::command]
#[specta::specta]
pub fn sdk_php_effective(window: Window, state: State<'_, AppState>) -> IdeResult<Option<ide_core::IpcPath>> {
ide_host::ops::sdk::sdk_php_effective(&state.host, window.label())
}
/// The PHP language level of the project `path` belongs to: the effective one and Auto's.
#[tauri::command]
#[specta::specta]
-15
View File
@@ -631,21 +631,6 @@ pub async fn vcs_diff_revisions(
ide_host::ops::vcs::vcs_diff_revisions(&state.host, window.label(), repo, from, to).await
}
/// Added and deleted lines between two revisions (Git Log › lines added and deleted).
/// `paths` limits the diff to those repository-relative paths; empty diffs every file.
#[tauri::command]
#[specta::specta]
pub async fn vcs_diff_numstat(
window: Window,
state: State<'_, AppState>,
repo: Option<PathBuf>,
from: String,
to: String,
paths: Vec<String>,
) -> IdeResult<Vec<FileLineStats>> {
ide_host::ops::vcs::vcs_diff_numstat(&state.host, window.label(), repo, from, to, paths).await
}
/// The best common ancestor of `a` and `b` (`git merge-base`), if any.
#[tauri::command]
#[specta::specta]
+3 -25
View File
@@ -577,11 +577,6 @@ export const commands = {
vcsComparePaths: (paths: string[], revision: string, repositories: string[] | null, request: RequestId) => typedError<CompareResult, IdeError>(__TAURI_INVOKE("vcs_compare_paths", { paths, revision, repositories, request })),
/** Files that differ between two revisions (the "N files updated" of Update Project). */
vcsDiffRevisions: (repo: string | null, from: string, to: string) => typedError<ChangedFile[], IdeError>(__TAURI_INVOKE("vcs_diff_revisions", { repo, from, to })),
/**
* Added and deleted lines between two revisions (Git Log › lines added and deleted).
* `paths` limits the diff to those repository-relative paths; empty diffs every file.
*/
vcsDiffNumstat: (repo: string | null, from: string, to: string, paths: string[]) => typedError<FileLineStats[], IdeError>(__TAURI_INVOKE("vcs_diff_numstat", { repo, from, to, paths })),
/** The best common ancestor of `a` and `b` (`git merge-base`), if any. */
vcsMergeBase: (repo: string | null, a: string, b: string) => typedError<string | null, IdeError>(__TAURI_INVOKE("vcs_merge_base", { repo, a, b })),
vcsCreatePatch: (repo: string | null, source: PatchSource) => typedError<string, IdeError>(__TAURI_INVOKE("vcs_create_patch", { repo, source })),
@@ -772,8 +767,6 @@ export const commands = {
sdkDetect: (kind: SdkKind) => typedError<Sdk[], IdeError>(__TAURI_INVOKE("sdk_detect", { kind })),
/** The interpreter the project uses: the configured one, else its virtualenv. */
sdkPythonEffective: () => typedError<string | null, IdeError>(__TAURI_INVOKE("sdk_python_effective")),
/** The PHP interpreter chosen in Settings › Languages & Frameworks › PHP; `None`: the `php` on `PATH`. */
sdkPhpEffective: () => typedError<string | null, IdeError>(__TAURI_INVOKE("sdk_php_effective")),
/** The PHP language level of the project `path` belongs to: the effective one and Auto's. */
sdkPhpLanguageLevel: (path: string) => typedError<PhpLanguageLevels, IdeError>(__TAURI_INVOKE("sdk_php_language_level", { path })),
/**
@@ -3491,14 +3484,12 @@ export type ExtensionCount = {
};
/** A formatter that runs outside the language servers, with its own settings group. */
export type ExternalFormatter = "prettier" | "oxfmt" | "ruff";
export type ExternalFormatter = "prettier" | "oxfmt";
/** An external formatter (Prettier, oxfmt, Ruff) and the languages it formats. */
/** An external formatter (Prettier, oxfmt) and the languages it formats. */
export type ExternalFormatterSettings = {
/** Empty path: project node_modules (Prettier/oxfmt), managed installation, then PATH. */
/** Empty path: project node_modules, managed installation, then PATH. */
path?: string,
/** Ruff only: directory containing its configuration, or a configuration file. */
configurationDirectory?: string,
languages?: string[],
/** Prefer this formatter even when a language server offers formatting. */
prefer?: boolean,
@@ -3585,18 +3576,6 @@ export type FileIdentifiers = {
occurrences: IdentifierOccurrence[],
};
/**
* Added and deleted lines of one file between two revisions (`git diff --numstat`).
* A rename keeps the counts of the content change; a pure rename is `0`/`0`.
*/
export type FileLineStats = {
/** Path after the change (the only path, when the file was not renamed). */
path: string,
oldPath: string | null,
added: number,
removed: number,
};
export type FileMatch = {
path: IpcPath,
/** Path relative to the project root, as displayed. */
@@ -4819,7 +4798,6 @@ export type LanguagesSettings = {
typescript?: TypeScriptSettings,
prettier?: ExternalFormatterSettings,
oxfmt?: ExternalFormatterSettings,
ruff?: ExternalFormatterSettings,
java?: JavaSettings,
ruby?: RubySettings,
markdown?: MarkdownSettings,
+1 -5
View File
@@ -4,10 +4,7 @@
* -effect`, set by Settings › Editor › Color Scheme overrides.
*/
import { HighlightStyle, type TagStyle } from "@codemirror/language";
import { Tag, tags as t } from "@lezer/highlight";
/** Log warnings: the UI warning colour, since no syntax token reads as one in every scheme. */
export const logWarning = Tag.define("logWarning");
import { tags as t, type Tag } from "@lezer/highlight";
function token(
tag: Tag | Tag[],
@@ -89,7 +86,6 @@ export const openideaHighlightStyle = HighlightStyle.define([
token(t.url, "function", { italic: "italic" }),
token(t.monospace, "markup-code"),
token(t.contentSeparator, "keyword"),
{ tag: logWarning, color: "var(--warning)" },
{ tag: t.inserted, color: "var(--vcs-added)" },
{ tag: t.deleted, color: "var(--danger)" },
{ tag: t.changed, color: "var(--vcs-modified)" },
-40
View File
@@ -1,6 +1,5 @@
import { describe, expect, it } from "vitest";
import { highlightTree, tagHighlighter, tags as t } from "@lezer/highlight";
import { logWarning } from "./highlight";
import { findLanguageDescription, loadSupport } from "./languages";
describe("language modes", () => {
@@ -58,45 +57,6 @@ describe("language modes", () => {
}
});
it("highlights log levels, timestamps, exception names and stack frames", async () => {
const description = findLanguageDescription("Log", "/var/log/server.log");
expect(description?.name).toBe("Log");
const support = await loadSupport(description!);
const doc = [
"2024-05-01 12:00:00,123 ERROR [main] Boom: java.lang.IllegalStateException: bad",
"\tat com.acme.Main.run(Main.java:42)",
"\t... 3 more",
"12:00:01.5 [warn] retry https://acme.test/x",
"ts=1 level=info msg=started",
"INFOS and errors stay plain, DEBUG does not",
].join("\n");
const highlighter = tagHighlighter([
{ tag: t.invalid, class: "error" },
{ tag: logWarning, class: "warn" },
{ tag: t.string, class: "info" },
{ tag: t.comment, class: "dim" },
{ tag: t.number, class: "time" },
{ tag: t.typeName, class: "type" },
{ tag: t.link, class: "link" },
]);
const highlighted: [string, string][] = [];
highlightTree(support!.language.parser.parse(doc), highlighter, (from, to, style) => {
highlighted.push([doc.slice(from, to), style]);
});
expect(highlighted).toEqual([
["2024-05-01 12:00:00,123", "time"],
["ERROR", "error"],
["java.lang.IllegalStateException", "type"],
["\tat com.acme.Main.run(Main.java:42)", "dim"],
["\t... 3 more", "dim"],
["12:00:01.5", "time"],
["warn", "warn"],
["https://acme.test/x", "link"],
["info", "info"],
["DEBUG", "dim"],
]);
});
it("assigns highlight tags to AsciiDoc syntax", async () => {
const support = await loadSupport(findLanguageDescription("AsciiDoc", "/docs/guide.adoc")!);
const doc = [
-5
View File
@@ -85,11 +85,6 @@ const OWN_MODES = [
extensions: ["adoc", "asciidoc"],
load: () => import("./asciidoc").then((m) => new LanguageSupport(m.asciidocLanguage)),
}),
LanguageDescription.of({
name: "Log",
extensions: ["log"],
load: () => import("./log").then((m) => new LanguageSupport(m.logLanguage)),
}),
LanguageDescription.of({
name: "Shell",
load: async () => {
-83
View File
@@ -1,83 +0,0 @@
import { StreamLanguage, type StreamParser } from "@codemirror/language";
import { logWarning } from "./highlight";
const LEVELS =
"FATAL|CRITICAL|SEVERE|EMERG|ALERT|ERROR|ERR|WARNING|WARN|NOTICE|INFO|DEBUG|TRACE|VERBOSE|FINEST|FINER|FINE";
// Upper case anywhere; any case only where a level is unmistakable: `[warn]`, `<Info>`, `level=error`.
const LEVEL_UPPER = new RegExp(`^(?:${LEVELS})\\b`);
const LEVEL_MARKED = new RegExp(`^(?:${LEVELS})(?=[\\]>])`, "i");
const LEVEL_KEY = /^(?:level|lvl|severity)=/i;
const LEVEL_VALUE = new RegExp(`^(?:${LEVELS})\\b`, "i");
const TIMESTAMP = [
/^\d{4}[-/]\d\d[-/]\d\d(?:[T ]\d\d:\d\d(?::\d\d(?:[.,]\d+)?)?(?:Z|[+-]\d\d(?::?\d\d)?)?)?/,
/^\d\d:\d\d:\d\d(?:[.,]\d+)?/,
/^(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) +\d{1,2} \d\d:\d\d:\d\d/,
];
const EXCEPTION = /^[\w$]+(?:\.[\w$]+)*(?:Exception|Error)\b/;
const FRAME = /^\s+(?:at\s|\.\.\. \d+ (?:more|common frames omitted))/;
type State = { keyLevel: boolean };
/** Level word → the highlight tag whose colour carries its severity in every scheme. */
function levelToken(word: string): string {
switch (word.toUpperCase()) {
case "FATAL":
case "CRITICAL":
case "SEVERE":
case "EMERG":
case "ALERT":
case "ERROR":
case "ERR":
return "invalid";
case "WARN":
case "WARNING":
case "NOTICE":
return "logWarning";
case "INFO":
return "string";
default:
return "comment";
}
}
const parser: StreamParser<State> = {
name: "log",
tokenTable: { logWarning },
startState: () => ({ keyLevel: false }),
copyState: ({ keyLevel }) => ({ keyLevel }),
token(stream, state) {
if (stream.sol() && FRAME.test(stream.string)) {
stream.skipToEnd();
return "comment";
}
if (stream.eatSpace()) return null;
if (state.keyLevel) {
state.keyLevel = false;
const m = stream.match(LEVEL_VALUE) as RegExpMatchArray | null;
if (m) return levelToken(m[0]);
}
if (stream.match(LEVEL_KEY)) {
state.keyLevel = true;
return "propertyName";
}
const before = stream.start === 0 ? "" : stream.string[stream.start - 1]!;
const wordStart = !/[\w$.-]/.test(before);
if (wordStart) {
for (const re of TIMESTAMP) if (stream.match(re)) return "number";
const level = (stream.match(LEVEL_UPPER) || (/[[<]/.test(before) && stream.match(LEVEL_MARKED))) as
| RegExpMatchArray
| boolean
| null;
if (level) return levelToken(stream.current());
if (stream.match(EXCEPTION)) return "typeName";
if (stream.match(/^https?:\/\/[^\s"'<>)\]]+/)) return "link";
}
if (stream.match(/^[\w$]+/)) return null;
stream.next();
return null;
},
};
export const logLanguage = StreamLanguage.define(parser);
-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%",
-18
View File
@@ -262,24 +262,6 @@ describe("EditorController", () => {
useProject.setState({ lightEdit: false });
});
it("opens a file requested while the LightEdit session is being restored", async () => {
ctrl = new EditorController(createMemoryBackend({ "/x/a.txt": "a", "/y/b.md": "b", "/z/launch.lua": "l" }));
ctrl.start();
useProject.setState({ lightEdit: true });
await ctrl.openFile("/x/a.txt", { focus: false });
await ctrl.openFile("/y/b.md", { focus: false });
useProject.setState({ lightEdit: false });
// A launch with a file path: LightEdit starts and the file opens before the old tabs are back.
useProject.setState({ lightEdit: true });
await ctrl.openFile("/z/launch.lua", { focus: false });
expect(openFilePaths(useTabs.getState())).toEqual(["/x/a.txt", "/y/b.md", "/z/launch.lua"]);
const s = useTabs.getState();
const active = s.panes[s.activePane]!;
expect(active.tabs.find((t) => t.id === active.active)?.input.path).toBe("/z/launch.lua");
await ctrl.closeEverything();
useProject.setState({ lightEdit: false });
});
it("closes a tab by its input key", async () => {
ctrl = new EditorController(createMemoryBackend());
ctrl.start();
+1 -9
View File
@@ -237,8 +237,6 @@ export class EditorController implements SlotHost {
private selectionRequests = new Map<string, number>();
private activationGeneration = 0;
private admitting = false;
/** Tabs restored from a saved session: not a user's activation, so a pending open still activates its tab. */
private restoring = false;
private closeRequests = 0;
private projectTransition: Promise<void> = Promise.resolve();
@@ -271,7 +269,6 @@ export class EditorController implements SlotHost {
prevTabs = s;
if (
!this.admitting &&
!this.restoring &&
(s.activePane !== prev.activePane || s.panes[s.activePane]?.active !== prev.panes[prev.activePane]?.active)
)
++this.activationGeneration;
@@ -489,12 +486,7 @@ export class EditorController implements SlotHost {
pane.tabs = pane.tabs.map((tab) => (documentLifecycle(tab.input, kinds) ? { ...tab, preview: false } : tab));
this.restoredViews = new Map(Object.entries(session.files));
this.restoredTabs = new Set(Object.values(state.panes).flatMap((p) => p.tabs.map((t) => t.id)));
this.restoring = true;
try {
useTabs.setState(state, true);
} finally {
this.restoring = false;
}
useTabs.setState(state, true);
for (const pane of Object.values(state.panes)) if (pane.active) void this.ensureSlot(pane.active);
}
+3 -10
View File
@@ -124,12 +124,12 @@ function editorAction(
return { id, title, category, when: EDITOR_WHEN, run: withEditor(run), ...extra };
}
/** Whether saving a `language` file formats it: Format on save, or Run on save of an external formatter. */
/** Whether saving a `language` file formats it: Format on save, or Run on save of Prettier or oxfmt. */
export function formatsOnSave(settings: Settings | null, language: string): boolean {
const { prettier, oxfmt, ruff } = validateLanguages(settings?.languages);
const { prettier, oxfmt } = validateLanguages(settings?.languages);
return (
!!settings?.editor?.formatOnSave ||
[prettier, oxfmt, ruff].some((formatter) => formatter.runOnSave && formatter.languages.includes(language))
[prettier, oxfmt].some((formatter) => formatter.runOnSave && formatter.languages.includes(language))
);
}
@@ -206,13 +206,6 @@ export function registerLspActions(): () => void {
(v) => formatDocument(v, { explicit: true, tool: "oxfmt" }),
{ keywords: ["format", "oxfmt", "oxc"] },
),
editorAction(
"code.reformatWithRuff",
"Reformat with Ruff",
"Code",
(v) => formatDocument(v, { explicit: true, tool: "ruff" }),
{ keywords: ["format", "ruff", "python"] },
),
editorAction("code.reformatDialog", "Reformat File…", "Code", (v) =>
reformatDialog(v, (view) => applyKindSilently(view, "source.organizeImports")),
),
-2
View File
@@ -177,8 +177,6 @@ describe("format on save provider isolation", () => {
expect(formatsOnSave(oxfmt, "typescript")).toBe(true);
expect(formatsOnSave(oxfmt, "markdown")).toBe(false);
expect(formatsOnSave(settings({ prettier: { runOnSave: true } }), "markdown")).toBe(true);
expect(formatsOnSave(settings({ ruff: { runOnSave: true } }), "python")).toBe(true);
expect(formatsOnSave(settings({ ruff: { runOnSave: true } }), "typescript")).toBe(false);
expect(formatsOnSave(settings({}), "typescript")).toBe(false);
expect(formatsOnSave(settings({}, true), "rust")).toBe(true);
expect(formatsOnSave(null, "typescript")).toBe(false);
+6 -12
View File
@@ -136,26 +136,20 @@ async function format(view: EditorView, opts: FormatDocumentOptions): Promise<Fo
commands.lspFormat(doc.doc, range, { ...options, onSave: opts.onSave, tool: opts.tool }),
);
} catch (e) {
const formatter =
e instanceof IpcError && e.data?.type === "tool"
? {
prettier: { failed: "Prettier formatting failed", timedOut: "Prettier formatting timed out" },
oxfmt: { failed: "oxfmt formatting failed", timedOut: "oxfmt formatting timed out" },
ruff: { failed: "Ruff formatting failed", timedOut: "Ruff formatting timed out" },
}[e.data.tool]
: undefined;
if (e instanceof IpcError && e.data?.type === "tool" && formatter) {
if (e instanceof IpcError && e.data?.type === "tool" && (e.data.tool === "prettier" || e.data.tool === "oxfmt")) {
const tool = e.data.tool;
if (e.kind === "notFound" && e.data.missing) {
await executeAction("tools.promptInstall", { name: tool, sticky: !opts.onSave, formatter: true });
await executeAction("tools.promptInstall", { name: tool, sticky: !opts.onSave });
} else if (e.kind !== "cancelled") {
notifications().notify({
group: "tools",
level: "error",
title: tx(formatter.failed),
title: tool === "oxfmt" ? tx("oxfmt formatting failed") : tx("Prettier formatting failed"),
body:
e.kind === "timedOut"
? tx(formatter.timedOut)
? tool === "oxfmt"
? tx("oxfmt formatting timed out")
: tx("Prettier formatting timed out")
: createElement("span", { className: styles.diagnostics }, e.message),
key: `${tool}-format-error:${doc.path}`,
sticky: false,
+5 -6
View File
@@ -44,9 +44,9 @@ afterEach(() => {
_resetI18nForTests();
});
describe.each(["prettier", "oxfmt", "ruff"])("%s formatter errors", (name) => {
describe.each(["prettier", "oxfmt"])("%s formatter errors", (name) => {
const tool = { type: "tool", tool: name, missing: false } as const;
const display = name === "prettier" ? "Prettier" : name === "ruff" ? "Ruff" : name;
const display = name === "prettier" ? "Prettier" : name;
it("uses structured missing-tool information to request the shared install prompt", async () => {
const prompt = vi.fn();
const dispose = registerAction({
@@ -57,9 +57,9 @@ describe.each(["prettier", "oxfmt", "ruff"])("%s formatter errors", (name) => {
try {
failWith({ kind: "notFound", message: "unrelated diagnostic", data: { ...tool, missing: true } });
await formatDocument(viewFor());
expect(prompt).toHaveBeenCalledWith({ name, sticky: true, formatter: true });
expect(prompt).toHaveBeenCalledWith({ name, sticky: true });
await formatDocument(viewFor(), { explicit: false, onSave: true });
expect(prompt).toHaveBeenLastCalledWith({ name, sticky: false, formatter: true });
expect(prompt).toHaveBeenLastCalledWith({ name, sticky: false });
expect(useNotifications.getState().items).toHaveLength(0);
} finally {
dispose();
@@ -126,11 +126,10 @@ describe.each(["prettier", "oxfmt", "ruff"])("%s formatter errors", (name) => {
});
});
describe("Reformat with an external formatter", () => {
describe("Reformat with Prettier / oxfmt", () => {
it.each([
["code.reformatWithPrettier", "prettier"],
["code.reformatWithOxfmt", "oxfmt"],
["code.reformatWithRuff", "ruff"],
])("%s asks the backend for %s whatever the settings select", async (id, tool) => {
vi.spyOn(lsp, "lspRequest").mockImplementation(async (request) => {
await request();
-65
View File
@@ -50,71 +50,6 @@ class Foo:
);
});
it("marks unused parameters and highlights type and f-string keywords", async () => {
const state = await parse(`type Alias = list[int]
s = f"value={1}"
def used(x):
return x
def unused(param):
return 1
def skipped(self, _hidden):
return 1
`);
const tree = syntaxTree(state);
const kinds = pythonSymbolKinds(tree, state.doc);
const kindOf = (name: string) =>
[...kinds]
.filter(([range]) => {
const [from, to] = range.split(":").map(Number);
return state.doc.sliceString(from!, to!) === name;
})
.map(([, kind]) => kind);
expect(kindOf("param")).toEqual(["unusedParameter"]);
expect(kindOf("x")).toEqual(["parameter", "parameter"]);
expect(kindOf("self")).toEqual(["parameter"]);
expect(kindOf("_hidden")).toEqual(["parameter"]);
// Signatures dictated elsewhere: stubs, abstract / overloaded methods, dunders, overrides.
const stubs = await parse(`from abc import abstractmethod
class Plain:
def method(self, unused_a):
return 1
def stub(self, stub_a):
"""Doc."""
...
def todo(self, todo_a):
raise NotImplementedError
def nothing(self, pass_a): pass
@abstractmethod
def abstract(self, abstract_a):
return 1
def __exit__(self, exc_type, exc, tb):
return None
class Child(Plain):
def method(self, override_a):
return 2
`);
const stubKinds = pythonSymbolKinds(syntaxTree(stubs), stubs.doc);
const stubKindOf = (name: string) =>
[...stubKinds]
.filter(([range]) => {
const [from, to] = range.split(":").map(Number);
return stubs.doc.sliceString(from!, to!) === name;
})
.map(([, kind]) => kind);
expect(stubKindOf("unused_a")).toEqual(["unusedParameter"]);
for (const name of ["stub_a", "todo_a", "pass_a", "abstract_a", "exc_type", "tb", "override_a"])
expect(stubKindOf(name), name).toEqual(["parameter"]);
const ranges = pythonSyntaxRanges(tree, state.doc, kinds);
const styles = ranges.map((r) => [state.doc.sliceString(r.from, r.to), r.className]);
expect(styles).toContainEqual(["param", "cm-sem-parameter cm-sem-unused"]);
expect(styles).toContainEqual(["type", "cm-sem-keyword"]);
const fstring = ranges.filter((r) => r.className === "cm-sem-python-fstring");
expect(fstring.length).toBeGreaterThan(0);
expect(fstring.some((r) => state.doc.sliceString(r.from, r.to).includes("value="))).toBe(true);
});
it("lets server classification override the local builtin guess", async () => {
const state = await parse("print(1)");
const ranges = semanticRanges(
+2 -89
View File
@@ -19,7 +19,6 @@ export type PythonSymbolKind =
| "constant"
| "property"
| "typeParameter"
| "unusedParameter"
| "builtin";
const PYTHON_CLASSES: Record<PythonSymbolKind, string> = {
@@ -33,7 +32,6 @@ const PYTHON_CLASSES: Record<PythonSymbolKind, string> = {
constant: "cm-sem-constant cm-sem-python-constant",
property: "cm-sem-property cm-sem-python-property",
typeParameter: "cm-sem-typeParameter cm-sem-python-type-parameter",
unusedParameter: "cm-sem-parameter cm-sem-unused",
builtin: "cm-sem-builtin cm-sem-python-builtin",
};
@@ -93,7 +91,6 @@ const KIND_PRIORITY: Record<PythonSymbolKind, number> = {
globalVariable: 70,
typeParameter: 90,
parameter: 80,
unusedParameter: 80,
property: 80,
constant: 70,
classAttribute: 60,
@@ -109,33 +106,6 @@ function scope(node: SyntaxNode | null): SyntaxNode | null {
return null;
}
const STUB_STATEMENTS = new Set(["PassStatement", "RaiseStatement"]);
/**
* Whether the parameters `fn` doesn't read count as unused. Not for stubs (a body of only a
* docstring, `...`, `pass` or `raise`), `@abstractmethod` / `@overload` / `@override`, dunder
* methods or methods of a class with bases: those signatures are dictated elsewhere.
*/
function checksUnusedParameters(fn: SyntaxNode, doc: Text): boolean {
if (fn.name !== "FunctionDefinition") return true;
const name = directName(fn);
if (name && /^__\w+__$/.test(doc.sliceString(name.from, name.to))) return false;
if (fn.parent?.name === "DecoratedStatement")
for (let d = fn.parent.firstChild; d; d = d.nextSibling)
if (d.name === "Decorator" && /\b(?:abstractmethod|overload|override)\b/.test(doc.sliceString(d.from, d.to)))
return false;
const owner = scope(fn.parent);
if (owner?.name === "ClassDefinition" && owner.getChild("ArgList")) return false;
const body = fn.getChild("Body");
for (let st = body?.firstChild; st; st = st.nextSibling) {
if (st.name === ":" || st.name === "Comment" || STUB_STATEMENTS.has(st.name)) continue;
const only = st.name === "ExpressionStatement" ? st.firstChild : null;
if (only && !only.nextSibling && (only.name === "String" || only.name === "Ellipsis")) continue;
return true;
}
return false;
}
function enclosingClass(node: SyntaxNode | null): SyntaxNode | null {
for (; node; node = node.parent) if (node.name === "ClassDefinition") return node;
return null;
@@ -370,24 +340,6 @@ export function pythonSymbolKinds(tree: Tree, doc: Text): Map<string, PythonSymb
},
});
const usedParameters = new Set<string>();
tree.iterate({
enter(cursor) {
const node = cursor.node;
if (node.name !== "VariableName" || hasAncestor(node, "ParamList")) return;
const name = text(node);
let crossedFunction = false;
for (let current = scope(node); current; current = scope(current.parent)) {
if (FUNCTION_SCOPES.has(current.name)) crossedFunction = true;
const kind = bindings.get(key(current))?.get(name);
if (!kind) continue;
if (crossedFunction && current.name === "ClassDefinition" && kind !== "typeParameter") continue;
if (kind === "parameter") usedParameters.add(`${key(current)}:${name}`);
break;
}
},
});
const resolve = (
node: SyntaxNode,
name: string,
@@ -442,18 +394,6 @@ export function pythonSymbolKinds(tree: Tree, doc: Text): Map<string, PythonSymb
const kindFor = (node: SyntaxNode): PythonSymbolKind | undefined => {
const name = text(node);
if (node.name === "PropertyName") return memberKind(node, name);
if (node.parent?.name === "ParamList") {
const owner = scope(node);
if (
owner &&
name !== "self" &&
name !== "cls" &&
!name.startsWith("_") &&
!usedParameters.has(`${key(owner)}:${name}`) &&
checksUnusedParameters(owner, doc)
)
return "unusedParameter";
}
const start = node.parent?.name === "FunctionDefinition" ? scope(node.parent.parent) : scope(node);
const resolved = resolve(node, name, start);
if (resolved) return resolved;
@@ -483,34 +423,8 @@ export function pythonSyntaxRanges(
tree.iterate({
enter(cursor) {
const node = cursor.node;
if (
![
"VariableName",
"PropertyName",
"String",
"FormatString",
"type",
"Escape",
"(",
")",
"[",
"]",
"{",
"}",
].includes(node.name)
)
if (!["VariableName", "PropertyName", "String", "Escape", "(", ")", "[", "]", "{", "}"].includes(node.name))
return;
if (node.name === "FormatString") {
const className = "cm-sem-python-fstring";
let from = node.from;
for (let child = node.firstChild; child; child = child.nextSibling) {
if (child.name !== "FormatReplacement") continue;
if (child.from > from) ranges.push({ from, to: child.from, className, source: "pythonSyntax" });
from = child.to;
}
if (from < node.to) ranges.push({ from, to: node.to, className, source: "pythonSyntax" });
return;
}
const text = doc.sliceString(node.from, node.to);
const symbol = symbols.get(`${node.from}:${node.to}`);
let style: string | undefined;
@@ -519,7 +433,7 @@ export function pythonSyntaxRanges(
else if (["[", "]"].includes(node.name)) style = "python-brackets";
else if (["{", "}"].includes(node.name)) style = "python-braces";
else if (node.name === "VariableName") {
if (symbol === "typeParameter" || symbol === "unusedParameter") className = pythonSymbolClasses(symbol);
if (symbol === "typeParameter") className = pythonSymbolClasses(symbol);
else if (node.parent?.name === "FunctionDefinition") {
if (symbol === "property") className = pythonSymbolClasses(symbol);
else
@@ -553,7 +467,6 @@ export function pythonSyntaxRanges(
if (docstring && !/^[bf]/i.test(text)) style = "python-docstring";
else if (/^[rub]*b[rub]*['"]/i.test(text)) style = "python-bytes";
} else if (node.name === "Escape") style = "python-escape";
else if (node.name === "type" && node.parent?.name === "TypeDefinition") style = "keyword";
if (style) className = `cm-sem-${style}`;
if (className) ranges.push({ from: node.from, to: node.to, className, source: "pythonSyntax" });
if (style === "python-docstring") return false;
-2
View File
@@ -97,7 +97,6 @@ const PYTHON_VARIABLE_KINDS = new Set<PythonSymbolKind>([
"constant",
"property",
"typeParameter",
"unusedParameter",
]);
const PYTHON_SYMBOL_BASES = new Set([
"cm-sem-variable",
@@ -106,7 +105,6 @@ const PYTHON_SYMBOL_BASES = new Set([
"cm-sem-constant",
"cm-sem-type",
"cm-sem-typeParameter",
"cm-sem-parameter",
"cm-sem-builtin",
]);
+3 -8
View File
@@ -365,14 +365,10 @@ export const lspTheme = EditorView.theme({
".cm-sem-python-braces": pythonStyle("python-braces", "punct"),
".cm-sem-python-definition": pythonStyle("function", "function"),
".cm-sem-python-nested": pythonStyle("python-nested-function", "function"),
".cm-sem-python-predefined-definition": pythonStyle("python-predefined-definition", "predefined"),
".cm-sem-python-predefined-usage": pythonStyle("python-predefined-usage", "predefined"),
".cm-sem-python-predefined-definition": pythonStyle("python-predefined-definition", "function"),
".cm-sem-python-predefined-usage": pythonStyle("python-predefined-usage", "self"),
".cm-sem-python-docstring, .cm-sem-python-docstring *": pythonStyle("docstring", "string"),
".cm-sem-python-bytes": pythonStyle("python-bytes", "string"),
".cm-sem-python-fstring, .cm-sem-python-fstring .tok-string, .cm-sem-python-fstring .tok-string2": pythonStyle(
"python-fstring",
"string",
),
".cm-sem-python-escape": pythonStyle("escape", "escape"),
".cm-sem-python-format": pythonStyle("python-format", "escape"),
".cm-sem-python-keyword-arg": pythonStyle("keyword-arg", "param"),
@@ -389,13 +385,12 @@ export const lspTheme = EditorView.theme({
".cm-sem-variable": { color: "var(--syn-variable)" },
".cm-sem-property": { color: "var(--syn-property)" },
".cm-sem-parameter": { color: "var(--syn-param, var(--syn-variable))" },
".cm-sem-parameter.cm-sem-unused": { color: "var(--fg-unused, var(--fg-faint))" },
".cm-sem-self": { color: "var(--syn-self, var(--syn-keyword))" },
".cm-sem-keyword": { color: "var(--syn-keyword)", fontWeight: "var(--syn-keyword-bold, inherit)" },
// Before the builtin rule: library globals (`print`, `string`) keep the predefined colour.
".cm-sem-global:not(.cm-sem-function)": { color: "var(--syn-global, inherit)" },
".cm-sem-builtin:not(.cm-sem-function)": { color: "var(--syn-builtin, inherit)" },
".cm-sem-builtin.cm-sem-python-builtin": pythonStyle("python-builtin", "builtin"),
".cm-sem-builtin.cm-sem-python-builtin": pythonStyle("predefined", "builtin"),
".cm-sem-static": { fontStyle: "var(--syn-static-italic, italic)" },
".cm-sem-property.cm-sem-static, .cm-sem-property.cm-sem-static *": {
color: "var(--syn-static-property, var(--syn-property))",
@@ -1,16 +0,0 @@
import { describe, expect, it } from "vitest";
import { filterColumns } from "./ColumnsPopup";
const columns = ["id", "user_id", "name", "created_at", "username"].map((name) => ({ name, type: "TEXT" }));
describe("filterColumns", () => {
it("lists every column for an empty query", () => {
expect(filterColumns(columns, " ")).toEqual([0, 1, 2, 3, 4]);
});
it("puts prefix matches first, then other matches, ignoring case", () => {
expect(filterColumns(columns, "US")).toEqual([1, 4]);
expect(filterColumns(columns, "name")).toEqual([2, 4]);
expect(filterColumns(columns, "_id")).toEqual([1]);
});
});
-92
View File
@@ -1,92 +0,0 @@
/**
* Go to Column in a table editor (⌘F12 / Ctrl+F12): the grid's columns, filtered as you type;
* Enter selects that column's cell in the current row.
*/
import { useState } from "react";
import { Icon, Input, List, Popup, listNavigate } from "@/ui";
import { tx } from "@/platform/i18n";
import type { GridColumn } from "./Grid";
import styles from "./Database.module.css";
export function filterColumns(columns: readonly GridColumn[], query: string): number[] {
const q = query.trim().toLowerCase();
const all = columns.map((_, i) => i);
if (!q) return all;
const starts = all.filter((i) => columns[i]!.name.toLowerCase().startsWith(q));
const contains = all.filter((i) => !starts.includes(i) && columns[i]!.name.toLowerCase().includes(q));
return [...starts, ...contains];
}
export function ColumnsPopup(props: {
anchor: { x: number; y: number };
table: string;
columns: readonly GridColumn[];
onPick: (col: number) => void;
onClose: () => void;
}) {
const [query, setQuery] = useState("");
const [active, setActive] = useState(0);
const shown = filterColumns(props.columns, query);
const current = Math.min(active, shown.length - 1);
const pick = (i: number) => {
const col = shown[i];
if (col !== undefined) props.onPick(col);
};
return (
<Popup
open
anchor={props.anchor}
placement="bottom"
onClose={props.onClose}
aria-label={tx`Columns of ${props.table}`}
className={styles.columnsPopup}
trapFocus
>
<Input
autoFocus
aria-label={tx("Search columns")}
placeholder={tx`Columns of ${props.table}`}
value={query}
onChange={(e) => {
setQuery(e.target.value);
setActive(0);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
pick(current);
return;
}
const next = listNavigate(e.key, { items: shown, active: current });
if (next !== null) {
e.preventDefault();
setActive(next);
}
}}
/>
<List
items={shown}
getKey={(col) => String(col)}
activeIndex={current}
onActiveChange={setActive}
onActivate={(_, i) => pick(i)}
focusable={false}
hoverSelects
rowHeight={22}
aria-label={tx("Columns")}
className={styles.columnsList}
empty={<span>{tx("No matching columns")}</span>}
renderItem={(col) => {
const c = props.columns[col]!;
return (
<span className={styles.columnsRow}>
<Icon name={c.primaryKey ? "db-column-key" : c.foreignKey ? "db-column-fk" : "db-column"} size={16} />
{c.name}
{c.type && c.type !== "NULL" && <span className={styles.treeType}>{c.type}</span>}
</span>
);
}}
/>
</Popup>
);
}
-2
View File
@@ -90,7 +90,6 @@ export interface DataViewProps extends Pick<
/** The search bar is shown (the toolbar's search button, ⌘F in the grid). */
searchOpen?: boolean;
onSearchOpenChange?: (open: boolean) => void;
revealColumn?: GridProps["revealColumn"];
"aria-label"?: string;
}
@@ -201,7 +200,6 @@ export function DataView(props: DataViewProps) {
isMatch={test ? (r, c) => matchSet.has(`${r}:${c}`) : undefined}
currentMatch={currentMatch}
onFind={onSearchOpenChange && openSearch}
revealColumn={props.revealColumn}
aria-label={props["aria-label"]}
columns={columns}
rowCount={shown}
-19
View File
@@ -823,25 +823,6 @@
font: inherit;
cursor: pointer;
}
/* Go to Column (ColumnsPopup.tsx). */
.columnsPopup {
display: flex;
flex-direction: column;
gap: 6px;
width: 320px;
padding: 8px;
}
.columnsList {
height: min(320px, 50vh);
}
.columnsRow {
display: flex;
flex: 1;
align-items: center;
gap: 6px;
min-width: 0;
white-space: nowrap;
}
.filterPopup {
display: flex;
flex-direction: column;
-25
View File
@@ -57,8 +57,6 @@ export interface GridProps {
currentMatch?: { row: number; col: number } | null;
/** ⌘F / Ctrl+F in the grid (opens the search bar). */
onFind?: () => void;
/** Go to Column: a new object selects that column's cell in the current row, scrolls to it and focuses the grid. */
revealColumn?: { col: number } | null;
"aria-label"?: string;
}
@@ -162,37 +160,15 @@ export function Grid(props: GridProps) {
estimateSize: () => ROW_H,
overscan: 12,
});
/** Scrolls sideways so column `c` is visible (the first one with the row numbers). */
const scrollColumnIntoView = (c: number) => {
const el = scrollRef.current;
if (!el || c < 0 || c >= widths.length) return;
const left = c ? NUM_W + widths.slice(0, c).reduce((a, b) => a + b, 0) : 0;
const right = NUM_W + widths.slice(0, c + 1).reduce((a, b) => a + b, 0);
if (left < el.scrollLeft) el.scrollLeft = left;
else if (right > el.scrollLeft + el.clientWidth) el.scrollLeft = Math.min(left, right - el.clientWidth);
};
const { currentMatch } = props;
useEffect(() => {
if (!currentMatch) return;
const { row, col } = currentMatch;
setSel({ r0: row, c0: col, r1: row, c1: col });
virtualizer.scrollToIndex(row, { align: "auto" });
scrollColumnIntoView(col);
// Follows the match only, not the virtualizer's identity.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentMatch?.row, currentMatch?.col]);
const { revealColumn } = props;
useEffect(() => {
if (!revealColumn || !rowCount) return;
const { col } = revealColumn;
const row = sel ? Math.min(sel.r1, rowCount - 1) : 0;
setSel({ r0: row, c0: col, r1: row, c1: col });
virtualizer.scrollToIndex(row, { align: "auto" });
scrollColumnIntoView(col);
scrollRef.current?.focus({ preventScroll: true });
// Runs once per request.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [revealColumn]);
const totalWidth = useMemo(() => NUM_W + widths.reduce((a, b) => a + b, 0), [widths]);
const template = `${NUM_W}px ${widths.map((w) => `${w}px`).join(" ")}`;
@@ -279,7 +255,6 @@ export function Grid(props: GridProps) {
const c = Math.max(0, Math.min(columns.length - 1, sel.c1 + m[1]));
setSel(e.shiftKey ? { ...sel, r1: r, c1: c } : { r0: r, c0: c, r1: r, c1: c });
virtualizer.scrollToIndex(r, { align: "auto" });
scrollColumnIntoView(c);
};
const onCellMouseDown = (e: MouseEvent, r: number, c: number) => {
+2 -38
View File
@@ -3,7 +3,7 @@
* a WHERE filter, and pending cell edits / added / deleted rows submitted
* together in one transaction. Tables without a primary key are read-only.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
call,
commands,
@@ -16,7 +16,6 @@ import {
import { Button, EmptyState, Icon, IconButton, Select, Spinner, T, showContextMenu } from "@/ui";
import { notifications, type EditorInput } from "@/workbench/contributions";
import { SqlFilterInput } from "./SqlFilterInput";
import { ColumnsPopup } from "./ColumnsPopup";
import { DataView, ExtractorSelect, PanelToggle, exportMenu, type DataPanel } from "./DataView";
import type { ExportFormat } from "./export";
import {
@@ -31,7 +30,6 @@ import {
import { openDialog, sourceById, withConnection } from "./store";
import type { TableRef } from "./tableInput";
import styles from "./Database.module.css";
import { registerTableEditor, tableEditorChanged, type TableEditorHandle } from "./tableEditorFocus";
import { tx } from "@/platform/i18n";
import { shortcutLabel } from "@/platform/keymap";
@@ -181,30 +179,9 @@ export function TableEditor({ input }: { input: EditorInput }) {
});
const dirty = hasEdits(edits);
// Submit / Revert actions (⌘↩ / ⌥⌘Z) reach the latest state through the handle.
const viewRef = useRef<HTMLDivElement>(null);
const handle = useRef<TableEditorHandle>({
dirty: false,
submit: () => {},
revert: () => {},
showColumns: () => {},
}).current;
handle.dirty = editable && dirty;
handle.submit = () => {
if (editable && dirty) void submit();
};
handle.revert = () => setEdits(emptyEdits());
const [columnsAt, setColumnsAt] = useState<{ x: number; y: number } | null>(null);
const [revealColumn, setRevealColumn] = useState<{ col: number } | null>(null);
handle.showColumns = () => {
const r = viewRef.current?.getBoundingClientRect();
if (page && r) setColumnsAt({ x: r.left + r.width / 2, y: r.top + 48 });
};
useEffect(() => registerTableEditor(viewRef.current!, handle), [handle]);
useEffect(() => tableEditorChanged(handle), [handle, dirty, editable]);
const orderInvalid = orderDraft !== null && !parseOrderBy(orderDraft, names);
return (
<div className={styles.view} ref={viewRef} data-db-table-editor="">
<div className={styles.view}>
<div className={styles.toolbar}>
<IconButton icon="refresh" title="Reload Page" onClick={reload} disabled={loading} />
<div className={styles.pager}>
@@ -351,7 +328,6 @@ export function TableEditor({ input }: { input: EditorInput }) {
columns={gridColumns}
rowCount={rowCount}
fixedRows={loaded}
revealColumn={revealColumn}
value={value}
panel={panel}
onPanelChange={setPanel}
@@ -379,18 +355,6 @@ export function TableEditor({ input }: { input: EditorInput }) {
/>
</div>
)}
{columnsAt && (
<ColumnsPopup
anchor={columnsAt}
table={ref.table}
columns={gridColumns}
onPick={(col) => {
setColumnsAt(null);
setRevealColumn({ col });
}}
onClose={() => setColumnsAt(null)}
/>
)}
</div>
);
}
-26
View File
@@ -7,7 +7,6 @@
import { lazyComponent } from "@/ui";
import { activeEditor, registerEditorContribution } from "@/editor/api";
import { registerActions } from "@/platform/actions";
import { activeTableEditor, installTableEditorFocus } from "./tableEditorFocus";
import { useProject } from "@/platform/project";
import {
registerEditorKind,
@@ -95,7 +94,6 @@ export function activate(): () => void {
}),
() => setFileDecorations("database", new Map()),
watchIdleConnections(),
installTableEditorFocus(),
registerDatabaseTreeActions(),
registerActions([
{
@@ -154,30 +152,6 @@ export function activate(): () => void {
await executeInView(e.view, e.ctx.path);
},
},
{
id: "db.goToColumn",
title: "Go to Column",
category: "Tools",
keywords: ["table editor", "file structure", "columns"],
when: "dbTableEditorFocus",
run: () => activeTableEditor()?.showColumns(),
},
{
id: "db.submitChanges",
title: "Submit",
category: "Tools",
keywords: ["table editor", "apply changes", "commit"],
when: "dbTableEditorFocus && dbTableEditorChanges",
run: () => activeTableEditor()?.submit(),
},
{
id: "db.revertChanges",
title: "Revert",
category: "Tools",
keywords: ["table editor", "discard changes", "undo"],
when: "dbTableEditorFocus && dbTableEditorChanges",
run: () => activeTableEditor()?.revert(),
},
{
id: "db.explainPlan",
title: "Explain Plan",
@@ -1,9 +1,7 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { DbDriver, DbTablePage } from "@/platform/ipc";
import { getContext } from "@/platform/context";
import { TableEditor } from "./TableEditor";
import { activeTableEditor, installTableEditorFocus } from "./tableEditorFocus";
import { tableInput } from "./tableInput";
const backend = vi.hoisted(() => ({
@@ -89,40 +87,4 @@ describe("table editor capabilities", () => {
expect(screen.getByRole("button", { name: "Add Row" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Submit" })).toBeTruthy();
});
it("offers Submit and Revert to the keyboard while the grid has focus and edits are pending (#1549)", async () => {
backend.driver = "postgres";
backend.applyEdits.mockResolvedValue({ status: "ok", data: 1 });
const uninstall = installTableEditorFocus();
try {
await act(async () => {
render(<TableEditor input={tableInput("source", "public", "users")} />);
});
const grid = screen.getByRole("grid");
act(() => grid.focus());
expect(getContext()).toMatchObject({ dbTableEditorFocus: true, dbTableEditorChanges: false });
fireEvent.doubleClick(await screen.findByRole("gridcell", { name: "Ann" }));
const input = screen.getByRole("textbox", { name: "Edit name" });
// A text field keeps ⌘↩ for its own edit.
expect(getContext().dbTableEditorFocus).toBe(false);
fireEvent.change(input, { target: { value: "Bob" } });
fireEvent.keyDown(input, { key: "Enter" });
act(() => grid.focus());
expect(getContext()).toMatchObject({ dbTableEditorFocus: true, dbTableEditorChanges: true });
act(() => activeTableEditor()!.revert());
expect(screen.getByRole("gridcell", { name: "Ann" })).toBeTruthy();
expect(getContext().dbTableEditorChanges).toBe(false);
fireEvent.doubleClick(screen.getByRole("gridcell", { name: "Ann" }));
fireEvent.change(screen.getByRole("textbox", { name: "Edit name" }), { target: { value: "Bob" } });
fireEvent.keyDown(screen.getByRole("textbox", { name: "Edit name" }), { key: "Enter" });
act(() => grid.focus());
await act(async () => activeTableEditor()!.submit());
expect(backend.applyEdits).toHaveBeenCalledTimes(1);
} finally {
uninstall();
}
});
});
-59
View File
@@ -1,59 +0,0 @@
/**
* The table editor that has focus, for Submit / Revert (⌘↩ / ⌥⌘Z) and Go to Column (⌘F12). Not
* while a text field has it: ⌘↩ there applies the field's own edit (the Value panel, the cell editor).
*/
import { setContext } from "@/platform/context";
export interface TableEditorHandle {
dirty: boolean;
submit(): void;
revert(): void;
showColumns(): void;
}
const handles = new WeakMap<Element, TableEditorHandle>();
let active: TableEditorHandle | null = null;
const isTextField = (el: Element) => !!el.closest("input, textarea, select, [contenteditable='true']");
function update() {
setContext("dbTableEditorFocus", !!active);
setContext("dbTableEditorChanges", !!active?.dirty);
}
function onFocusIn(event: FocusEvent) {
const target = event.target instanceof Element ? event.target : null;
const root = target?.closest("[data-db-table-editor]");
active = (root && !isTextField(target!) && handles.get(root)) || null;
update();
}
/** Registers the editor rooted at `root`; returns the function that unregisters it. */
export function registerTableEditor(root: Element, handle: TableEditorHandle): () => void {
handles.set(root, handle);
if (root.contains(document.activeElement) && !isTextField(document.activeElement!)) active = handle;
update();
return () => {
handles.delete(root);
if (active === handle) active = null;
update();
};
}
/** The handle's state changed (pending edits). */
export function tableEditorChanged(handle: TableEditorHandle) {
if (active === handle) update();
}
export function activeTableEditor(): TableEditorHandle | null {
return active;
}
export function installTableEditorFocus(): () => void {
document.addEventListener("focusin", onFocusIn);
return () => {
document.removeEventListener("focusin", onFocusIn);
active = null;
update();
};
}
@@ -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 {
+92 -101
View File
@@ -365,120 +365,111 @@ export function DebugConsole({ session, interactive = true }: { session: DebugSe
};
return (
<div className={styles.consoleArea}>
<div
className={styles.consoleToolbar}
role="toolbar"
aria-orientation="vertical"
aria-label={tx("Console actions")}
>
<IconButton
icon="search"
iconSize={14}
title="Find…"
shortcut={formatBinding(hostBinding("Meta+F"))}
pressed={findOpen}
onClick={() => (findOpen ? closeFind() : openFind())}
<div className={styles.console}>
{findOpen && (
<ConsoleFindBar
query={query}
onChange={changeQuery}
options={findOptions}
onOptionsChange={changeOptions}
count={found.matches.length}
active={activeIndex}
overflow={found.overflow}
invalid={found.invalid}
inputRef={findInput}
onNext={() => goToMatch(1)}
onPrevious={() => goToMatch(-1)}
onClose={closeFind}
/>
)}
<div
ref={scroller}
className={clsx(styles.consoleOutput, ansi.palette, !softWrap && styles.noWrap)}
onScroll={(e) => {
if (programmatic.current) return;
const el = e.currentTarget;
const atEnd = el.scrollHeight - el.scrollTop - el.clientHeight < 24;
if (atEnd !== stick.current) setFollow(atEnd);
}}
onContextMenu={onContextMenu}
role="log"
onKeyDown={onOutputKeyDown}
aria-label={tx("Debug console")}
tabIndex={0}
>
{!lines?.length && session.launch?.request === "attach" && (
<T>The attached process may not provide standard output to the debugger.</T>
)}
<div ref={inner} className={styles.consoleInner} style={{ height: virtualizer.getTotalSize() }}>
{virtualizer.getVirtualItems().map((item) => {
const l = lines?.[item.index];
if (!l) return null;
return (
<span
key={item.key}
data-index={item.index}
ref={virtualizer.measureElement}
className={clsx(styles.out, styles[`cat-${l.category}`])}
style={{ transform: `translateY(${item.start}px)` }}
>
{findSegments(l.spans, marksByLine.get(item.index) ?? [], current).map(segmentNode)}
</span>
);
})}
</div>
</div>
<div className={styles.repl}>
{interactive && (
<>
<span className={styles.prompt} aria-hidden>
›
</span>
<input
ref={inputRef}
className={styles.evalInput}
onFocus={() => setConsoleHistoryBrowser(browseHistory)}
onBlur={() => {
setConsoleHistoryBrowser(null);
completion.close();
}}
value={input}
onChange={(e) => {
setInput(e.target.value);
completion.onInput();
}}
onKeyDown={onKeyDown}
placeholder={isLive(session) ? "Evaluate in the debug console" : "Session finished"}
disabled={!isLive(session)}
aria-label={tx("Debug console input")}
spellCheck={false}
autoComplete="off"
/>
{completion.popup}
</>
)}
<IconButton
icon="arrow-down"
iconSize={14}
title="Scroll to End"
size="small"
iconSize={12}
pressed={follow}
onClick={() => setFollow((v) => !v)}
/>
<IconButton
icon="soft-wrap"
iconSize={14}
title="Soft-Wrap"
size="small"
iconSize={12}
pressed={softWrap}
onClick={() => setWrapOverride(!softWrap)}
/>
<IconButton icon="trash" iconSize={14} title="Clear Console" onClick={() => clearConsole(session.id)} />
</div>
<div className={styles.console}>
{findOpen && (
<ConsoleFindBar
query={query}
onChange={changeQuery}
options={findOptions}
onOptionsChange={changeOptions}
count={found.matches.length}
active={activeIndex}
overflow={found.overflow}
invalid={found.invalid}
inputRef={findInput}
onNext={() => goToMatch(1)}
onPrevious={() => goToMatch(-1)}
onClose={closeFind}
/>
)}
<div
ref={scroller}
className={clsx(styles.consoleOutput, ansi.palette, !softWrap && styles.noWrap)}
onScroll={(e) => {
if (programmatic.current) return;
const el = e.currentTarget;
const atEnd = el.scrollHeight - el.scrollTop - el.clientHeight < 24;
if (atEnd !== stick.current) setFollow(atEnd);
}}
onContextMenu={onContextMenu}
role="log"
onKeyDown={onOutputKeyDown}
aria-label={tx("Debug console")}
tabIndex={0}
>
{!lines?.length && session.launch?.request === "attach" && (
<T>The attached process may not provide standard output to the debugger.</T>
)}
<div ref={inner} className={styles.consoleInner} style={{ height: virtualizer.getTotalSize() }}>
{virtualizer.getVirtualItems().map((item) => {
const l = lines?.[item.index];
if (!l) return null;
return (
<span
key={item.key}
data-index={item.index}
ref={virtualizer.measureElement}
className={clsx(styles.out, styles[`cat-${l.category}`])}
style={{ transform: `translateY(${item.start}px)` }}
>
{findSegments(l.spans, marksByLine.get(item.index) ?? [], current).map(segmentNode)}
</span>
);
})}
</div>
</div>
<div className={styles.repl}>
{interactive && (
<>
<span className={styles.prompt} aria-hidden>
›
</span>
<input
ref={inputRef}
className={styles.evalInput}
onFocus={() => setConsoleHistoryBrowser(browseHistory)}
onBlur={() => {
setConsoleHistoryBrowser(null);
completion.close();
}}
value={input}
onChange={(e) => {
setInput(e.target.value);
completion.onInput();
}}
onKeyDown={onKeyDown}
placeholder={isLive(session) ? "Evaluate in the debug console" : "Session finished"}
disabled={!isLive(session)}
aria-label={tx("Debug console input")}
spellCheck={false}
autoComplete="off"
/>
{completion.popup}
</>
)}
</div>
<IconButton
icon="trash"
title="Clear Console"
size="small"
iconSize={12}
onClick={() => clearConsole(session.id)}
/>
</div>
</div>
);
@@ -218,23 +218,6 @@
}
/* console */
/* The Console tab: actions in a vertical strip on the left, as in the Run window. */
.consoleArea {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
}
.consoleToolbar {
flex: none;
width: 32px;
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 4px 0;
border-right: 1px solid var(--border);
}
.console {
flex: 1;
min-width: 0;
@@ -1,19 +0,0 @@
import { act, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, it } from "vitest";
import { GithubDialogHost } from "../ui/dialogs";
import { remoteBranchNameDialog } from "./RemoteBranchDialog";
it("turns spaces typed in the remote branch name into separators", async () => {
const user = userEvent.setup();
render(<GithubDialogHost />);
let result: Promise<string | null> | undefined;
act(() => {
result = remoteBranchNameDialog("");
});
const input = await screen.findByRole("textbox", { name: "Remote branch name" });
await user.type(input, "my feature");
expect(input).toHaveValue("my-feature");
await user.click(screen.getByRole("button", { name: "OK" }));
await expect(result).resolves.toBe("my-feature");
});
@@ -1,7 +1,6 @@
/** "New Remote Branch for Pull Request": the name to push a local head without a push target to. */
import { useState } from "react";
import { cleanUpBranchNameOnTyping } from "@/platform/gitRefName";
import { Dialog, Input, T, useSanitizedField } from "@/ui";
import { Dialog, Input, T } from "@/ui";
import { openDialog } from "../ui/dialogs";
import styles from "./Create.module.css";
@@ -12,7 +11,6 @@ export function remoteBranchNameDialog(suggested: string): Promise<string | null
function RemoteBranchDialog(props: { suggested: string; onDone: (name: string | null) => void }) {
const [name, setName] = useState(props.suggested);
const { ref, clean } = useSanitizedField(cleanUpBranchNameOnTyping);
const valid = !!name.trim();
return (
<Dialog
@@ -35,13 +33,7 @@ function RemoteBranchDialog(props: { suggested: string; onDone: (name: string |
<label className={styles.dialogLabel}>
<T>Remote branch name:</T>
</label>
<Input
ref={ref}
data-autofocus
aria-label="Remote branch name"
value={name}
onChange={(e) => setName(clean(e))}
/>
<Input data-autofocus aria-label="Remote branch name" value={name} onChange={(e) => setName(e.target.value)} />
<div className={styles.dialogComment}>
<T>Your changes will be pushed to the remote repository to create the pull request</T>
</div>
-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(),
+4 -18
View File
@@ -664,24 +664,10 @@ describe("Show Members", () => {
});
});
it("sorts workspace member roots by the folder names shown in the tree", async () => {
fs = new MemoryFs({
"/a/service10/ten.txt": "",
"/z/Service2/two.txt": "",
"/m/alpha/first.txt": "",
"/workspace/.openidea/workspace.json": "{}",
});
it("renders workspace member roots with their own paths, without the settings directory", async () => {
fs = new MemoryFs({ "/z/first.txt": "", "/a/second.txt": "", "/workspace/.openidea/workspace.json": "{}" });
setProjectFsForTests(fs, "/workspace");
initProjectTree("/workspace", "Workspace", ["/a/service10", "/z/Service2", "/m/alpha"]);
initProjectTree("/workspace", "Workspace", ["/z", "/a"]);
const { rows } = setup();
await waitFor(() =>
expect(rows()).toEqual([
"alpha/m/alpha",
"first.txt",
"Service2/z/Service2",
"two.txt",
"service10/a/service10",
"ten.txt",
]),
);
await waitFor(() => expect(rows()).toEqual(["z/z", "first.txt", "a/a", "second.txt"]));
});
+1 -5
View File
@@ -100,10 +100,6 @@ export function isVisible(entry: FileEntry, opts: TreeOptions): boolean {
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
export function compareNames(a: string, b: string): number {
return collator.compare(a, b) || (a < b ? -1 : a > b ? 1 : 0);
}
/** Symlinks report the target's metadata; folders have size 0 (the entry has no target kind). */
export const isDirEntry = (e: FileEntry) => e.kind === "dir" || (e.kind === "symlink" && e.size === 0);
@@ -129,7 +125,7 @@ export function sortEntries(
const m = (b.mtimeMs ?? 0) - (a.mtimeMs ?? 0);
if (m) return m;
}
return compareNames(a.name, b.name);
return collator.compare(a.name, b.name) || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
});
}
+2 -3
View File
@@ -104,13 +104,12 @@ describe("creating packages", () => {
});
});
it("shows manifest members in name order in workspace Project and Project Files views", () => {
it("uses only manifest members in order in workspace Project and Project Files views", () => {
for (const mode of ["project", "projectFiles"] as const) {
initProjectTree("/workspace", "Workspace", ["/z", "/a"]);
useProjectTree.setState({ mode });
const model = selectModel(useProjectTree.getState());
expect(model.roots.map((r) => r.path)).toEqual(["/a", "/z"]);
expect(useProjectTree.getState().members).toEqual(["/z", "/a"]);
expect(model.roots.map((r) => r.path)).toEqual(["/z", "/a"]);
expect(model.byKey.has("/workspace")).toBe(false);
initProjectTree("/workspace", "Workspace", ["/a"]);
expect(selectModel(useProjectTree.getState()).roots.map((r) => r.path)).toEqual(["/a"]);
+7 -5
View File
@@ -14,7 +14,6 @@ import {
GROUP_PREFIX,
buildModel,
buildPathsModel,
compareNames,
extraFolders,
isGroupKey,
isLibKey,
@@ -35,7 +34,7 @@ import {
} from "./model";
import { projectFs } from "./fs";
import { projectViewRoots, useProjectViewRoots, type ProjectViewRootContribution } from "@/workbench/contributions";
import { ancestorsWithin, isSameOrInside, nameOf, type PackageKind } from "./paths";
import { ancestorsWithin, isSameOrInside, type PackageKind } from "./paths";
export interface ViewOptions extends TreeOptions {
/** Select the active editor's file in the tree. */
@@ -219,9 +218,12 @@ export function selectModel(s: ProjectTreeState): TreeModel {
const extras = useProjectViewRoots.getState().roots;
const options = treeOptions(s);
const contributed = projectViewRoots(extras);
const memberRoots = s.members
?.map((path) => ({ path, label: nameOf(path), icon: "", detail: path }))
.sort((a, b) => compareNames(a.label, b.label));
const memberRoots = s.members?.map((path) => ({
path,
label: path.slice(path.lastIndexOf("/") + 1),
icon: "",
detail: path,
}));
const project = () =>
memberRoots
? withExtraRoots(EMPTY_MODEL, memberRoots, s.listings, options, placeholder)
@@ -50,7 +50,6 @@ vi.mock("@/platform/javaLaunch", async (original) => ({
vi.mock("./buildUpdate", () => ({ runUpdateBuild: mocks.runBuild }));
vi.mock("./tomcat", () => ({ buildTomcatArtifact: mocks.build, syncTomcatOutputs: mocks.sync }));
vi.mock("./store", () => ({
rerunConfig: (s: Pick<RunSession, "config">) => mocks.state.configs.find((c) => c.id === s.config.id) ?? s.config,
startRun: mocks.start,
stopSession: mocks.stop,
waitForExit: mocks.wait,
@@ -106,16 +105,6 @@ describe("Tomcat application updates", () => {
expect(mocks.wait.mock.invocationCallOrder[0]).toBeLessThan(mocks.action.mock.invocationCallOrder[0]!);
expect(mocks.action).toHaveBeenCalledWith("run.debugConfig", session.config);
});
it("restarts with the configuration as edited since the server started", async () => {
const edited = { ...session.config, name: "Tomcat (edited)" };
mocks.state.configs = [edited];
try {
await updateTomcat(session, "restart");
expect(mocks.action).toHaveBeenCalledWith("run.debugConfig", edited);
} finally {
mocks.state.configs = [];
}
});
it("redeploys when the debugger cannot perform HotSwap", async () => {
mocks.action.mockResolvedValue(false);
await updateTomcat(session, "classes");
+4 -5
View File
@@ -13,7 +13,7 @@ import {
import { showContextMenu } from "@/ui";
import { buildTomcatArtifact, syncTomcatOutputs } from "./tomcat";
import { runUpdateBuild } from "./buildUpdate";
import { rerunConfig, startRun, stopSession, useRun, waitForExit, type RunSession } from "./store";
import { startRun, stopSession, useRun, waitForExit, type RunSession } from "./store";
import { isUpdatable, updateAllowed, type UpdateMode } from "./updatePolicies";
export type UpdateSession = Pick<RunSession, "id" | "config"> & { debug?: DebugServiceSession };
@@ -72,15 +72,14 @@ export async function updateApplication(session: UpdateSession, mode: string): P
const debugging =
!!session.debug || useDebugRunSessions.getState().sessions.some((s) => s.configId === session.config.id);
if (mode === "restart") {
const config = rerunConfig(session);
if (session.debug) {
await session.debug.stop();
await executeAction("run.debugConfig", config);
await executeAction("run.debugConfig", session.config);
} else if (debugging) {
await stopSession(session.id);
await waitForExit([session.id]);
await executeAction("run.debugConfig", config);
} else await startRun(config, session.id);
await executeAction("run.debugConfig", session.config);
} else await startRun(session.config, session.id);
return;
}
await maybeEditorService()?.saveAll();
+1 -36
View File
@@ -15,7 +15,7 @@ import {
} from "./configs";
import { contextConfig, fileRole } from "./context";
import { envFromRows } from "./EnvDialog";
import { configRemoved, defaultSelection, rerunConfig, useRun, type RunSession } from "./store";
import { defaultSelection } from "./store";
const cfg = (over: Partial<RunConfig> & { id: string }): RunConfig => ({
name: over.id,
@@ -344,38 +344,3 @@ describe("context configuration", () => {
expect(contextConfig("tools/gen.py", [])).toMatchObject({ command: "python3", args: ["tools/gen.py"] });
});
});
describe("rerunning a session after its configuration changed (#1497)", () => {
const session = (config: RunConfig, baseConfig?: RunConfig): RunSession => ({
id: "s",
config,
baseConfig,
runId: null,
status: "finished",
exitCode: 0,
startedAt: 1,
endedAt: 2,
stdinClosed: true,
});
it("launches the configuration as saved now, or the one it ran with once deleted", () => {
const ran = cfg({ id: "app", command: "python", args: ["init.py"] });
const edited = { ...ran, args: ["main.py"] };
useRun.setState({ configs: [edited] });
expect(rerunConfig(session(ran))).toBe(edited);
expect(configRemoved("app")).toBe(false);
useRun.setState({ configs: [] });
expect(rerunConfig(session(ran))).toBe(ran);
expect(configRemoved("app")).toBe(true);
// A run that was never a listed configuration isn't "removed".
expect(configRemoved("build:task")).toBe(false);
});
it("keeps the tests of a derived run (Rerun Failed Tests)", () => {
const base = cfg({ id: "tests", isTest: true });
const derived = { ...base, args: ["-k", "failing"] };
useRun.setState({ configs: [base] });
expect(rerunConfig(session(derived, base))).toBe(derived);
useRun.setState({ configs: [] });
});
});
+1 -7
View File
@@ -98,13 +98,7 @@ export async function produceResolved(input: ContextInput, dir = false): Promise
// Project configurations (tests, cargo, gradle…) need no runtime lookup.
if (named.config && !named.config.id.startsWith("context:file:")) return named;
const lang = scriptLanguage(input.path, input.content);
const env = await toolEnv(
neededTools(fresh),
input.path,
fresh.root,
lang === "python" || lang === "shebang",
lang === "php" || lang === "shebang",
);
const env = await toolEnv(neededTools(fresh), input.path, fresh.root, lang === "python" || lang === "shebang");
return produceContext(fresh, env);
}
-9
View File
@@ -226,15 +226,6 @@ describe("single-file producers (scratches)", () => {
it("Ruby, PHP, Perl, Lua, R, Swift, Dart, Elixir, Scala CLI, Kotlin script", () => {
expect(run(scratch("a.rb"))).toMatchObject({ command: "/bin/ruby", args: ["a.rb"] });
expect(run(scratch("a.php"))).toMatchObject({ command: "/bin/php", args: ["a.php"] });
// The PHP chosen in Settings wins over the PATH one, also with no PHP on PATH (#1555).
const php81 = "/opt/homebrew/opt/[email protected]/bin/php";
expect(run(scratch("a.php"), { ...ALL, php: php81 })).toMatchObject({ command: php81, args: ["a.php"] });
expect(run(scratch("a.php"), env({}, { php: php81 }))).toMatchObject({ command: php81, args: ["a.php"] });
expect(run(scratch("tool", "#!/usr/bin/env php\n<?php echo 1;"), { ...ALL, php: php81 })).toMatchObject({
command: php81,
args: ["tool"],
});
expect(produceContext(scratch("a.php"), env({}))!.missing!.body).toMatch(/Settings › Languages & Frameworks › PHP/);
expect(run(scratch("a.pl"))).toMatchObject({ command: "/bin/perl" });
expect(run(scratch("a.lua"))).toMatchObject({ command: "/bin/lua" });
expect(run(scratch("a.R"))).toMatchObject({ command: "/bin/Rscript", args: ["a.R"] });
+1 -7
View File
@@ -38,8 +38,6 @@ export interface ToolEnv {
windows?: boolean;
/** The project interpreter from settings (Python). */
python?: string | null;
/** The PHP interpreter chosen in settings (else the `php` on `PATH`). */
php?: string | null;
/** The project root has `deno.json(c)` / `bun.lock(b)`. */
deno?: boolean;
bun?: boolean;
@@ -290,7 +288,7 @@ const INSTALL: Record<string, string> = {
js: "Install Node.js from https://nodejs.org (or `brew install node`).",
python: "Install Python (`brew install python`) or choose an interpreter in Settings › Python Interpreter.",
ruby: "Install Ruby (`brew install ruby`).",
php: "Install PHP (`brew install php`) or choose an interpreter in Settings › Languages & Frameworks › PHP.",
php: "Install PHP (`brew install php`).",
perl: "Install Perl (`brew install perl`).",
lua: "Install Lua (`brew install lua`).",
r: "Install R (`brew install r`) so that `Rscript` is on the PATH.",
@@ -764,8 +762,6 @@ export function produceFile(input: ContextInput, env: ToolEnv): Produced | null
config: fileConfig(s, "python", py.command, [...py.prefix, s.arg], "debugpy"),
};
}
if (interp === "php" && env.php)
return { name: s.file, scope: "file", config: fileConfig(s, "php", env.php, [...sb.slice(1), s.arg]) };
const t = sb[0]!.startsWith("/") ? { command: sb[0]!, name: interp } : pick(env, [interp]);
if (!t) return { name: s.file, scope: "file", missing: missing(lang, s.file, [interp]) };
const kind = /^(ba|z|k|da|fi)?sh$/.test(interp) ? "shell" : interp === "node" ? "node" : interp;
@@ -809,8 +805,6 @@ export function produceFile(input: ContextInput, env: ToolEnv): Produced | null
// Sources roots go on the load path (`-Ilib`), as `rake test` does; paths are project-relative.
return simple(lang, s, env, lang, () => [...(s.inProject ? rubyLoadPath(currentRoots()) : []), s.arg]);
case "php":
if (env.php) return { name: s.file, scope: "file", config: fileConfig(s, "php", env.php, [s.arg]) };
return simple(lang, s, env, lang, () => [s.arg]);
case "perl":
case "lua":
case "swift":
+1 -12
View File
@@ -86,14 +86,6 @@ async function projectPython(): Promise<string | null> {
}
}
async function projectPhp(): Promise<string | null> {
try {
return await call(commands.sdkPhpEffective());
} catch {
return null;
}
}
async function rootMarkers(root: string | null): Promise<{ deno: boolean; bun: boolean }> {
if (!root) return { deno: false, bun: false };
try {
@@ -114,12 +106,10 @@ export async function toolEnv(
file: string | null,
root: string | null,
python: boolean,
php = false,
): Promise<ToolEnv> {
const [tools, py, phpInterpreter, markers] = await Promise.all([
const [tools, py, markers] = await Promise.all([
locateTools(names, file),
python ? projectPython() : Promise.resolve(null),
php ? projectPhp() : Promise.resolve(null),
names.includes("deno") || names.includes("bun") ? rootMarkers(root) : Promise.resolve({ deno: false, bun: false }),
]);
return {
@@ -127,7 +117,6 @@ export async function toolEnv(
compileDir: compileDir ?? "/tmp/openidea-run",
windows: isWindows(),
python: py,
php: phpInterpreter,
...markers,
};
}
-11
View File
@@ -256,17 +256,6 @@ describe("configurations that ran", () => {
expect(items).toHaveLength(1);
expect(items[0]!.children!.map((c) => [c.label, c.detail])).toEqual([["dev", "Running"]]);
});
it("drops a deleted configuration once its run has ended (#1497)", () => {
const removed = (id: string) => id === "dev";
const ended = runServiceItems([], [session("s1", dev, "finished", 1)], [], ["dev"], {}, [], removed);
expect(ended).toEqual([]);
const live = runServiceItems([], [session("s1", dev, "running", 1)], [], ["dev"], {}, [], removed);
expect(live[0]!.children!.map((c) => [c.label, c.detail])).toEqual([["dev", "Running"]]);
// Never a saved configuration (a build task, an external tool): its finished run stays.
const adHoc = runServiceItems([], [session("s2", scratch, "finished", 1)], [], [], {}, [], removed);
expect(adHoc[0]!.children!.map((c) => c.key)).toEqual(["run:config:context:file"]);
});
});
describe("embedded web server port", () => {
+5 -25
View File
@@ -29,16 +29,7 @@ import { commandLine, configIcon, KIND_OPTIONS, kindLabel } from "./configs";
import { SessionPanel } from "./RunView";
import { showApplicationUpdate } from "./applicationUpdate";
import { isUpdatable } from "./updatePolicies";
import {
configRemoved,
loadConfigs,
persistTemporary,
rerunConfig,
startRun,
stopSession,
useRun,
type RunSession,
} from "./store";
import { loadConfigs, persistTemporary, startRun, stopSession, useRun, type RunSession } from "./store";
import {
currentServiceSession,
currentServiceSessions,
@@ -239,7 +230,6 @@ function executionDetail(s: ServiceSession | undefined): string | undefined {
/**
* Groups by configuration type: added types list all their configurations, other
* types only those that ran. A default type (Spring Boot) without configurations is hidden.
* A configuration deleted since it ran (`removed`) stays only while its run is live.
*/
export function runServiceItems(
configs: readonly RunConfig[],
@@ -248,13 +238,11 @@ export function runServiceItems(
ran: readonly string[] = [],
order: ServiceOrder = {},
debug: readonly DebugServiceSession[] = [],
removed: (id: string) => boolean = () => false,
): ServiceTreeItem[] {
const executions = serviceSessions(sessions, debug);
const latest = currentServiceSessions(executions);
const kinds = [...added];
const shown = (s: ServiceSession) => liveServiceSession(s) || !removed(s.config.id);
for (const s of executions) if (shown(s) && !kinds.includes(s.config.kind)) kinds.push(s.config.kind);
for (const s of executions) if (!kinds.includes(s.config.kind)) kinds.push(s.config.kind);
for (const id of ran) {
const kind = configs.find((c) => c.id === id)?.kind;
if (kind && !kinds.includes(kind)) kinds.push(kind);
@@ -264,7 +252,7 @@ export function runServiceItems(
? configs.filter((c) => c.kind === kind)
: ran.map((id) => configs.find((c) => c.id === id)).filter((c): c is RunConfig => !!c && c.kind === kind);
for (const s of latest.values())
if (s.config.kind === kind && shown(s) && !entries.some((c) => c.id === s.config.id)) entries.push(s.config);
if (s.config.kind === kind && !entries.some((c) => c.id === s.config.id)) entries.push(s.config);
if (!entries.length && DEFAULT_TYPES.includes(kind)) return [];
const children = orderServices(entries, order[kind]).map((c): ServiceTreeItem => {
const s = latest.get(c.id);
@@ -310,15 +298,7 @@ function useRoots(): ServiceTreeItem[] {
}, [root, typesRoot, ranRoot]);
return useMemo(
() =>
runServiceItems(
configs,
sessions,
typesRoot === root ? types : [],
ranRoot === root ? ran : [],
order,
debug,
configRemoved,
),
runServiceItems(configs, sessions, typesRoot === root ? types : [], ranRoot === root ? ran : [], order, debug),
[configs, sessions, types, typesRoot, ran, ranRoot, root, order, debug],
);
}
@@ -355,7 +335,7 @@ function run(r: Resolved) {
if (debug) debug.rerun?.();
else if (r.session?.run) {
const s = r.session.run;
void startRun(rerunConfig(s), s.id, s.baseConfig, { show: false });
void startRun(s.config, s.id, s.baseConfig, { show: false });
} else if (r.config) void startRun(r.config, undefined, undefined, { show: false });
}
+5 -31
View File
@@ -1010,7 +1010,7 @@ function notifyExit(sessionId: string, code: number | null) {
toolWindowService().show("run", true);
},
},
{ label: "Rerun", run: () => void startRun(rerunConfig(session), sessionId, session.baseConfig) },
{ label: "Rerun", run: () => void startRun(session.config, sessionId) },
],
});
}
@@ -1070,35 +1070,9 @@ export function waitForExit(sessionIds: readonly string[], timeoutMs = 10_000):
});
}
/**
* What rerunning a session launches: its configuration as saved now (edited since, as IDEA), or
* the one it ran with when that was deleted or never saved. A derived run (Rerun Failed Tests) keeps its tests.
*/
export function rerunConfig(session: Pick<RunSession, "config" | "baseConfig">): RunConfig {
if (session.baseConfig) return session.config;
return useRun.getState().configs.find((c) => c.id === session.config.id) ?? session.config;
}
/** The session's original configuration, as saved now (see {@link rerunConfig}). */
function baseConfigOf(session: RunSession): RunConfig {
const base = session.baseConfig ?? session.config;
return useRun.getState().configs.find((c) => c.id === base.id) ?? base;
}
/** Ids of every configuration listed in this window, so a run of one deleted since can be told apart. */
const listedConfigIds = new Set<string>();
useRun.subscribe((s, prev) => {
if (s.configs !== prev.configs) for (const c of s.configs) listedConfigIds.add(c.id);
});
/** The configuration was saved or temporary in this window and has been removed since. */
export function configRemoved(id: string): boolean {
return listedConfigIds.has(id) && !useRun.getState().configs.some((c) => c.id === id);
}
export function rerunSession(sessionId: string) {
const session = getSession(sessionId);
if (session) void startRun(rerunConfig(session), sessionId, session.baseConfig && baseConfigOf(session));
if (session) void startRun(session.config, sessionId, session.baseConfig);
}
/**
@@ -1108,7 +1082,7 @@ export function rerunSession(sessionId: string) {
*/
export function debugSession(sessionId: string) {
const session = getSession(sessionId);
if (session) void executeAction("run.debugConfig", baseConfigOf(session));
if (session) void executeAction("run.debugConfig", session.baseConfig ?? session.config);
}
/** Paths of the failed tests of a session's last run. */
@@ -1121,7 +1095,7 @@ export function failedTests(sessionId: string): string[][] {
export function rerunFailed(sessionId: string): boolean {
const session = getSession(sessionId);
if (!session || session.status === "running" || session.status === "starting") return false;
const base = baseConfigOf(session);
const base = session.baseConfig ?? session.config;
const derived = rerunFailedConfig(base, failedTests(sessionId));
if (!derived) return false;
void startRun(derived, sessionId, base);
@@ -1142,7 +1116,7 @@ export async function runSelectedTests(
if (!session || !tests) return false;
const nodes = nodeIds.map((id) => tests.get(id)).filter((n) => !!n);
const paths = leafTestPaths(nodes);
const base = baseConfigOf(session);
const base = session.baseConfig ?? session.config;
const name = nodes.length === 1 ? nodes[0]!.name : `${paths.length} tests`;
const derived = testsConfig(base, paths, name);
if (!derived) return false;
@@ -78,9 +78,7 @@ describe("languages settings", () => {
}
expect(PHP_LANGUAGE_LEVELS[0]).toBe("");
expect(PHP_LANGUAGE_LEVELS).toContain("5.6");
// Newest first after Auto (#1557).
expect(PHP_LANGUAGE_LEVELS[1]).toBe("8.5");
expect(PHP_LANGUAGE_LEVELS.at(-1)).toBe("5.6");
expect(PHP_LANGUAGE_LEVELS.at(-1)).toBe("8.5");
});
it("finds the PHP page by its language level", () => {
@@ -94,13 +92,7 @@ describe("languages settings", () => {
validateLanguages({
prettier: { path: " ./bin/prettier ", languages: ["markdown", 4, "markdown"], prefer: true, runOnSave: true },
}).prettier,
).toEqual({
path: "./bin/prettier",
configurationDirectory: "",
languages: ["markdown"],
prefer: true,
runOnSave: true,
});
).toEqual({ path: "./bin/prettier", languages: ["markdown"], prefer: true, runOnSave: true });
expect(validateLanguages({ prettier: { languages: [] } }).prettier.languages).toEqual([]);
});
@@ -110,43 +102,10 @@ describe("languages settings", () => {
prettier: { runOnSave: true },
oxfmt: { path: " ./bin/oxfmt ", languages: ["typescript", null, "typescript"], prefer: true },
});
expect(v.oxfmt).toEqual({
path: "./bin/oxfmt",
configurationDirectory: "",
languages: ["typescript"],
prefer: true,
runOnSave: false,
});
expect(v.oxfmt).toEqual({ path: "./bin/oxfmt", languages: ["typescript"], prefer: true, runOnSave: false });
expect(v.prettier).toMatchObject({ runOnSave: true, prefer: false, path: "" });
});
it("enables Ruff for Python by default and keeps its settings separate", () => {
expect(validateLanguages({}).ruff).toMatchObject({
path: "",
configurationDirectory: "",
languages: ["python"],
prefer: false,
runOnSave: false,
});
const v = validateLanguages({
ruff: {
path: " ./bin/ruff ",
configurationDirectory: " ./config/ruff ",
languages: ["python", null, "python", "pyi"],
prefer: true,
runOnSave: true,
},
});
expect(v.ruff).toEqual({
path: "./bin/ruff",
configurationDirectory: "./config/ruff",
languages: ["python", "pyi"],
prefer: true,
runOnSave: true,
});
expect(v.prettier).toEqual(DEFAULT_LANGUAGES.prettier);
});
it("leaves the Markdown preview font size unset (editor font) and keeps a set one in 8..72", () => {
expect(validateLanguages({}).markdown).toEqual({ previewFontSize: null });
expect(validateLanguages({ markdown: { previewFontSize: 90 } }).markdown).toEqual({ previewFontSize: 72 });
@@ -184,7 +143,6 @@ describe("language pages", () => {
"PHP",
"Prettier",
"oxfmt",
"Ruff",
"Ruby",
"Rust",
"TypeScript",
@@ -1,11 +1,9 @@
import { useId } from "react";
import type { ExternalFormatter } from "@/bindings";
import { IconButton, Input } from "@/ui";
import { Input } from "@/ui";
import { Row, Section, Select, Toggle } from "../components/Form";
import { useSetting } from "../components/context";
import { DEFAULT_LANGUAGES } from "@/platform/languageSettings";
import { choosePath } from "./api";
import styles from "./Sdk.module.css";
const names: Record<string, string> = {
markdown: "Markdown",
@@ -22,28 +20,17 @@ const names: Record<string, string> = {
json: "JSON",
jsonc: "JSON with Comments",
vue: "Vue",
python: "Python",
};
interface FormatterPageProps {
tool: ExternalFormatter;
name: string;
pathLabel: string;
pathHint?: string;
configurationDirectory?: boolean;
providerHint?: string;
saveHint: string;
}
function FormatterPage({
tool,
name,
pathLabel,
pathHint = "Auto: nearest project node_modules, managed installation, then PATH. An explicit path overrides auto detection.",
configurationDirectory = false,
providerHint,
saveHint,
}: FormatterPageProps) {
function FormatterPage({ tool, name, pathLabel, providerHint, saveHint }: FormatterPageProps) {
const id = useId();
const [path, setPath] = useSetting<string>(`languages.${tool}.path`);
const [languages, setLanguages] = useSetting<string[]>(`languages.${tool}.languages`);
@@ -52,10 +39,13 @@ function FormatterPage({
return (
<>
<Section title={name}>
<Row label={pathLabel} htmlFor={id} hint={pathHint}>
<Row
label={pathLabel}
htmlFor={id}
hint="Auto: nearest project node_modules, managed installation, then PATH. An explicit path overrides auto detection."
>
<Input id={id} mono value={path} placeholder="Auto" onChange={(e) => setPath(e.target.value)} />
</Row>
{configurationDirectory && <ConfigurationDirectory settingKey={`languages.${tool}.configurationDirectory`} />}
<Row label="Formatting provider" hint={providerHint}>
<Select
value={prefer ? tool : "auto"}
@@ -85,43 +75,6 @@ function FormatterPage({
);
}
function ConfigurationDirectory({ settingKey }: { settingKey: string }) {
const id = useId();
const [path, setPath] = useSetting<string>(settingKey);
return (
<Row
label="Configuration directory"
htmlFor={id}
hint="Empty uses the nearest project pyproject.toml ([tool.ruff]), ruff.toml, or .ruff.toml. A configured directory must contain one of them."
>
<span className={styles.pathRow}>
<Input
id={id}
mono
className={styles.pathInput}
value={path}
placeholder="Project configuration"
onChange={(e) => setPath(e.target.value)}
/>
<IconButton
icon="folder-open"
size="small"
title="Browse…"
aria-label="Select Ruff Configuration Directory"
onClick={async () => {
const picked = await choosePath({
directory: true,
title: "Select Ruff Configuration Directory",
defaultPath: path || undefined,
});
if (picked) setPath(picked);
}}
/>
</span>
</Row>
);
}
export function PrettierPage() {
return (
<FormatterPage
@@ -144,17 +97,3 @@ export function OxfmtPage() {
/>
);
}
export function RuffPage() {
return (
<FormatterPage
tool="ruff"
name="Ruff"
pathLabel="Ruff path"
pathHint="Auto: managed installation, then PATH. An explicit path overrides auto detection."
configurationDirectory
providerHint="Ruff reads the nearest pyproject.toml ([tool.ruff]), ruff.toml, or .ruff.toml. Use it before the language server when preferred."
saveHint="Run Ruff on save for enabled languages when it is the selected formatting provider."
/>
);
}
@@ -3,8 +3,7 @@
* Build Tools › Maven: the toolchain each language's server and run configurations
* use (applied by `ide-sdk` when language servers start and when run configurations load).
*/
import { useEffect, useId, useMemo, useState } from "react";
import { call, commands } from "@/platform/ipc";
import { useId, useMemo } from "react";
import { IconButton, Input, type SelectOption } from "@/ui";
import { PHP_LANGUAGE_LEVELS } from "@/platform/languageSettings";
import { GlobListEditor, Row, Section, Select, Toggle } from "../components/Form";
@@ -118,31 +117,13 @@ const PHP_LEVEL_OPTIONS: SelectOption[] = PHP_LANGUAGE_LEVELS.map((level) =>
level ? { value: level, label: level } : { value: "", label: "Auto", detail: "from composer.json" },
);
/** The `php` on `PATH` (what the Default interpreter runs), looked up while `enabled`. */
function usePathPhp(enabled: boolean): string | null {
const [path, setPath] = useState<string | null>(null);
useEffect(() => {
if (!enabled) return;
let live = true;
call(commands.sdkLocateTools(["php"], null))
.then((r) => live && setPath(r.tools[0]?.path ?? null))
.catch(() => {});
return () => {
live = false;
};
}, [enabled]);
return path;
}
export function PhpPage() {
const [interpreter] = useSetting<string>("languages.php.interpreter");
const [level, setLevel] = useSetting<string>("languages.php.languageLevel");
const [includePaths, setIncludePaths] = useSetting<string[]>("languages.php.includePaths");
const [stubs, setStubs] = useSetting<string[]>("languages.php.stubs");
const { list } = useDetected("php");
const pathPhp = usePathPhp(!interpreter);
// Default runs the `php` on PATH: the list is newest first, so it isn't necessarily `list[0]`.
const selected = list.find((s) => s.path === (interpreter || pathPhp));
const selected = list.find((s) => s.path === interpreter) ?? (interpreter ? undefined : list[0]);
return (
<div className={styles.page}>
<Section>
+1 -19
View File
@@ -5,7 +5,7 @@
*/
import { GoPage, JavaPage, NodePage, PhpPage, RubyPage, RustPage, TypeScriptPage } from "../languages/LanguagePages";
import { MarkdownPage } from "../languages/MarkdownPage";
import { OxfmtPage, PrettierPage, RuffPage } from "../languages/FormatterPage";
import { OxfmtPage, PrettierPage } from "../languages/FormatterPage";
import { ProjectStructurePage } from "../languages/ProjectStructurePage";
import { PythonInterpreterPage } from "../languages/PythonInterpreterPage";
import { GoBuildTagsPage, GO_BUILD_TAG_SECTIONS } from "../languages/GoBuildTags";
@@ -138,24 +138,6 @@ export const LANGUAGE_PAGES: PageSpec[] = [
keywords: ["formatter", "oxfmt", "oxc", ".oxfmtrc.json", "format on save", "node_modules"],
component: OxfmtPage,
},
{
id: "languages.ruff",
projectScope: true,
defaultScope: "project",
title: "Ruff",
group: LANGUAGES_GROUP,
order: 403.7,
keywords: [
"formatter",
"ruff",
"python",
"configuration directory",
"pyproject.toml",
"ruff.toml",
"format on save",
],
component: RuffPage,
},
{
id: "languages.php",
projectScope: true,
@@ -147,25 +147,6 @@ describe("missing language server prompt", () => {
off();
});
it("offers Ruff formatting even when another Python server is installed", async () => {
const installed = sampleTools().find((t) => t.name === "basedpyright")!;
backend.tools.push({
...installed,
name: "ruff",
displayName: "Ruff",
command: "ruff",
installMethod: "uv (virtualenv)",
languages: ["Python"],
alternatives: [],
});
await promptInstall("ruff", true, new Set(), true);
expect(notes.at(-1)).toMatchObject({
title: "Ruff is not installed",
body: "Reformat Code needs Ruff. Install it via uv (virtualenv)?",
key: missingKey("ruff"),
});
});
it("stays silent when an installed alternative serves the same languages", async () => {
const off = installMissingServerPrompts();
// typescript-language-server (on PATH) covers vtsls' languages.
+1 -6
View File
@@ -46,12 +46,7 @@ export function activate(): () => void {
hidden: true,
run: async ({ arg }) => {
if (arg && typeof arg === "object" && "name" in arg && typeof arg.name === "string") {
await promptInstall(
arg.name,
!("sticky" in arg) || arg.sticky !== false,
new Set(),
"formatter" in arg && arg.formatter === true,
);
await promptInstall(arg.name, !("sticky" in arg) || arg.sticky !== false);
}
},
},
+5 -6
View File
@@ -21,14 +21,12 @@ export async function promptInstall(
name: string,
sticky = true,
offered: ReadonlySet<string> = new Set(),
formatter = false,
): Promise<boolean> {
if (!useTools.getState().loaded) await refreshTools();
const tool = toolByName(name);
if (!tool || !tool.canInstall || tool.status !== "missing") return false;
const position = tool.alternatives.indexOf(name);
if (position > 0 && tool.alternatives.slice(0, position).some((n) => offered.has(n))) return false;
formatter ||= tool.kind === "formatter";
// An installed alternative (typescript-language-server for vtsls) already serves these files.
const served = useTools
.getState()
@@ -39,16 +37,17 @@ export async function promptInstall(
t.status !== "missing" &&
t.languages.some((l) => tool.languages.includes(l)),
);
if (!formatter && served) return false;
if (tool.kind !== "formatter" && served) return false;
const key = missingKey(name);
const langs = tool.languages.length ? tool.languages.join(", ") : "these files";
notifications().notify({
group: "tools",
level: "warning",
title: tx`${tool.displayName} is not installed`,
body: formatter
? tx`Reformat Code needs ${tool.displayName}. Install it via ${tool.installMethod}?`
: tx`Code insight for ${langs} needs ${tool.command}. Install it via ${tool.installMethod}?`,
body:
tool.kind === "formatter"
? tx`Reformat Code needs ${tool.displayName}. Install it via ${tool.installMethod}?`
: tx`Code insight for ${langs} needs ${tool.command}. Install it via ${tool.installMethod}?`,
key,
sticky,
actions: [
+17 -17
View File
@@ -48,7 +48,7 @@ import {
toggledCheck,
type ChangeNode,
} from "./changes";
import { applyStash, commitChanges, deleteFiles, dropStash, showUnstashDialog, stashChanges } from "./changeOps";
import { applyStash, commitChanges, deleteUnversioned, dropStash, showUnstashDialog, stashChanges } from "./changeOps";
import { CommitOptionsPopup } from "./CommitOptions";
import { DEFAULT_LIST_NAME, useChangelists, withListDefaults } from "./changelists";
import { moveToChangelist } from "./changelistsUi";
@@ -226,16 +226,14 @@ export function listMenu(id: string, active: boolean, files: readonly FileStatus
}
/** Paths Delete… applies to: only when every selected file is unversioned (versioned ones are rolled back). */
/** The selected files that still exist in the working tree (Delete… removes them from disk). */
export function deletableFiles(nodes: ChangeNode[]): string[] {
export function deletableUnversioned(nodes: ChangeNode[]): string[] {
const files = filesOf(nodes);
const onDisk = (f: FileStatus) => f.unstaged !== "deleted" && !(f.staged === "deleted" && !f.unstaged);
return files.length && files.every(onDisk) ? files.map((f) => f.path) : [];
return files.length && files.every((f) => f.unstaged === "untracked") ? files.map((f) => f.path) : [];
}
export function nodeMenu(nodes: ChangeNode[], repos: readonly string[]): MenuItem[] {
const files = filesOf(nodes);
const deletable = deletableFiles(nodes);
const deletable = deletableUnversioned(nodes);
const paths = files.map((f) => f.path);
const first = nodes.find((n) => n.type === "file");
const untracked = files.filter((f) => f.unstaged === "untracked");
@@ -256,16 +254,18 @@ export function nodeMenu(nodes: ChangeNode[], repos: readonly string[]): MenuIte
items.push(
{ kind: "action", action: "vcs.rollback", arg: paths, label: "Rollback…" },
...(untracked.length
? [{ kind: "action" as const, action: "vcs.addToVcs", arg: untracked.map((f) => f.path) }]
: []),
...(deletable.length
? [
{
label: "Delete…",
icon: "trash",
shortcut: formatBinding(isMacHost() ? "Backspace" : "Delete"),
run: () => void deleteFiles(deletable),
},
{ kind: "action" as const, action: "vcs.addToVcs", arg: untracked.map((f) => f.path) },
...(deletable.length
? [
{
label: "Delete…",
icon: "trash",
shortcut: formatBinding(isMacHost() ? "Backspace" : "Delete"),
run: () => void deleteUnversioned(deletable),
},
]
: []),
]
: []),
...(toIgnore.length
@@ -534,7 +534,7 @@ export function ChangesTree({ mode }: { mode: "commit" | "local" | "dialog" }) {
} else if ((e.key === "Delete" || e.key === "Backspace") && !e.metaKey) {
const nodes = selectedNodes();
const group = nodes.find((x) => x.type === "group" && x.changelist);
const deletable = group ? [] : deletableFiles(nodes);
const deletable = group ? [] : deletableUnversioned(nodes);
if (group?.changelist) {
e.preventDefault();
e.stopPropagation();
@@ -542,7 +542,7 @@ export function ChangesTree({ mode }: { mode: "commit" | "local" | "dialog" }) {
} else if (deletable.length) {
e.preventDefault();
e.stopPropagation();
void deleteFiles(deletable);
void deleteUnversioned(deletable);
}
} else if (e.key === "F4") {
const n = selectedNodes().find((x) => x.type === "file");
-15
View File
@@ -377,21 +377,6 @@
text-overflow: ellipsis;
color: var(--fg-subtle);
}
.lineStats {
display: inline-flex;
gap: 6px;
flex: none;
font-variant-numeric: tabular-nums;
}
.fileRow .lineStats {
margin-left: auto;
}
.lineAdded {
color: var(--vcs-added);
}
.lineRemoved {
color: var(--vcs-deleted);
}
.messageBox {
height: 100%;
overflow: auto;
+2 -53
View File
@@ -104,7 +104,6 @@ import {
showChangesDiff,
toLogChanges,
useLogChanges,
withLineStats,
type LogChange,
} from "./logChanges";
import { canDeleteBranch, canUpdateBranch, useBranchesPane, type BranchTarget } from "./logPaneActions";
@@ -2176,9 +2175,8 @@ export function CommitDetails({ commits }: { commits: Commit[] }) {
const onlyAffected = useLogOptions((s) => s.showOnlyAffected);
const preview = useLogOptions((s) => s.diffPreview);
const diffLocation = useLogOptions((s) => s.diffLocation);
const showLineStats = useLogOptions((s) => s.showLineStats);
const filters = useGitLog((s) => s.filters);
const filesKey = JSON.stringify([idsKey, fromParents, parentIndex, commitRoot, showLineStats]);
const filesKey = JSON.stringify([idsKey, fromParents, parentIndex, commitRoot]);
const files = result?.key === filesKey ? result.files : null;
const error = result?.key === filesKey ? result.error : null;
@@ -2227,7 +2225,6 @@ export function CommitDetails({ commits }: { commits: Commit[] }) {
return toLogChanges(files, c, commitRoot, readOnly);
})();
load
.then((files) => (showLineStats ? withLineStats(files).catch(() => files) : files))
.then((files) => alive && setResult({ key: filesKey, files, error: null }))
.catch(
(e) => alive && setResult({ key: filesKey, files: null, error: e instanceof Error ? e.message : String(e) }),
@@ -2235,7 +2232,7 @@ export function CommitDetails({ commits }: { commits: Commit[] }) {
return () => {
alive = false;
};
}, [idsKey, chosen, fromParents, parentIndex, commitRoot, filesKey, many, readOnly, showLineStats]);
}, [idsKey, chosen, fromParents, parentIndex, commitRoot, filesKey, many, readOnly]);
const groupByDirectory = useLogOptions((s) => s.groupByDirectory);
const [selectedKeys, setSelectedKeys] = useState<ReadonlySet<string>>(() => new Set());
@@ -2257,14 +2254,6 @@ export function CommitDetails({ commits }: { commits: Commit[] }) {
)
: changes;
}, [files, commit, many, filters.path, filters.paths, onlyAffected]);
const lineTotals = useMemo(() => {
if (!showLineStats || !changes.length) return null;
if (changes.some((f) => f.added === undefined || f.removed === undefined)) return null;
return changes.reduce((sum, f) => ({ added: sum.added + f.added!, removed: sum.removed + f.removed! }), {
added: 0,
removed: 0,
});
}, [changes, showLineStats]);
const multipleRoots = new Set(chosen.map(commitRepo)).size > 1;
const changeTreePath = useCallback(
(f: LogChange) => `${multipleRoots ? `${repoName(f.repo ?? "")}/` : ""}${f.group ? `${f.group}/` : ""}${f.path}`,
@@ -2352,7 +2341,6 @@ export function CommitDetails({ commits }: { commits: Commit[] }) {
{ kind: "separator" },
{ kind: "action", action: "vcs.log.changes.diffPreview" },
toggle("Show Only Affected Changes", logOptions(), "showOnlyAffected"),
toggle("Show lines added and deleted", logOptions(), "showLineStats"),
toggle("Show Changes from Parents", logOptions(), "changesFromParents"),
{
label: "Diff Preview Location",
@@ -2416,7 +2404,6 @@ export function CommitDetails({ commits }: { commits: Commit[] }) {
{files ? fileCount(changes.length) : ""}
{many ? " " + tx`in ${chosen.length} commits` : ""}
</span>
{lineTotals && <LineStats added={lineTotals.added} removed={lineTotals.removed} />}
<span className={styles.grow} />
<IconButton
icon="expand-all"
@@ -2712,50 +2699,13 @@ export function changesMenu(changes: LogChange[]): MenuItem[] {
];
}
function lineCount(file: ChangedFile | LogChange): { added: number; removed: number } | null {
if (!("added" in file) || typeof file.added !== "number" || typeof file.removed !== "number") return null;
return { added: file.added, removed: file.removed };
}
/** Sum of a directory once every file under it has counts; otherwise the row stays without them. */
function nodeLineCount(node: FileTreeNode<ChangedFile | LogChange>): { added: number; removed: number } | null {
if (node.file) return lineCount(node.file);
let added = 0;
let removed = 0;
for (const child of node.children) {
const count = nodeLineCount(child);
if (!count) return null;
added += count.added;
removed += count.removed;
}
return node.children.length ? { added, removed } : null;
}
function LineStats({ added, removed }: { added: number; removed: number }) {
return (
<span className={styles.lineStats}>
<span className={styles.lineAdded} aria-label={added === 1 ? tx`${added} line added` : tx`${added} lines added`}>
+{added}
</span>
<span
className={styles.lineRemoved}
aria-label={removed === 1 ? tx`${removed} line deleted` : tx`${removed} lines deleted`}
>
−{removed}
</span>
</span>
);
}
function ChangedFileRow({ node, flat = false }: { node: FileTreeNode<ChangedFile | LogChange>; flat?: boolean }) {
const stats = nodeLineCount(node);
if (!node.file)
return (
<span className={styles.fileRow}>
<Icon name="folder" size={16} className={styles.fileIcon} />
<span className={styles.fileName}>{node.name}</span>
<span className={styles.fileDir}>{fileCount(node.count)}</span>
{stats && <LineStats added={stats.added} removed={stats.removed} />}
</span>
);
const f = node.file;
@@ -2768,7 +2718,6 @@ function ChangedFileRow({ node, flat = false }: { node: FileTreeNode<ChangedFile
</span>
{flat && dirName(f.path) && <span className={styles.fileDir}>{dirName(f.path)}</span>}
{f.oldPath && <span className={styles.fileDir}>{tx`renamed from ${baseName(f.oldPath)}`}</span>}
{stats && <LineStats added={stats.added} removed={stats.removed} />}
</span>
);
}
-1
View File
@@ -11,7 +11,6 @@ const OPTIONS: { key: Flag; label: string }[] = [
{ key: "tagNames", label: "Show tag names" },
{ key: "commitTimestamp", label: "Show commit time rather than author time" },
{ key: "refsOnLeft", label: "Show references on the left" },
{ key: "showLineStats", label: "Show lines added and deleted" },
];
const COLUMNS: { key: Flag; label: string }[] = [
+1 -14
View File
@@ -5,7 +5,7 @@ vi.mock("../dialogs", async (original) => ({
...(await original<typeof import("../dialogs")>()),
promptText: mocks.prompt,
}));
import { newBranch, newBranchInRepos, renameBranch } from "../branchOps";
import { newBranch, newBranchInRepos } from "../branchOps";
import { useVcs } from "../store";
const branch = (head: string | null): BranchInfo => ({
@@ -61,16 +61,3 @@ it("prefills the current branch in the multi-repository dialog too", async () =>
await newBranchInRepos(["/p/front", "/p/back"]);
expect(mocks.prompt.mock.calls[0]?.[0].initial).toBe("feature/ui");
});
it.each([
["New Branch", () => newBranch()],
["New Branch from a revision", () => newBranch("origin/main")],
["New Branch in several repositories", () => newBranchInRepos(["/p/front", "/p/back"])],
["Rename Branch", () => renameBranch("main")],
])("%s turns a typed space into a separator, as IDEA does", async (_name, open) => {
await open();
const { sanitize, validate } = mocks.prompt.mock.calls[0]![0];
const typed: string = sanitize("fix login");
expect(typed).toBe("fix-login");
expect(validate(typed)).toBeNull();
});
@@ -13,11 +13,11 @@ vi.mock("../dialogs", async (original) => ({
confirmDialog: mocks.confirm,
}));
const { deletableFiles, nodeMenu } = await import("../CommitView");
const { deleteFiles } = await import("../changeOps");
const { deletableUnversioned, nodeMenu } = await import("../CommitView");
const { deleteUnversioned } = await import("../changeOps");
const file = (rel: string, unstaged: FileStatus["unstaged"], staged: FileStatus["staged"] = null): ChangeNode => {
const f: FileStatus = { path: `/p/${rel}`, relPath: rel, staged, unstaged, renamedFrom: null };
const file = (rel: string, unstaged: FileStatus["unstaged"]): ChangeNode => {
const f: FileStatus = { path: `/p/${rel}`, relPath: rel, staged: null, unstaged, renamedFrom: null };
return { key: f.path, type: "file", label: rel, group: "unversioned", files: [f], path: f.path };
};
const deleteItem = (menu: MenuItem[]) =>
@@ -29,30 +29,30 @@ beforeEach(() => {
mocks.call.mockResolvedValue(null);
});
describe("Delete… in the Changes view", () => {
it("is offered when every selected file is still on disk", () => {
describe("Delete… for unversioned files", () => {
it("is offered only when every selected file is unversioned", () => {
expect(deleteItem(nodeMenu([file("a.txt", "untracked"), file("b.txt", "untracked")], ["/p"]))).toBeDefined();
expect(deleteItem(nodeMenu([file("a.txt", "untracked"), file("c.txt", "modified")], ["/p"]))).toBeDefined();
expect(deleteItem(nodeMenu([file("c.txt", "modified")], ["/p"]))).toBeDefined();
expect(deleteItem(nodeMenu([file("d.txt", null, "added")], ["/p"]))).toBeDefined();
expect(deleteItem(nodeMenu([file("c.txt", "modified"), file("e.txt", "deleted")], ["/p"]))).toBeUndefined();
expect(deleteItem(nodeMenu([file("f.txt", null, "deleted")], ["/p"]))).toBeUndefined();
expect(deleteItem(nodeMenu([file("a.txt", "untracked"), file("c.txt", "modified")], ["/p"]))).toBeUndefined();
expect(deleteItem(nodeMenu([file("c.txt", "modified")], ["/p"]))).toBeUndefined();
});
it("shows the Delete key and gives the key handler the same paths", () => {
expect(deleteItem(nodeMenu([file("a.txt", "untracked")], ["/p"]))).toMatchObject({ shortcut: "⌫" });
expect(deletableFiles([file("a.txt", "untracked"), file("c.txt", "modified")])).toEqual(["/p/a.txt", "/p/c.txt"]);
expect(deletableFiles([file("a.txt", "untracked"), file("e.txt", "deleted")])).toEqual([]);
expect(deletableFiles([])).toEqual([]);
expect(deletableUnversioned([file("a.txt", "untracked"), file("b.txt", "untracked")])).toEqual([
"/p/a.txt",
"/p/b.txt",
]);
expect(deletableUnversioned([file("a.txt", "untracked"), file("c.txt", "modified")])).toEqual([]);
expect(deletableUnversioned([])).toEqual([]);
});
it("asks first, then deletes each file", async () => {
mocks.confirm.mockResolvedValueOnce(false);
await deleteFiles(["/p/a.txt"]);
await deleteUnversioned(["/p/a.txt"]);
expect(mocks.fsDelete).not.toHaveBeenCalled();
mocks.confirm.mockResolvedValueOnce(true);
await deleteFiles(["/p/a.txt", "/p/b.txt"]);
await deleteUnversioned(["/p/a.txt", "/p/b.txt"]);
expect(mocks.fsDelete.mock.calls.map((c) => c[0])).toEqual(["/p/a.txt", "/p/b.txt"]);
});
});
+2 -67
View File
@@ -1,9 +1,8 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { fireEvent, render, screen } from "@testing-library/react";
import type { Branch, Commit } from "@/bindings";
const vcsCommitFiles = vi.fn();
const vcsDiffNumstat = vi.fn();
const vcsTags = vi.fn();
vi.mock("@/platform/ipc", async (importOriginal) => {
@@ -13,8 +12,6 @@ vi.mock("@/platform/ipc", async (importOriginal) => {
commands: {
...real.commands,
vcsCommitFiles: (repo: unknown, id: string) => vcsCommitFiles(repo, id),
vcsDiffNumstat: (repo: unknown, from: string, to: string, paths: string[]) =>
vcsDiffNumstat(repo, from, to, paths),
vcsTags: (repo: unknown) => vcsTags(repo),
vcsLog: () => Promise.resolve({ status: "ok", data: [] }),
vcsConfigGet: () => Promise.resolve({ status: "ok", data: null }),
@@ -24,16 +21,7 @@ vi.mock("@/platform/ipc", async (importOriginal) => {
import { buildBranchTree, filterBranchTree } from "../branches";
import { buildFileTree } from "../fileTree";
import {
EMPTY_TREE,
assignLineStats,
filesUnder,
filterPatch,
flatFileNodes,
lineStatRanges,
toLogChanges,
useLogChanges,
} from "../logChanges";
import { filesUnder, filterPatch, flatFileNodes, toLogChanges, useLogChanges } from "../logChanges";
import { canDeleteBranch, canUpdateBranch, useBranchesPane } from "../logPaneActions";
import { BranchesPanel, CommitDetails, changesMenu, graphOptionsMenu } from "../GitLog";
import { LogSettingsPage } from "../LogSettingsPage";
@@ -73,7 +61,6 @@ afterEach(() => {
useGitLog.setState({ commits: [], selection: [], selected: null, filters: emptyFilters() });
useLogOptions.setState({ ...DEFAULT_LOG_OPTIONS });
vcsCommitFiles.mockReset();
vcsDiffNumstat.mockReset();
vcsTags.mockReset();
});
@@ -90,28 +77,6 @@ describe("changed files model", () => {
expect([mod!.before, mod!.after]).toEqual([commit.parents[0], commit.id]);
expect([add!.before, add!.after]).toEqual([null, commit.id]);
expect([del!.before, del!.after]).toEqual([commit.parents[0], null]);
// Added and deleted files still count against the parent diff, not the empty tree.
expect([add!.statsFrom, add!.statsTo, del!.statsTo]).toEqual([commit.parents[0], commit.id, commit.id]);
const [rootFile] = toLogChanges([{ path: "a.ts", kind: "added", oldPath: null }], { ...commit, parents: [] });
expect(rootFile!.statsFrom).toBe(EMPTY_TREE);
});
it("asks for one numstat per revision pair and matches a rename by either name", () => {
const files = toLogChanges(
[
{ path: "a.ts", kind: "modified", oldPath: null },
{ path: "b.ts", kind: "added", oldPath: null },
{ path: "new.ts", kind: "renamed", oldPath: "old.ts" },
],
commit,
);
expect(lineStatRanges(files).map(({ from, to, paths }) => ({ from, to, paths }))).toEqual([
{ from: commit.parents[0], to: commit.id, paths: ["a.ts", "b.ts", "new.ts", "old.ts"] },
]);
const [renamed] = assignLineStats(files, [
{ ...lineStatRanges(files)[0]!, stats: [{ path: "new.ts", oldPath: "old.ts", added: 1, removed: 2 }] },
]).filter((f) => f.path === "new.ts");
expect([renamed!.added, renamed!.removed]).toEqual([1, 2]);
});
it("lists files without directories by name, and the files under selected directories", () => {
@@ -163,34 +128,6 @@ describe("changed files pane", () => {
unmount();
expect(useLogChanges.getState().selected).toEqual([]);
});
it("shows added and deleted lines when the log option is on", async () => {
vcsCommitFiles.mockResolvedValue({
status: "ok",
data: [
{ path: "src/a.ts", kind: "modified", oldPath: null },
{ path: "src/b.ts", kind: "added", oldPath: null },
],
});
vcsDiffNumstat.mockResolvedValue({
status: "ok",
data: [
{ path: "src/a.ts", oldPath: null, added: 2, removed: 1 },
{ path: "src/b.ts", oldPath: null, added: 4, removed: 0 },
],
});
useLogOptions.setState({ showLineStats: true });
useGitLog.setState({ commits: [commit], selected: commit.id, selection: [commit.id] });
render(<CommitDetails commits={[commit]} />);
expect(await screen.findByLabelText("2 lines added")).toBeInTheDocument();
// The file, its directory and the toolbar each report the deleted line.
expect(screen.getAllByLabelText("1 line deleted")).toHaveLength(3);
expect(screen.getAllByLabelText("6 lines added")).toHaveLength(2);
expect(vcsDiffNumstat.mock.calls[0]!.slice(1)).toEqual([commit.parents[0], commit.id, ["src/a.ts", "src/b.ts"]]);
useLogOptions.setState({ showLineStats: false });
await waitFor(() => expect(screen.queryByLabelText("2 lines added")).not.toBeInTheDocument());
});
});
describe("Branches pane", () => {
@@ -253,7 +190,5 @@ describe("Settings › Version Control › Log", () => {
expect(useLogOptions.getState().tagNames).toBe(!DEFAULT_LOG_OPTIONS.tagNames);
fireEvent.click(screen.getByLabelText("Hash"));
expect(useLogOptions.getState().showHash).toBe(!DEFAULT_LOG_OPTIONS.showHash);
fireEvent.click(screen.getByLabelText("Show lines added and deleted"));
expect(useLogOptions.getState().showLineStats).toBe(true);
});
});
Loaded 100 of 132 files, more files were not shown because too many files have changed in this diff. Show more