mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 03:08:18 -10:00
Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff3d6213a4 | ||
|
|
4002eaa8e9 | ||
|
|
98268106b8 | ||
|
|
2e9a637158 | ||
|
|
f50a8a301c | ||
|
|
07df51f308 | ||
|
|
38ff63658e | ||
|
|
7dbce43c88 | ||
|
|
f68d8f499a | ||
|
|
7a715758a6 | ||
|
|
e5c250aad8 | ||
|
|
ad64506336 | ||
|
|
f3fe837a66 | ||
|
|
af0ffb809e | ||
|
|
31e477b7fb | ||
|
|
425801be34 | ||
|
|
269f3b22fd | ||
|
|
2c6131999c | ||
|
|
97177486e9 | ||
|
|
57d35ecdb4 | ||
|
|
a275239138 | ||
|
|
f35026f31c | ||
|
|
875e049dcd | ||
|
|
4ca44ee815 | ||
|
|
1186cdce64 | ||
|
|
f665beb17e | ||
|
|
d1acf9f9ac | ||
|
|
c179b1a822 | ||
|
|
846ac97650 | ||
|
|
b70c01d1d6 |
No files matched your search
Generated
+1
@@ -4121,6 +4121,7 @@ dependencies = [
|
||||
name = "ide-build"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dirs 6.0.0",
|
||||
"dunce",
|
||||
"ide-core",
|
||||
|
||||
@@ -6,6 +6,7 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
blake3.workspace = true
|
||||
dunce.workspace = true
|
||||
ide-core.workspace = true
|
||||
zip.workspace = true
|
||||
|
||||
@@ -2,18 +2,49 @@
|
||||
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,
|
||||
files: BTreeMap<PathBuf, Option<(u64, SystemTime)>>,
|
||||
/// 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())
|
||||
}
|
||||
|
||||
fn scan(
|
||||
root: &Path,
|
||||
path: &Path,
|
||||
files: &mut BTreeMap<PathBuf, Option<(u64, SystemTime)>>,
|
||||
files: &mut BTreeMap<PathBuf, Option<Stamp>>,
|
||||
ancestors: &mut HashSet<PathBuf>,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<()> {
|
||||
@@ -36,11 +67,12 @@ 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(&entry?.path(), files, ancestors, cancel)?;
|
||||
scan(root, &entry?.path(), files, ancestors, cancel)?;
|
||||
}
|
||||
ancestors.remove(&canonical);
|
||||
} else if meta.is_file() {
|
||||
files.insert(path.to_path_buf(), Some((meta.len(), meta.modified()?)));
|
||||
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)));
|
||||
} else {
|
||||
return Err(IdeError::internal("unsupported launch input file"));
|
||||
}
|
||||
@@ -145,11 +177,11 @@ pub fn launch_fingerprint(
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
let mut files = BTreeMap::new();
|
||||
for path in inputs {
|
||||
scan(&path, &mut files, &mut HashSet::new(), cancel)?;
|
||||
scan(root, &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: 1, parameters, files }))
|
||||
Ok(Some(LaunchFingerprint { version: 2, parameters, files }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -231,6 +263,25 @@ 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();
|
||||
|
||||
@@ -252,6 +252,8 @@ pub struct SmartKeysSettings {
|
||||
/// Reformat on paste: `none` | `indentBlock` | `indentEachLine`.
|
||||
pub reformat_on_paste: String,
|
||||
pub smart_indent: bool,
|
||||
/// Enter › Insert documentation comment stub: Enter after `/**` closes the comment.
|
||||
pub insert_doc_stub: bool,
|
||||
/// Backspace › Unindent: `autoindent` (proper indent position) | `nearest` indent position | `none`.
|
||||
pub backspace_unindent: String,
|
||||
}
|
||||
@@ -267,6 +269,7 @@ impl Default for SmartKeysSettings {
|
||||
tab_out_of_pair: true,
|
||||
reformat_on_paste: "indentEachLine".into(),
|
||||
smart_indent: true,
|
||||
insert_doc_stub: true,
|
||||
backspace_unindent: "autoindent".into(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -601,7 +601,7 @@ impl Default for FileSettings {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct LanguagesSettings {
|
||||
pub python: PythonSettings,
|
||||
@@ -612,11 +612,34 @@ 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")]
|
||||
@@ -630,6 +653,7 @@ impl LanguagesSettings {
|
||||
match tool {
|
||||
ExternalFormatter::Prettier => &self.prettier,
|
||||
ExternalFormatter::Oxfmt => &self.oxfmt,
|
||||
ExternalFormatter::Ruff => &self.ruff,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -640,6 +664,7 @@ impl LanguagesSettings {
|
||||
pub enum ExternalFormatter {
|
||||
Prettier,
|
||||
Oxfmt,
|
||||
Ruff,
|
||||
}
|
||||
|
||||
impl ExternalFormatter {
|
||||
@@ -648,16 +673,19 @@ impl ExternalFormatter {
|
||||
match self {
|
||||
Self::Prettier => "prettier",
|
||||
Self::Oxfmt => "oxfmt",
|
||||
Self::Ruff => "ruff",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An external formatter (Prettier, oxfmt) and the languages it formats.
|
||||
/// An external formatter (Prettier, oxfmt, Ruff) 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, managed installation, then PATH.
|
||||
/// Empty path: project node_modules (Prettier/oxfmt), 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,
|
||||
@@ -668,6 +696,7 @@ impl Default for ExternalFormatterSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
path: String::new(),
|
||||
configuration_directory: String::new(),
|
||||
languages: [
|
||||
"markdown",
|
||||
"yaml",
|
||||
|
||||
@@ -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.
|
||||
fn version_key(name: &str) -> Vec<u64> {
|
||||
pub fn version_key(name: &str) -> Vec<u64> {
|
||||
name.trim_start_matches('v').split('.').map(|n| n.parse().unwrap_or(0)).collect()
|
||||
}
|
||||
|
||||
|
||||
@@ -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, Range, TextEdit};
|
||||
use ide_core::{DocId, IdeError, IdeEvent, IdeResult, Pos, Range, TextEdit};
|
||||
use ide_language::LanguageConfig;
|
||||
use ide_lsp::types::FormatOptions;
|
||||
use ide_tools::{Tools, formatter};
|
||||
@@ -11,20 +11,27 @@ 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). 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.
|
||||
/// `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.
|
||||
fn external_tool(
|
||||
forced: Option<ExternalFormatter>,
|
||||
language: Option<&LanguageConfig>,
|
||||
file: &Path,
|
||||
settings: &LanguagesSettings,
|
||||
) -> IdeResult<Option<ExternalFormatter>> {
|
||||
use ExternalFormatter::{Oxfmt, Prettier};
|
||||
let Some(language) = language.filter(|l| l.formatter.as_ref().is_some_and(|f| f.tool == "prettier")) else {
|
||||
use ExternalFormatter::{Oxfmt, Prettier, Ruff};
|
||||
let Some(language) = language.filter(|l| l.formatter.is_some()) else {
|
||||
return match forced {
|
||||
Some(tool) => Err(IdeError::invalid(format!(
|
||||
"{} cannot format {} files",
|
||||
@@ -34,10 +41,21 @@ fn external_tool(
|
||||
None => Ok(None),
|
||||
};
|
||||
};
|
||||
if forced.is_some() {
|
||||
return Ok(forced);
|
||||
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
|
||||
)))
|
||||
};
|
||||
}
|
||||
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)
|
||||
@@ -46,6 +64,34 @@ 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,
|
||||
@@ -85,13 +131,28 @@ 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 offsets = range.map(|r| (text.pos_to_offset(r.start), text.pos_to_offset(r.end)));
|
||||
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 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, offsets));
|
||||
command.args(formatter::arguments(
|
||||
tool,
|
||||
&config,
|
||||
&snap.path,
|
||||
options.tab_size,
|
||||
options.insert_spaces,
|
||||
range,
|
||||
config_file.as_deref(),
|
||||
));
|
||||
let formatted = formatter::run(tool, command, &snap.text, &Default::default())?;
|
||||
Ok(text
|
||||
.diff(&formatted)
|
||||
@@ -120,7 +181,7 @@ pub async fn format(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ExternalFormatter::{Oxfmt, Prettier};
|
||||
use ExternalFormatter::{Oxfmt, Prettier, Ruff};
|
||||
use ide_core::LanguageId;
|
||||
use ide_language::LanguageRegistry;
|
||||
|
||||
@@ -135,17 +196,36 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_settings_give_every_external_formatter_language_to_prettier() {
|
||||
fn default_settings_use_ruff_for_python_and_prettier_for_the_web() {
|
||||
let settings = LanguagesSettings::default();
|
||||
let file = Path::new("/project/file");
|
||||
for lang in LanguageRegistry::load(None).unwrap().all() {
|
||||
let configured = lang.formatter.as_ref().is_some_and(|f| f.tool == "prettier");
|
||||
let configured = match lang.formatter.as_ref().map(|f| f.tool.as_str()) {
|
||||
Some("prettier") => Some(Prettier),
|
||||
Some("ruff") => Some(Ruff),
|
||||
_ => None,
|
||||
};
|
||||
let selected = external_tool(None, Some(&lang), file, &settings).unwrap();
|
||||
assert_eq!(selected, configured.then_some(Prettier), "{}", lang.id);
|
||||
assert_eq!(selected, configured, "{}", 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();
|
||||
@@ -192,4 +272,21 @@ 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,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -242,6 +242,29 @@ 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();
|
||||
|
||||
@@ -52,6 +52,14 @@ 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)]
|
||||
@@ -198,6 +206,7 @@ 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 },
|
||||
|
||||
@@ -1007,6 +1007,19 @@ 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(
|
||||
@@ -1357,6 +1370,9 @@ 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
|
||||
});
|
||||
|
||||
@@ -30,6 +30,11 @@ pub trait PluginWorkspace: Send + Sync {
|
||||
fn projects(&self) -> Vec<PathBuf>;
|
||||
/// Unsaved text of the open document at `path`.
|
||||
fn buffer_text(&self, path: &Path) -> Option<String>;
|
||||
/// Up to `limit` files of the project index below `root` whose root-relative path satisfies
|
||||
/// `matches`; `None` when no index covers `root` yet (the caller walks the disk instead).
|
||||
fn indexed_files(&self, _root: &Path, _matches: &dyn Fn(&Path) -> bool, _limit: usize) -> Option<Vec<PathBuf>> {
|
||||
None
|
||||
}
|
||||
/// Version and text of the open document at `path`.
|
||||
fn document(&self, path: &Path) -> Option<(u64, String)>;
|
||||
/// Applies `edits` to the open document at `path` if it is still at
|
||||
@@ -306,8 +311,9 @@ 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 when this permission was allowed; \
|
||||
install it, then allow {cap} again in Settings › Plugins"
|
||||
"{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"
|
||||
))
|
||||
})?;
|
||||
pin.verify().map(Path::to_path_buf).map_err(|e| HostError::Denied(format!("{cap}: {e}")))
|
||||
@@ -458,6 +464,19 @@ impl HostApi for PluginApi {
|
||||
.build()
|
||||
.map_err(|e| HostError::Invalid(format!("{glob:?}: {e}")))?
|
||||
.compile_matcher();
|
||||
// Hidden files and folders are left out, as in the walk below.
|
||||
let visible = |rel: &Path| !rel.iter().any(|c| c.to_str().is_some_and(|c| c.starts_with('.')));
|
||||
// The project index already lists the files: walking a large tree on every call can take
|
||||
// longer than an editor provider's deadline (the plugin is then stopped as crashed).
|
||||
if let Some(files) = self
|
||||
.shared
|
||||
.workspace()
|
||||
.and_then(|w| w.indexed_files(root, &|rel| visible(rel) && matcher.is_match(rel), limit))
|
||||
{
|
||||
let mut out: Vec<String> = files.iter().map(|p| to_ipc(p)).collect();
|
||||
out.sort();
|
||||
return Ok(out);
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for (n, entry) in ignore::WalkBuilder::new(root).hidden(true).build().flatten().enumerate() {
|
||||
if n >= MAX_WALK || out.len() >= limit {
|
||||
@@ -737,6 +756,12 @@ impl PluginWorkspace for HostWorkspace {
|
||||
HostWorkspace::projects(self).iter().find_map(|p| p.documents.text_of(path))
|
||||
}
|
||||
|
||||
fn indexed_files(&self, root: &Path, matches: &dyn Fn(&Path) -> bool, limit: usize) -> Option<Vec<PathBuf>> {
|
||||
// The index of the project rooted at `root` (a workspace member's files are in its own).
|
||||
let project = HostWorkspace::projects(self).into_iter().find(|p| p.root == root)?;
|
||||
project.index.files_matching(root, matches, limit)
|
||||
}
|
||||
|
||||
fn document(&self, path: &Path) -> Option<(u64, String)> {
|
||||
let (project, doc) = self.open_document(path)?;
|
||||
let snap = project.documents.snapshot(doc).ok()?;
|
||||
@@ -820,6 +845,60 @@ mod tests {
|
||||
assert_eq!(api.vcs_status(&root).unwrap().unwrap().root, to_ipc(&root));
|
||||
}
|
||||
|
||||
/// A project index that lists `files` (root-relative), whatever is on disk.
|
||||
struct Indexed(PathBuf, Vec<&'static str>);
|
||||
impl PluginWorkspace for Indexed {
|
||||
fn projects(&self) -> Vec<PathBuf> {
|
||||
vec![self.0.clone()]
|
||||
}
|
||||
fn buffer_text(&self, _: &Path) -> Option<String> {
|
||||
None
|
||||
}
|
||||
fn document(&self, _: &Path) -> Option<(u64, String)> {
|
||||
None
|
||||
}
|
||||
fn apply_edits(&self, _: &Path, _: u64, _: &[TextEdit]) -> Result<u64, HostError> {
|
||||
Err(HostError::Stale)
|
||||
}
|
||||
fn trusted(&self, _: &Path) -> bool {
|
||||
true
|
||||
}
|
||||
fn project_of(&self, _: &str) -> Option<PathBuf> {
|
||||
None
|
||||
}
|
||||
fn publish_diagnostics(&self, _: &str, _: &Path, _: Vec<ide_core::Diagnostic>) -> Result<(), HostError> {
|
||||
Ok(())
|
||||
}
|
||||
fn indexed_files(&self, root: &Path, matches: &dyn Fn(&Path) -> bool, limit: usize) -> Option<Vec<PathBuf>> {
|
||||
(root == self.0).then(|| {
|
||||
self.1.iter().map(Path::new).filter(|rel| matches(rel)).take(limit).map(|rel| root.join(rel)).collect()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_files_uses_the_project_index_when_there_is_one() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().canonicalize().unwrap();
|
||||
let api = api(&root, Arc::default());
|
||||
*api.shared.workspace.write() = Some(Arc::new(Indexed(
|
||||
root.clone(),
|
||||
vec!["src/b/UserMapper.xml", "src/a/OrderMapper.xml", ".idea/workspace.xml", "pom.xml"],
|
||||
)));
|
||||
// Listed by the index, not walked: none of these exist on disk.
|
||||
assert_eq!(
|
||||
api.find_files(&root, "**/*Mapper.xml", 10).unwrap(),
|
||||
[to_ipc(&root.join("src/a/OrderMapper.xml")), to_ipc(&root.join("src/b/UserMapper.xml"))]
|
||||
);
|
||||
assert_eq!(api.find_files(&root, "**/*.xml", 10).unwrap().len(), 3, "hidden folders are left out");
|
||||
assert_eq!(api.find_files(&root, "**/*.xml", 1).unwrap().len(), 1);
|
||||
// Another root: no index covers it, so the disk is walked.
|
||||
let other = root.join("other");
|
||||
std::fs::create_dir_all(&other).unwrap();
|
||||
std::fs::write(other.join("a.xml"), "").unwrap();
|
||||
assert_eq!(api.find_files(&other, "*.xml", 10).unwrap(), [to_ipc(&other.join("a.xml"))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn programs_run_only_as_approved() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -831,7 +910,8 @@ 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 when this permission was allowed") && err.contains("allow process:lint again"),
|
||||
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}"
|
||||
);
|
||||
|
||||
|
||||
@@ -338,6 +338,7 @@ 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"
|
||||
|
||||
@@ -35,6 +35,8 @@ async fn opens_unity_without_generated_solutions_and_excludes_caches_from_the_in
|
||||
assert!(unity.projects.is_empty() && unity.solutions.is_empty());
|
||||
assert!(project.index.wait_idle(Duration::from_secs(20)));
|
||||
assert_eq!(project.index.find_files("Player.cs", 100).len(), 1);
|
||||
let listed = project.index.files_matching(&project.root, |rel| rel.ends_with("Player.cs"), 10).unwrap();
|
||||
assert_eq!(listed.len(), 1);
|
||||
for name in ["Cached.cs", "Temporary.cs", "Editor.log"] {
|
||||
assert!(project.index.find_files(name, 100).is_empty(), "indexed generated file {name}");
|
||||
}
|
||||
|
||||
@@ -197,6 +197,28 @@ impl ProjectIndex {
|
||||
matching::find_files(&d, &matching::Recents { paths: &recent }, query, limit)
|
||||
}
|
||||
|
||||
/// Up to `limit` indexed files below `dir` (VCS-ignored ones left out) whose path relative to
|
||||
/// `dir` satisfies `matches`: a file list without walking the disk. `None` while nothing is
|
||||
/// indexed yet (first scan without a cache).
|
||||
pub fn files_matching(&self, dir: &Path, matches: impl Fn(&Path) -> bool, limit: usize) -> Option<Vec<PathBuf>> {
|
||||
let d = self.shared.data.read();
|
||||
if d.file_count == 0 {
|
||||
return None;
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for file in d.files.iter().flatten().filter(|f| !f.ignored) {
|
||||
if out.len() >= limit {
|
||||
break;
|
||||
}
|
||||
if let Ok(rel) = file.path.strip_prefix(dir)
|
||||
&& matches(rel)
|
||||
{
|
||||
out.push(file.path.clone());
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Fuzzy search for folders that hold indexed files; `rel_path` ends in `/`.
|
||||
pub fn find_dirs(&self, query: &str, limit: usize) -> Vec<FileMatch> {
|
||||
matching::find_dirs(&self.shared.data.read(), query, limit)
|
||||
|
||||
@@ -533,6 +533,7 @@ mod tests {
|
||||
"Julia",
|
||||
"Kotlin",
|
||||
"LiveScript",
|
||||
"Log",
|
||||
"Lua",
|
||||
"mIRC",
|
||||
"Mathematica",
|
||||
|
||||
@@ -136,6 +136,12 @@ impl ClientHandler {
|
||||
|
||||
fn show_message(&self, mgr: &LspManager, params: &Value) {
|
||||
let text = params.get("message").and_then(Value::as_str).unwrap_or_default().to_owned();
|
||||
// rust-analyzer built from source reports its own slow main-loop turns as errors, meant for
|
||||
// its developers (`poke_rust_analyzer_developer`); nothing for the user to act on.
|
||||
if text.starts_with("overly long loop turn") {
|
||||
tracing::warn!("[{}] {text}", self.server.name);
|
||||
return;
|
||||
}
|
||||
let level = match params.get("type").and_then(Value::as_u64) {
|
||||
Some(1) => NotifyLevel::Error,
|
||||
Some(2) => NotifyLevel::Warning,
|
||||
|
||||
@@ -954,6 +954,11 @@ async fn apply_edit_hook_and_server_requests() {
|
||||
assert_eq!(v, Value::Null);
|
||||
assert!(peer.request::<_, Value>("unknown/method", Value::Null, None).await.is_err());
|
||||
|
||||
peer.notify(
|
||||
"window/showMessage",
|
||||
json!({ "type": 1, "message": "overly long loop turn took 113ms (event handling took 95ms): PrimeCaches" }),
|
||||
)
|
||||
.unwrap();
|
||||
peer.notify("window/showMessage", json!({ "type": 2, "message": "careful" })).unwrap();
|
||||
peer.notify("window/showMessage", json!({ "type": 3, "message": "info only" })).unwrap();
|
||||
peer.notify("window/logMessage", json!({ "type": 1, "message": "log" })).unwrap();
|
||||
@@ -962,7 +967,10 @@ async fn apply_edit_hook_and_server_requests() {
|
||||
})
|
||||
.await;
|
||||
let notifies = h.events.lock().iter().filter(|e| matches!(e, IdeEvent::Notify { .. })).count();
|
||||
assert_eq!(notifies, 2, "showMessageRequest error + showMessage warning; info is not surfaced");
|
||||
assert_eq!(
|
||||
notifies, 2,
|
||||
"showMessageRequest error + showMessage warning; info and rust-analyzer's developer notices are not surfaced"
|
||||
);
|
||||
|
||||
// Dynamically registered capability is now usable.
|
||||
s.respond(
|
||||
|
||||
@@ -71,6 +71,62 @@ pub fn context_xml(doc_base: &Path, reload: bool) -> String {
|
||||
format!("<Context docBase=\"{}\" reloadable=\"{reload}\" />\n", escape(&doc_base.to_string_lossy()))
|
||||
}
|
||||
|
||||
/// The web app's own `META-INF/context.xml`, from an exploded directory or a WAR.
|
||||
fn app_context_xml(doc_base: &Path) -> Option<String> {
|
||||
if doc_base.is_dir() {
|
||||
return fs::read_to_string(doc_base.join("META-INF/context.xml")).ok();
|
||||
}
|
||||
let mut zip = zip::ZipArchive::new(fs::File::open(doc_base).ok()?).ok()?;
|
||||
let mut text = String::new();
|
||||
zip.by_name("META-INF/context.xml").ok()?.read_to_string(&mut text).ok()?;
|
||||
Some(text)
|
||||
}
|
||||
|
||||
/// The context descriptor for `conf/Catalina/localhost`. Tomcat ignores the app's
|
||||
/// `META-INF/context.xml` once a descriptor exists there, so its content (`<Resource>`,
|
||||
/// `<Environment>`, `<Valve>`…) is carried over, with `docBase` and `reloadable` set by the IDE.
|
||||
pub fn deployed_context_xml(doc_base: &Path, reload: bool) -> String {
|
||||
let Some(app) = app_context_xml(doc_base) else { return context_xml(doc_base, reload) };
|
||||
merge_context_xml(&app, doc_base, reload).unwrap_or_else(|e| {
|
||||
tracing::warn!("ignoring the web app's META-INF/context.xml: {e}");
|
||||
context_xml(doc_base, reload)
|
||||
})
|
||||
}
|
||||
|
||||
fn merge_context_xml(app: &str, doc_base: &Path, reload: bool) -> IdeResult<String> {
|
||||
let mut reader = Reader::from_str(app);
|
||||
let mut writer = Writer::new(Vec::new());
|
||||
let mut root = false;
|
||||
loop {
|
||||
let event = reader.read_event().map_err(invalid)?;
|
||||
let event = match event {
|
||||
Event::Eof => break,
|
||||
Event::Start(ref tag) | Event::Empty(ref tag) if !root => {
|
||||
if tag.name().as_ref() != "Context" {
|
||||
return Err(invalid("the root element is not <Context>"));
|
||||
}
|
||||
root = true;
|
||||
let mut new = BytesStart::new("Context");
|
||||
for a in tag.attributes() {
|
||||
let a = a.map_err(invalid)?;
|
||||
if !matches!(a.key.as_ref(), "docBase" | "reloadable" | "path") {
|
||||
new.push_attribute(a);
|
||||
}
|
||||
}
|
||||
new.push_attribute(("docBase", doc_base.to_string_lossy().as_ref()));
|
||||
new.push_attribute(("reloadable", if reload { "true" } else { "false" }));
|
||||
if matches!(event, Event::Empty(_)) { Event::Empty(new) } else { Event::Start(new) }
|
||||
}
|
||||
e => e,
|
||||
};
|
||||
writer.write_event(event).map_err(invalid)?;
|
||||
}
|
||||
if !root {
|
||||
return Err(invalid("no <Context> element"));
|
||||
}
|
||||
String::from_utf8(writer.into_inner()).map_err(invalid)
|
||||
}
|
||||
|
||||
pub fn patch_server_xml(xml: &str, http: u16, shutdown: u16, secret: &str) -> IdeResult<String> {
|
||||
let mut reader = Reader::from_str(xml);
|
||||
let mut writer = Writer::new(Vec::new());
|
||||
@@ -442,7 +498,7 @@ pub fn prepare(root: &Path, cache: &Path, config: &mut RunConfig) -> IdeResult<(
|
||||
let server = patch_server_xml(&fs::read_to_string(home.join("conf/server.xml"))?, port, shutdown, &secret)?;
|
||||
fs::write(base.join("conf/server.xml"), server)?;
|
||||
let debug = config.extras.params.get("tomcatDebug").is_some_and(|v| v == "true");
|
||||
fs::write(contexts.join(format!("{context}.xml")), context_xml(&doc, !debug))?;
|
||||
fs::write(contexts.join(format!("{context}.xml")), deployed_context_xml(&doc, !debug))?;
|
||||
config.env.insert("CATALINA_HOME".into(), home.to_string_lossy().into_owned());
|
||||
config.env.insert("CATALINA_BASE".into(), base.to_string_lossy().into_owned());
|
||||
if let Some(jre) = config.extras.params.get("jre").filter(|s| !s.trim().is_empty()) {
|
||||
@@ -501,6 +557,48 @@ mod tests {
|
||||
}
|
||||
zip.finish().unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn keeps_the_apps_own_context_descriptor() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let app = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Context path=\"/old\" docBase=\"old\" reloadable=\"false\" antiResourceLocking=\"true\">\n <!-- JNDI -->\n <Resource name=\"jdbc/x\" auth=\"Container\" type=\"javax.sql.DataSource\" url=\"jdbc:h2:mem:a;b=&c\"/>\n <Environment name=\"mode\" value=\"dev\" type=\"java.lang.String\"/>\n</Context>\n";
|
||||
let dir = tmp.path().join("web app");
|
||||
fs::create_dir_all(dir.join("META-INF")).unwrap();
|
||||
fs::write(dir.join("META-INF/context.xml"), app).unwrap();
|
||||
let war = tmp.path().join("app.war");
|
||||
archive(&war, &[("META-INF/context.xml", app), ("index.jsp", "")]);
|
||||
for doc in [&dir, &war] {
|
||||
let xml = deployed_context_xml(doc, true);
|
||||
let doc_base = escape(&doc.to_string_lossy());
|
||||
assert!(
|
||||
xml.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Context antiResourceLocking=\"true\""),
|
||||
"{xml}"
|
||||
);
|
||||
assert!(xml.contains(&format!("docBase=\"{doc_base}\" reloadable=\"true\">")), "{xml}");
|
||||
assert!(!xml.contains("/old") && !xml.contains("\"old\"") && !xml.contains("\"false\""), "{xml}");
|
||||
assert!(xml.contains("<!-- JNDI -->"));
|
||||
assert!(xml.contains("<Resource name=\"jdbc/x\" auth=\"Container\" type=\"javax.sql.DataSource\" url=\"jdbc:h2:mem:a;b=&c\"/>"));
|
||||
assert!(xml.contains("<Environment name=\"mode\""));
|
||||
assert!(xml.trim_end().ends_with("</Context>"));
|
||||
}
|
||||
|
||||
fs::write(dir.join("META-INF/context.xml"), "<Context/>").unwrap();
|
||||
let doc_base = escape(&dir.to_string_lossy());
|
||||
assert_eq!(
|
||||
deployed_context_xml(&dir, false),
|
||||
format!("<Context docBase=\"{doc_base}\" reloadable=\"false\"/>")
|
||||
);
|
||||
// Unreadable or not a context: the IDE's own descriptor, as before.
|
||||
for broken in ["<Context><Resource></Context>", "<Server/>", ""] {
|
||||
fs::write(dir.join("META-INF/context.xml"), broken).unwrap();
|
||||
assert_eq!(deployed_context_xml(&dir, false), context_xml(&dir, false), "{broken}");
|
||||
}
|
||||
let plain = tmp.path().join("plain");
|
||||
fs::create_dir_all(&plain).unwrap();
|
||||
assert_eq!(deployed_context_xml(&plain, true), context_xml(&plain, true));
|
||||
let bare = tmp.path().join("bare.war");
|
||||
archive(&bare, &[("index.jsp", "")]);
|
||||
assert_eq!(deployed_context_xml(&bare, true), context_xml(&bare, true));
|
||||
}
|
||||
fn config() -> RunConfig {
|
||||
RunConfig {
|
||||
id: "local:demo".into(),
|
||||
|
||||
@@ -7,6 +7,7 @@ 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};
|
||||
@@ -28,6 +29,39 @@ 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> {
|
||||
@@ -575,6 +609,18 @@ 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();
|
||||
@@ -719,6 +765,11 @@ 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
|
||||
}
|
||||
|
||||
|
||||
@@ -282,8 +282,38 @@ pub(crate) fn php_source(path: &Path, home: Option<&Path>) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// `php` CLIs: every one on `PATH`, Homebrew's versioned formulae, PHP version managers,
|
||||
/// Laravel Herd, and the usual Windows distributions (XAMPP, Laragon).
|
||||
/// 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).
|
||||
fn php() -> Vec<Sdk> {
|
||||
let mut found: Vec<(PathBuf, &'static str)> = Vec::new();
|
||||
let home = home_dir();
|
||||
@@ -294,6 +324,11 @@ 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()
|
||||
@@ -332,7 +367,7 @@ fn php() -> Vec<Sdk> {
|
||||
}
|
||||
}
|
||||
let found = dedupe_by_real_path(found);
|
||||
par_map(&found, |(path, source)| {
|
||||
let mut sdks = 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() {
|
||||
@@ -346,7 +381,16 @@ 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,6 +1,6 @@
|
||||
use crate::apply::{
|
||||
apply_to_run_configs, configure_server, jdtls_jdk, maven_command, maven_options, project_jdk, python_interpreter,
|
||||
terminal_activation, tsdk, without_derived,
|
||||
ruff_configuration, terminal_activation, tsdk, without_derived,
|
||||
};
|
||||
use crate::python::parse_pip_list;
|
||||
use crate::{bin_dir, exe, venv_of};
|
||||
@@ -481,6 +481,9 @@ 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(),
|
||||
@@ -523,6 +526,38 @@ 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) {
|
||||
@@ -1287,9 +1322,50 @@ 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);
|
||||
|
||||
@@ -120,6 +120,7 @@ impl TerminalManager {
|
||||
for (k, v) in env {
|
||||
cmd.env(k, v);
|
||||
}
|
||||
apply_powershell_env(&shell, &mut cmd);
|
||||
|
||||
let child = pair
|
||||
.slave
|
||||
@@ -633,6 +634,18 @@ 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
|
||||
@@ -938,6 +951,56 @@ 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"), &[]);
|
||||
|
||||
@@ -8,6 +8,18 @@ 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())
|
||||
@@ -32,9 +44,13 @@ fn package_bin(package: &Path, tool: &str) -> IdeResult<PathBuf> {
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Prettier formats a range for some languages (`config.range`); oxfmt formats whole files only.
|
||||
/// Prettier and Ruff format ranges for some languages (`config.range`); oxfmt formats whole files only.
|
||||
pub fn supports_range(tool: &str, config: &FormatterConfig) -> bool {
|
||||
tool == "prettier" && config.range
|
||||
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")
|
||||
}
|
||||
|
||||
impl Tools {
|
||||
@@ -44,7 +60,7 @@ impl Tools {
|
||||
}
|
||||
|
||||
fn resolve_formatter_command(&self, tool: &str, file: &Path, custom: Option<&Path>) -> IdeResult<Command> {
|
||||
if crate::registry::find(tool).is_none_or(|t| t.kind != crate::ToolKind::Formatter) {
|
||||
if !is_formatter(tool) {
|
||||
return Err(IdeError::unavailable(format!("Unknown external formatter: {tool}")));
|
||||
}
|
||||
let package = Path::new("node_modules").join(tool);
|
||||
@@ -73,18 +89,36 @@ impl Tools {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// 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.
|
||||
pub fn arguments(
|
||||
tool: &str,
|
||||
config: &FormatterConfig,
|
||||
file: &Path,
|
||||
tab_size: u32,
|
||||
insert_spaces: bool,
|
||||
range: Option<(u32, u32)>,
|
||||
range: Option<FormatRange>,
|
||||
config_file: Option<&Path>,
|
||||
) -> 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"
|
||||
@@ -107,9 +141,14 @@ pub fn arguments(
|
||||
}
|
||||
args.extend(["--stdin-filepath".into(), file.as_os_str().to_owned()]);
|
||||
if supports_range(tool, config)
|
||||
&& let Some((start, end)) = range
|
||||
&& let Some(range) = range
|
||||
{
|
||||
args.extend(["--range-start".into(), start.to_string().into(), "--range-end".into(), end.to_string().into()]);
|
||||
args.extend([
|
||||
"--range-start".into(),
|
||||
range.start_offset.to_string().into(),
|
||||
"--range-end".into(),
|
||||
range.end_offset.to_string().into(),
|
||||
]);
|
||||
}
|
||||
// CLI defaults only find ignores in cwd; explicitly pass the nearest ancestor's files.
|
||||
for name in [".prettierignore", ".gitignore"] {
|
||||
@@ -181,8 +220,15 @@ mod tests {
|
||||
("vue", true),
|
||||
] {
|
||||
let config = FormatterConfig { tool: "prettier".into(), parser: parser.into(), range: supports_range };
|
||||
let args =
|
||||
arguments("prettier", &config, Path::new("/project with spaces/file.md"), 4, false, Some((3, 9)));
|
||||
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: 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"]));
|
||||
@@ -217,7 +263,9 @@ mod tests {
|
||||
);
|
||||
|
||||
let config = FormatterConfig { tool: "prettier".into(), parser: "typescript".into(), range: true };
|
||||
let args = arguments("oxfmt", &config, &file, 4, false, Some((3, 9)));
|
||||
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 ignore = root.join(".prettierignore").into_os_string();
|
||||
assert_eq!(
|
||||
args[..4],
|
||||
@@ -229,4 +277,38 @@ 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"));
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,15 @@ fn format(
|
||||
};
|
||||
let mut cmd = tools.formatter_command("prettier", path, None)?;
|
||||
cmd.current_dir(path.parent().unwrap());
|
||||
cmd.args(formatter::arguments("prettier", &config, path, 4, true, range));
|
||||
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));
|
||||
formatter::run("prettier", cmd, text, &CancellationToken::new())
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
//! `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"
|
||||
);
|
||||
}
|
||||
@@ -302,6 +302,18 @@ 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")]
|
||||
@@ -1248,6 +1260,12 @@ 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));
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
use crate::cli::{self, run};
|
||||
use crate::repo::{gx, resolve};
|
||||
use crate::{ChangeKind, ChangedFile, MergeSides, RebaseAction, RebaseEntry, Repo, ResetMode};
|
||||
use crate::{ChangeKind, ChangedFile, FileLineStats, MergeSides, RebaseAction, RebaseEntry, Repo, ResetMode};
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
|
||||
/// The in-progress operation git can continue / abort / skip.
|
||||
@@ -394,6 +394,44 @@ 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()])?;
|
||||
@@ -457,4 +495,17 @@ 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 },
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -594,6 +594,42 @@ 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]
|
||||
|
||||
@@ -868,17 +868,18 @@ impl Documents {
|
||||
.then(|| st.path.clone())
|
||||
})
|
||||
.collect();
|
||||
self.reload_from_disk(&paths, force_clean);
|
||||
self.reload_from_disk(&paths, force_clean, true);
|
||||
}
|
||||
|
||||
/// 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);
|
||||
self.reload_from_disk(paths, false, false);
|
||||
}
|
||||
|
||||
fn reload_from_disk(&self, paths: &[PathBuf], force_clean: bool) {
|
||||
/// `dedup`: blind scans skip files whose `(mtime, length)` stamp still matches.
|
||||
fn reload_from_disk(&self, paths: &[PathBuf], force_clean: bool, dedup: bool) {
|
||||
for path in paths {
|
||||
let Some(id) = self.by_path(path) else { continue };
|
||||
let result = self.with_doc(id, |st| {
|
||||
@@ -887,7 +888,23 @@ impl Documents {
|
||||
}
|
||||
let Ok(meta) = std::fs::metadata(&st.path) else { return Ok(()) };
|
||||
let mtime = ide_vfs::mtime_ms(&meta);
|
||||
if mtime == st.disk_mtime && meta.len() == st.disk_len && (!force_clean || st.dirty) {
|
||||
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))
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
if st.dirty {
|
||||
|
||||
@@ -32,6 +32,7 @@ const BUILD_FILES: &[&str] = &[
|
||||
"Pipfile",
|
||||
"requirements.txt",
|
||||
"package.json",
|
||||
"composer.json",
|
||||
];
|
||||
|
||||
/// Folder names that are (or contain) conventional roots.
|
||||
@@ -214,6 +215,15 @@ fn module_roots(root: &Path, rel: &str, files: &[String], out: &mut Vec<SourceFo
|
||||
}
|
||||
}
|
||||
|
||||
// Before `package.json`: a Laravel app's `tests/` is the PHP test root.
|
||||
if has("composer.json")
|
||||
&& let Ok(text) = std::fs::read_to_string(dir.join("composer.json"))
|
||||
{
|
||||
for (sub, kind) in composer_roots(&text) {
|
||||
add(&sub, kind, "composer.json");
|
||||
}
|
||||
}
|
||||
|
||||
if has("package.json") {
|
||||
// A JVM build's `src` holds source sets, not JavaScript.
|
||||
if !has("pom.xml") && !GRADLE_FILES.iter().any(|f| has(f)) {
|
||||
@@ -230,6 +240,37 @@ fn module_roots(root: &Path, rel: &str, files: &[String], out: &mut Vec<SourceFo
|
||||
}
|
||||
}
|
||||
|
||||
/// `autoload.psr-4` folders as Source roots and `autoload-dev.psr-4` ones as Test roots, relative to
|
||||
/// the `composer.json` folder; paths that leave it or point into `vendor/` are left out.
|
||||
fn composer_roots(text: &str) -> Vec<(String, RootKind)> {
|
||||
let Ok(json) = serde_json::from_str::<serde_json::Value>(text) else { return Vec::new() };
|
||||
let mut out = Vec::new();
|
||||
for (section, kind) in [("autoload", RootKind::Source), ("autoload-dev", RootKind::Test)] {
|
||||
let Some(map) = json.get(section).and_then(|a| a.get("psr-4")).and_then(|m| m.as_object()) else { continue };
|
||||
for value in map.values() {
|
||||
let paths: Vec<&str> = match value {
|
||||
serde_json::Value::String(p) => vec![p.as_str()],
|
||||
serde_json::Value::Array(a) => a.iter().filter_map(|p| p.as_str()).collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
for path in paths {
|
||||
let parts: Vec<&str> = path.split(['/', '\\']).filter(|c| !c.is_empty() && *c != ".").collect();
|
||||
if path.starts_with(['/', '\\'])
|
||||
|| parts.iter().any(|c| *c == ".." || c.contains(':'))
|
||||
|| parts.first() == Some(&"vendor")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let sub = parts.join("/");
|
||||
if !out.iter().any(|(p, _)| *p == sub) {
|
||||
out.push((sub, kind));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Gradle's conventional `src/<set>/<lang>` folders that exist: `(set, lang)`.
|
||||
fn source_sets(dir: &Path) -> Vec<(String, String)> {
|
||||
let mut out = Vec::new();
|
||||
@@ -307,6 +348,47 @@ mod tests {
|
||||
assert!(roots.roots.iter().any(|r| r.path == "server/src" && r.kind == RootKind::Source));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composer_psr4_folders_are_source_and_test_roots() {
|
||||
let dir = tree(&[
|
||||
"app/",
|
||||
"database/factories/",
|
||||
"database/seeders/",
|
||||
"lib/",
|
||||
"tests/",
|
||||
"vendor/acme/src/",
|
||||
"package.json",
|
||||
]);
|
||||
fs::write(
|
||||
dir.path().join("composer.json"),
|
||||
r#"{
|
||||
"autoload": { "psr-4": {
|
||||
"App\\": "app/",
|
||||
"Database\\Factories\\": "database/factories/",
|
||||
"Database\\Seeders\\": ["database/seeders/", "lib", "missing/"],
|
||||
"Vendored\\": "vendor/acme/src/",
|
||||
"Outside\\": "../elsewhere/"
|
||||
} },
|
||||
"autoload-dev": { "psr-4": { "Tests\\": "tests/" } }
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let mut roots = summary(&detect(dir.path()));
|
||||
roots.sort();
|
||||
assert_eq!(
|
||||
roots,
|
||||
[
|
||||
"[email protected]",
|
||||
"database/[email protected]",
|
||||
"database/[email protected]",
|
||||
"[email protected]",
|
||||
"[email protected]",
|
||||
]
|
||||
);
|
||||
assert!(affects_roots(Path::new("composer.json")));
|
||||
assert!(composer_roots("not json").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maven_conventional_roots_and_target() {
|
||||
let dir = tree(&[
|
||||
|
||||
@@ -479,6 +479,10 @@ 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);
|
||||
|
||||
@@ -403,6 +403,46 @@ 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();
|
||||
|
||||
@@ -324,7 +324,7 @@ a token.
|
||||
|
||||
<!-- BEGIN GENERATED TOKEN REFERENCE: pnpm gen:theme-docs -->
|
||||
|
||||
235 themeable tokens: 121 in the UI theme file, 114 in the editor colour scheme file.
|
||||
242 themeable tokens: 121 in the UI theme file, 121 in the editor colour scheme file.
|
||||
|
||||
### Surfaces (UI theme)
|
||||
|
||||
@@ -436,11 +436,18 @@ 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-property)` | `var(--syn-property)` |
|
||||
| `--syn-python-instance-attribute` | `var(--syn-variable)` | `var(--syn-variable)` |
|
||||
| `--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` |
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
@@ -26,6 +26,9 @@ 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" },
|
||||
@@ -52,6 +55,8 @@ 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")
|
||||
|
||||
@@ -69,9 +74,13 @@ 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!,
|
||||
|
||||
@@ -7,8 +7,11 @@ 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")).toHaveText("outer");
|
||||
await expect(page.locator(".cm-sem-python-definition")).toHaveCSS("color", "rgb(34, 68, 102)");
|
||||
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-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");
|
||||
@@ -46,6 +49,11 @@ 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
@@ -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").first()).toBeVisible();
|
||||
await expect(editor.locator(".cm-selectionBackground:visible").first()).toBeVisible();
|
||||
await page.keyboard.press("ArrowRight");
|
||||
await page.keyboard.press("Meta+f");
|
||||
await page.getByRole("textbox", { name: "Find", exact: true }).fill("Order");
|
||||
|
||||
@@ -2,3 +2,4 @@ id = "log"
|
||||
name = "Log"
|
||||
extensions = ["log"]
|
||||
indent = { tab-size = 4, insert-spaces = true }
|
||||
ui-mode = "Log"
|
||||
@@ -1,5 +1,6 @@
|
||||
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,11 +24,18 @@
|
||||
"--syn-constant": "#e58fa3",
|
||||
"--syn-python-local-variable": "#d9dfe8",
|
||||
"--syn-python-global-variable": "#d9dfe8",
|
||||
"--syn-python-instance-attribute": "#b9a3f0",
|
||||
"--syn-python-instance-attribute": "#d9dfe8",
|
||||
"--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",
|
||||
|
||||
@@ -309,6 +309,7 @@ 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,
|
||||
@@ -397,6 +398,7 @@ 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,
|
||||
|
||||
@@ -24,6 +24,13 @@ 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]
|
||||
|
||||
@@ -631,6 +631,21 @@ 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]
|
||||
|
||||
+27
-3
@@ -577,6 +577,11 @@ 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 })),
|
||||
@@ -767,6 +772,8 @@ 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 })),
|
||||
/**
|
||||
@@ -3484,12 +3491,14 @@ export type ExtensionCount = {
|
||||
};
|
||||
|
||||
/** A formatter that runs outside the language servers, with its own settings group. */
|
||||
export type ExternalFormatter = "prettier" | "oxfmt";
|
||||
export type ExternalFormatter = "prettier" | "oxfmt" | "ruff";
|
||||
|
||||
/** An external formatter (Prettier, oxfmt) and the languages it formats. */
|
||||
/** An external formatter (Prettier, oxfmt, Ruff) and the languages it formats. */
|
||||
export type ExternalFormatterSettings = {
|
||||
/** Empty path: project node_modules, managed installation, then PATH. */
|
||||
/** Empty path: project node_modules (Prettier/oxfmt), 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,
|
||||
@@ -3576,6 +3585,18 @@ 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. */
|
||||
@@ -4798,6 +4819,7 @@ export type LanguagesSettings = {
|
||||
typescript?: TypeScriptSettings,
|
||||
prettier?: ExternalFormatterSettings,
|
||||
oxfmt?: ExternalFormatterSettings,
|
||||
ruff?: ExternalFormatterSettings,
|
||||
java?: JavaSettings,
|
||||
ruby?: RubySettings,
|
||||
markdown?: MarkdownSettings,
|
||||
@@ -6848,6 +6870,8 @@ export type SmartKeysSettings = {
|
||||
/** Reformat on paste: `none` | `indentBlock` | `indentEachLine`. */
|
||||
reformatOnPaste?: string,
|
||||
smartIndent?: boolean,
|
||||
/** Enter › Insert documentation comment stub: Enter after `/**` closes the comment. */
|
||||
insertDocStub?: boolean,
|
||||
/** Backspace › Unindent: `autoindent` (proper indent position) | `nearest` indent position | `none`. */
|
||||
backspaceUnindent?: string,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { EditorSelection, EditorState } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { undo, history } from "@codemirror/commands";
|
||||
import { enterInDocComment, phpDocTags } from "./docComment";
|
||||
|
||||
const views: EditorView[] = [];
|
||||
afterEach(() => views.splice(0).forEach((v) => v.destroy()));
|
||||
|
||||
const slashStar = EditorState.languageData.of(() => [
|
||||
{ commentTokens: { line: "//", block: { open: "/*", close: "*/" } } },
|
||||
]);
|
||||
const hash = EditorState.languageData.of(() => [{ commentTokens: { line: "#" } }]);
|
||||
|
||||
/** `|` marks the caret. */
|
||||
function enter(text: string, language = "php", tokens = slashStar) {
|
||||
const at = text.indexOf("|");
|
||||
const view = new EditorView({
|
||||
state: EditorState.create({
|
||||
doc: text.replace("|", ""),
|
||||
selection: EditorSelection.cursor(at),
|
||||
extensions: [tokens, history()],
|
||||
}),
|
||||
});
|
||||
views.push(view);
|
||||
const handled = enterInDocComment(language)(view);
|
||||
const s = view.state;
|
||||
const caret = s.selection.main.head;
|
||||
return { handled, view, text: s.doc.sliceString(0, caret) + "|" + s.doc.sliceString(caret) };
|
||||
}
|
||||
|
||||
describe("Enter after /**", () => {
|
||||
it("lists a PHP function's parameters and return type, caret on the summary line", () => {
|
||||
const r = enter("/**|\nfunction f(int $a, $b): string {}\n/**\n * old\n */\nfunction g() {}");
|
||||
expect(r.handled).toBe(true);
|
||||
expect(r.text).toBe(
|
||||
"/**\n * |\n * @param int $a\n * @param $b\n * @return string\n */\nfunction f(int $a, $b): string {}\n/**\n * old\n */\nfunction g() {}",
|
||||
);
|
||||
undo(r.view);
|
||||
expect(r.view.state.doc.toString()).toBe(
|
||||
"/**\nfunction f(int $a, $b): string {}\n/**\n * old\n */\nfunction g() {}",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the indentation of the /** line inside a class", () => {
|
||||
expect(
|
||||
enter("class A {\n /**|\n public static function make(?array $o = [], string ...$rest): ?static {}\n}")
|
||||
.text,
|
||||
).toBe(
|
||||
"class A {\n /**\n * |\n * @param ?array $o\n * @param string ...$rest\n * @return ?static\n */\n public static function make(?array $o = [], string ...$rest): ?static {}\n}",
|
||||
);
|
||||
});
|
||||
|
||||
it("gives constructors and void methods no @return; promoted parameters keep their type", () => {
|
||||
expect(phpDocTags("public function __construct(private int $a, #[Attr] readonly Foo&Bar $b) {}")).toEqual([
|
||||
"@param int $a",
|
||||
"@param Foo&Bar $b",
|
||||
]);
|
||||
expect(phpDocTags("function run(array &$x = array(1, 2), &$y): void {}")).toEqual(["@param array $x", "@param $y"]);
|
||||
expect(phpDocTags("abstract protected function find(int|string $id): ?Model;")).toEqual([
|
||||
"@param int|string $id",
|
||||
"@return ?Model",
|
||||
]);
|
||||
});
|
||||
|
||||
it("closes the comment with an empty summary above anything else, and in other /* */ languages", () => {
|
||||
expect(enter("/**|\nclass A {}").text).toBe("/**\n * |\n */\nclass A {}");
|
||||
expect(enter(" /**|\n\nconst x = 1;", "javascript").text).toBe(" /**\n * |\n */\n\nconst x = 1;");
|
||||
});
|
||||
|
||||
it("only breaks the line in an existing doc block, after other text, or without /* */ comments", () => {
|
||||
expect(enter("/**|\n * old\n */\nfunction f() {}").handled).toBe(false);
|
||||
expect(enter("$a = 1; /**|").handled).toBe(false);
|
||||
expect(enter("/** x|").handled).toBe(false);
|
||||
expect(enter("/**|", "python", hash).handled).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Smart Keys › Enter › Insert documentation comment stub: Enter right after a `/**` that opens a
|
||||
* new comment closes it, the caret on the summary line. In PHP the stub of a function lists its
|
||||
* parameters (`@param`) and return type (`@return`), as PhpStorm does.
|
||||
*/
|
||||
import { EditorSelection } from "@codemirror/state";
|
||||
import type { Command } from "@codemirror/view";
|
||||
|
||||
/** `@param` / `@return` lines for the PHP function declared in `text` (the code below the `/**`). */
|
||||
export function phpDocTags(text: string): string[] {
|
||||
const m =
|
||||
/^\s*(?:#\[[^\]]*\]\s*)*(?:(?:abstract|final|public|protected|private|static)\s+)*function\s+&?\s*(\w+)\s*\(/.exec(
|
||||
text,
|
||||
);
|
||||
if (!m) return [];
|
||||
let depth = 1;
|
||||
let i = m[0].length;
|
||||
for (; i < text.length && depth > 0; i++) {
|
||||
const c = text[i];
|
||||
if (c === "(" || c === "[") depth++;
|
||||
else if (c === ")" || c === "]") depth--;
|
||||
}
|
||||
if (depth > 0) return [];
|
||||
const tags = splitTopLevel(text.slice(m[0].length, i - 1)).flatMap((param) => {
|
||||
const p = /^(?:#\[[^\]]*\]\s*)*(?:(?:public|protected|private|readonly)\s+)*([^$=.]*?)\s*(\.\.\.)?\s*(\$\w+)/.exec(
|
||||
param.trim(),
|
||||
);
|
||||
if (!p) return [];
|
||||
// `&$x` passes by reference: the `&` belongs to no type (`Foo&Bar` is an intersection).
|
||||
const type = p[1]!.replace(/&\s*$/, "").trim().replace(/\s+/g, " ");
|
||||
const name = (p[2] ?? "") + p[3];
|
||||
return [type ? `@param ${type} ${name}` : `@param ${name}`];
|
||||
});
|
||||
const ret = /^\s*:\s*([?\w\\|&() ]+?)\s*(?:\{|;|$)/.exec(text.slice(i));
|
||||
const type = ret?.[1]?.trim();
|
||||
if (type && type !== "void" && m[1] !== "__construct") tags.push(`@return ${type}`);
|
||||
return tags;
|
||||
}
|
||||
|
||||
function splitTopLevel(params: string): string[] {
|
||||
const out: string[] = [];
|
||||
let depth = 0;
|
||||
let start = 0;
|
||||
for (let i = 0; i < params.length; i++) {
|
||||
const c = params[i];
|
||||
if (c === "(" || c === "[") depth++;
|
||||
else if (c === ")" || c === "]") depth--;
|
||||
else if (c === "," && depth === 0) {
|
||||
out.push(params.slice(start, i));
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
out.push(params.slice(start));
|
||||
return out.filter((p) => p.trim());
|
||||
}
|
||||
|
||||
/** The Enter command for documents of `language` (stub tags in PHP). */
|
||||
export function enterInDocComment(language: string | undefined): Command {
|
||||
return (view) => {
|
||||
const { state } = view;
|
||||
if (state.readOnly || state.selection.ranges.length !== 1) return false;
|
||||
const r = state.selection.main;
|
||||
if (!r.empty) return false;
|
||||
const block = state.languageDataAt<{ block?: { open: string; close: string } }>("commentTokens", r.head)[0]?.block;
|
||||
if (block?.open !== "/*" || block.close !== "*/") return false;
|
||||
const line = state.doc.lineAt(r.head);
|
||||
const before = line.text.slice(0, r.head - line.from);
|
||||
if (before.trim() !== "/**" || line.text.slice(r.head - line.from).trim()) return false;
|
||||
// A `/**` that already starts a doc block (the next line continues it) only breaks the line.
|
||||
const next = line.number < state.doc.lines ? state.doc.line(line.number + 1).text.trimStart() : "";
|
||||
if (next.startsWith("*")) return false;
|
||||
const indent = /^[ \t]*/.exec(before)![0];
|
||||
const br = state.lineBreak;
|
||||
const below = line.number < state.doc.lines ? state.sliceDoc(state.doc.line(line.number + 1).from) : "";
|
||||
const tags = language === "php" ? phpDocTags(below.slice(0, 4000)) : [];
|
||||
const summary = `${br}${indent} * `;
|
||||
const insert = summary + tags.map((t) => `${br}${indent} * ${t}`).join("") + `${br}${indent} */`;
|
||||
view.dispatch(
|
||||
state.update({
|
||||
changes: { from: r.head, insert },
|
||||
selection: EditorSelection.cursor(r.head + summary.length),
|
||||
scrollIntoView: true,
|
||||
userEvent: "input",
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
};
|
||||
}
|
||||
@@ -4,7 +4,10 @@
|
||||
* -effect`, set by Settings › Editor › Color Scheme overrides.
|
||||
*/
|
||||
import { HighlightStyle, type TagStyle } from "@codemirror/language";
|
||||
import { tags as t, type Tag } from "@lezer/highlight";
|
||||
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");
|
||||
|
||||
function token(
|
||||
tag: Tag | Tag[],
|
||||
@@ -86,6 +89,7 @@ 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)" },
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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", () => {
|
||||
@@ -57,6 +58,45 @@ 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 = [
|
||||
|
||||
@@ -85,6 +85,11 @@ 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 () => {
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
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);
|
||||
@@ -35,6 +35,7 @@ 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 {
|
||||
@@ -47,6 +48,7 @@ import {
|
||||
selectHumpRight,
|
||||
} from "./editing";
|
||||
import { enterInLineComment, lineCommentStyle, type LineCommentStyle } from "./comments";
|
||||
import { enterInDocComment } from "./docComment";
|
||||
import { skipClosingQuote, smartBackspace, smartBackspaceApplies, tabOut } from "./smartKeys";
|
||||
|
||||
// --- soft wraps ---------------------------------------------------------------
|
||||
@@ -506,6 +508,7 @@ 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(" ") }) : [],
|
||||
];
|
||||
}
|
||||
@@ -569,6 +572,7 @@ export function prefsExtension(o: PrefsOptions): Extension {
|
||||
{ key: "Home", run: cursorLineStart, shift: selectLineStart, preventDefault: true },
|
||||
{ mac: "Mod-ArrowLeft", run: cursorLineStart, shift: selectLineStart, preventDefault: true },
|
||||
);
|
||||
if (!o.readOnly && k.insertDocStub) keys.push({ key: "Enter", run: enterInDocComment(o.language) });
|
||||
if (!o.readOnly) keys.push({ key: "Enter", run: enterInLineComment });
|
||||
if (!k.smartIndent) keys.push({ key: "Enter", run: insertNewlineKeepIndent });
|
||||
if (k.backspaceUnindent === "none") keys.push({ key: "Backspace", run: deleteCharBackwardStrict });
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
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",
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -177,7 +177,17 @@ export const openideaTheme = EditorView.theme({
|
||||
"&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground": {
|
||||
background: "var(--editor-selection)",
|
||||
},
|
||||
".cm-activeLine": { backgroundColor: "var(--editor-caret-row)" },
|
||||
// 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-selectionMatch": {
|
||||
backgroundColor: "var(--bg-identifier-under-caret, color-mix(in srgb, var(--fg) 9%, transparent))",
|
||||
},
|
||||
@@ -263,7 +273,13 @@ export const openideaTheme = EditorView.theme({
|
||||
".cm-foldPlaceholder:hover": { backgroundColor: "color-mix(in srgb, var(--fg-muted) 24%, transparent)" },
|
||||
|
||||
// Indent guides (cm/indentGuides.ts)
|
||||
".cm-openidea-guides": {
|
||||
// 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",
|
||||
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%",
|
||||
|
||||
@@ -262,6 +262,24 @@ 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();
|
||||
|
||||
@@ -237,6 +237,8 @@ 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();
|
||||
|
||||
@@ -269,6 +271,7 @@ 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;
|
||||
@@ -486,7 +489,12 @@ 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)));
|
||||
useTabs.setState(state, true);
|
||||
this.restoring = true;
|
||||
try {
|
||||
useTabs.setState(state, true);
|
||||
} finally {
|
||||
this.restoring = false;
|
||||
}
|
||||
for (const pane of Object.values(state.panes)) if (pane.active) void this.ensureSlot(pane.active);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 Prettier or oxfmt. */
|
||||
/** Whether saving a `language` file formats it: Format on save, or Run on save of an external formatter. */
|
||||
export function formatsOnSave(settings: Settings | null, language: string): boolean {
|
||||
const { prettier, oxfmt } = validateLanguages(settings?.languages);
|
||||
const { prettier, oxfmt, ruff } = validateLanguages(settings?.languages);
|
||||
return (
|
||||
!!settings?.editor?.formatOnSave ||
|
||||
[prettier, oxfmt].some((formatter) => formatter.runOnSave && formatter.languages.includes(language))
|
||||
[prettier, oxfmt, ruff].some((formatter) => formatter.runOnSave && formatter.languages.includes(language))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -206,6 +206,13 @@ 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")),
|
||||
),
|
||||
|
||||
@@ -177,6 +177,8 @@ 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);
|
||||
|
||||
@@ -136,20 +136,26 @@ async function format(view: EditorView, opts: FormatDocumentOptions): Promise<Fo
|
||||
commands.lspFormat(doc.doc, range, { ...options, onSave: opts.onSave, tool: opts.tool }),
|
||||
);
|
||||
} catch (e) {
|
||||
if (e instanceof IpcError && e.data?.type === "tool" && (e.data.tool === "prettier" || e.data.tool === "oxfmt")) {
|
||||
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) {
|
||||
const tool = e.data.tool;
|
||||
if (e.kind === "notFound" && e.data.missing) {
|
||||
await executeAction("tools.promptInstall", { name: tool, sticky: !opts.onSave });
|
||||
await executeAction("tools.promptInstall", { name: tool, sticky: !opts.onSave, formatter: true });
|
||||
} else if (e.kind !== "cancelled") {
|
||||
notifications().notify({
|
||||
group: "tools",
|
||||
level: "error",
|
||||
title: tool === "oxfmt" ? tx("oxfmt formatting failed") : tx("Prettier formatting failed"),
|
||||
title: tx(formatter.failed),
|
||||
body:
|
||||
e.kind === "timedOut"
|
||||
? tool === "oxfmt"
|
||||
? tx("oxfmt formatting timed out")
|
||||
: tx("Prettier formatting timed out")
|
||||
? tx(formatter.timedOut)
|
||||
: createElement("span", { className: styles.diagnostics }, e.message),
|
||||
key: `${tool}-format-error:${doc.path}`,
|
||||
sticky: false,
|
||||
|
||||
@@ -44,9 +44,9 @@ afterEach(() => {
|
||||
_resetI18nForTests();
|
||||
});
|
||||
|
||||
describe.each(["prettier", "oxfmt"])("%s formatter errors", (name) => {
|
||||
describe.each(["prettier", "oxfmt", "ruff"])("%s formatter errors", (name) => {
|
||||
const tool = { type: "tool", tool: name, missing: false } as const;
|
||||
const display = name === "prettier" ? "Prettier" : name;
|
||||
const display = name === "prettier" ? "Prettier" : name === "ruff" ? "Ruff" : 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"])("%s formatter errors", (name) => {
|
||||
try {
|
||||
failWith({ kind: "notFound", message: "unrelated diagnostic", data: { ...tool, missing: true } });
|
||||
await formatDocument(viewFor());
|
||||
expect(prompt).toHaveBeenCalledWith({ name, sticky: true });
|
||||
expect(prompt).toHaveBeenCalledWith({ name, sticky: true, formatter: true });
|
||||
await formatDocument(viewFor(), { explicit: false, onSave: true });
|
||||
expect(prompt).toHaveBeenLastCalledWith({ name, sticky: false });
|
||||
expect(prompt).toHaveBeenLastCalledWith({ name, sticky: false, formatter: true });
|
||||
expect(useNotifications.getState().items).toHaveLength(0);
|
||||
} finally {
|
||||
dispose();
|
||||
@@ -126,10 +126,11 @@ describe.each(["prettier", "oxfmt"])("%s formatter errors", (name) => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Reformat with Prettier / oxfmt", () => {
|
||||
describe("Reformat with an external formatter", () => {
|
||||
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();
|
||||
|
||||
@@ -50,6 +50,71 @@ 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(
|
||||
|
||||
@@ -19,6 +19,7 @@ export type PythonSymbolKind =
|
||||
| "constant"
|
||||
| "property"
|
||||
| "typeParameter"
|
||||
| "unusedParameter"
|
||||
| "builtin";
|
||||
|
||||
const PYTHON_CLASSES: Record<PythonSymbolKind, string> = {
|
||||
@@ -32,6 +33,7 @@ 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",
|
||||
};
|
||||
|
||||
@@ -91,6 +93,7 @@ const KIND_PRIORITY: Record<PythonSymbolKind, number> = {
|
||||
globalVariable: 70,
|
||||
typeParameter: 90,
|
||||
parameter: 80,
|
||||
unusedParameter: 80,
|
||||
property: 80,
|
||||
constant: 70,
|
||||
classAttribute: 60,
|
||||
@@ -106,6 +109,33 @@ 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;
|
||||
@@ -340,6 +370,24 @@ 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,
|
||||
@@ -394,6 +442,18 @@ 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;
|
||||
@@ -423,8 +483,34 @@ export function pythonSyntaxRanges(
|
||||
tree.iterate({
|
||||
enter(cursor) {
|
||||
const node = cursor.node;
|
||||
if (!["VariableName", "PropertyName", "String", "Escape", "(", ")", "[", "]", "{", "}"].includes(node.name))
|
||||
if (
|
||||
![
|
||||
"VariableName",
|
||||
"PropertyName",
|
||||
"String",
|
||||
"FormatString",
|
||||
"type",
|
||||
"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;
|
||||
@@ -433,7 +519,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") className = pythonSymbolClasses(symbol);
|
||||
if (symbol === "typeParameter" || symbol === "unusedParameter") className = pythonSymbolClasses(symbol);
|
||||
else if (node.parent?.name === "FunctionDefinition") {
|
||||
if (symbol === "property") className = pythonSymbolClasses(symbol);
|
||||
else
|
||||
@@ -467,6 +553,7 @@ 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;
|
||||
|
||||
@@ -97,6 +97,7 @@ const PYTHON_VARIABLE_KINDS = new Set<PythonSymbolKind>([
|
||||
"constant",
|
||||
"property",
|
||||
"typeParameter",
|
||||
"unusedParameter",
|
||||
]);
|
||||
const PYTHON_SYMBOL_BASES = new Set([
|
||||
"cm-sem-variable",
|
||||
@@ -105,6 +106,7 @@ const PYTHON_SYMBOL_BASES = new Set([
|
||||
"cm-sem-constant",
|
||||
"cm-sem-type",
|
||||
"cm-sem-typeParameter",
|
||||
"cm-sem-parameter",
|
||||
"cm-sem-builtin",
|
||||
]);
|
||||
|
||||
|
||||
@@ -365,10 +365,14 @@ 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", "function"),
|
||||
".cm-sem-python-predefined-usage": pythonStyle("python-predefined-usage", "self"),
|
||||
".cm-sem-python-predefined-definition": pythonStyle("python-predefined-definition", "predefined"),
|
||||
".cm-sem-python-predefined-usage": pythonStyle("python-predefined-usage", "predefined"),
|
||||
".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"),
|
||||
@@ -385,12 +389,13 @@ 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("predefined", "builtin"),
|
||||
".cm-sem-builtin.cm-sem-python-builtin": pythonStyle("python-builtin", "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))",
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
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]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
@@ -90,6 +90,7 @@ 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;
|
||||
}
|
||||
|
||||
@@ -200,6 +201,7 @@ 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}
|
||||
|
||||
@@ -823,6 +823,25 @@
|
||||
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;
|
||||
|
||||
@@ -57,6 +57,8 @@ 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;
|
||||
}
|
||||
|
||||
@@ -160,15 +162,37 @@ 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(" ")}`;
|
||||
|
||||
@@ -255,6 +279,7 @@ 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) => {
|
||||
|
||||
@@ -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, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
call,
|
||||
commands,
|
||||
@@ -16,6 +16,7 @@ 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 {
|
||||
@@ -30,6 +31,7 @@ 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";
|
||||
|
||||
@@ -179,9 +181,30 @@ 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}>
|
||||
<div className={styles.view} ref={viewRef} data-db-table-editor="">
|
||||
<div className={styles.toolbar}>
|
||||
<IconButton icon="refresh" title="Reload Page" onClick={reload} disabled={loading} />
|
||||
<div className={styles.pager}>
|
||||
@@ -328,6 +351,7 @@ export function TableEditor({ input }: { input: EditorInput }) {
|
||||
columns={gridColumns}
|
||||
rowCount={rowCount}
|
||||
fixedRows={loaded}
|
||||
revealColumn={revealColumn}
|
||||
value={value}
|
||||
panel={panel}
|
||||
onPanelChange={setPanel}
|
||||
@@ -355,6 +379,18 @@ 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>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
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,
|
||||
@@ -94,6 +95,7 @@ export function activate(): () => void {
|
||||
}),
|
||||
() => setFileDecorations("database", new Map()),
|
||||
watchIdleConnections(),
|
||||
installTableEditorFocus(),
|
||||
registerDatabaseTreeActions(),
|
||||
registerActions([
|
||||
{
|
||||
@@ -152,6 +154,30 @@ 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,7 +1,9 @@
|
||||
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(() => ({
|
||||
@@ -87,4 +89,40 @@ 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* 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 });
|
||||
const bpLine = Decoration.line({ class: `${styles.bpLine} cm-openidea-lineBg` });
|
||||
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 {
|
||||
background: var(--bg-breakpoint-line);
|
||||
--openidea-line-bg: var(--bg-breakpoint-line);
|
||||
}
|
||||
|
||||
:global(.cm-editor) :global(.cm-line).execLine {
|
||||
background: var(--bg-debug-line);
|
||||
--openidea-line-bg: var(--bg-debug-line);
|
||||
}
|
||||
|
||||
.inlineValue {
|
||||
|
||||
@@ -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 }).range(line.from)]);
|
||||
return Decoration.set([Decoration.line({ class: `${styles.execLine} cm-openidea-lineBg` }).range(line.from)]);
|
||||
}
|
||||
|
||||
export function executionPoint(ctx: EditorDocContext): Extension {
|
||||
|
||||
@@ -365,111 +365,120 @@ export function DebugConsole({ session, interactive = true }: { session: DebugSe
|
||||
};
|
||||
|
||||
return (
|
||||
<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 className={styles.consoleArea}>
|
||||
<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}
|
||||
className={styles.consoleToolbar}
|
||||
role="toolbar"
|
||||
aria-orientation="vertical"
|
||||
aria-label={tx("Console actions")}
|
||||
>
|
||||
{!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="search"
|
||||
iconSize={14}
|
||||
title="Find…"
|
||||
shortcut={formatBinding(hostBinding("Meta+F"))}
|
||||
pressed={findOpen}
|
||||
onClick={() => (findOpen ? closeFind() : openFind())}
|
||||
/>
|
||||
<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"
|
||||
title="Clear Console"
|
||||
size="small"
|
||||
iconSize={12}
|
||||
onClick={() => clearConsole(session.id)}
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -218,6 +218,23 @@
|
||||
}
|
||||
|
||||
/* 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;
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
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,6 +1,7 @@
|
||||
/** "New Remote Branch for Pull Request": the name to push a local head without a push target to. */
|
||||
import { useState } from "react";
|
||||
import { Dialog, Input, T } from "@/ui";
|
||||
import { cleanUpBranchNameOnTyping } from "@/platform/gitRefName";
|
||||
import { Dialog, Input, T, useSanitizedField } from "@/ui";
|
||||
import { openDialog } from "../ui/dialogs";
|
||||
import styles from "./Create.module.css";
|
||||
|
||||
@@ -11,6 +12,7 @@ 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
|
||||
@@ -33,7 +35,13 @@ function RemoteBranchDialog(props: { suggested: string; onDone: (name: string |
|
||||
<label className={styles.dialogLabel}>
|
||||
<T>Remote branch name:</T>
|
||||
</label>
|
||||
<Input data-autofocus aria-label="Remote branch name" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<Input
|
||||
ref={ref}
|
||||
data-autofocus
|
||||
aria-label="Remote branch name"
|
||||
value={name}
|
||||
onChange={(e) => setName(clean(e))}
|
||||
/>
|
||||
<div className={styles.dialogComment}>
|
||||
<T>Your changes will be pushed to the remote repository to create the pull request</T>
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,7 @@ 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";
|
||||
@@ -45,6 +46,7 @@ export function CellEditor({
|
||||
lineNumbers(),
|
||||
drawSelection(),
|
||||
selectionMarks,
|
||||
seamlessSelection(),
|
||||
highlightActiveLine(),
|
||||
highlightSpecialChars(),
|
||||
bracketMatching(),
|
||||
|
||||
@@ -664,10 +664,24 @@ describe("Show Members", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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": "{}" });
|
||||
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": "{}",
|
||||
});
|
||||
setProjectFsForTests(fs, "/workspace");
|
||||
initProjectTree("/workspace", "Workspace", ["/z", "/a"]);
|
||||
initProjectTree("/workspace", "Workspace", ["/a/service10", "/z/Service2", "/m/alpha"]);
|
||||
const { rows } = setup();
|
||||
await waitFor(() => expect(rows()).toEqual(["z/z", "first.txt", "a/a", "second.txt"]));
|
||||
await waitFor(() =>
|
||||
expect(rows()).toEqual([
|
||||
"alpha/m/alpha",
|
||||
"first.txt",
|
||||
"Service2/z/Service2",
|
||||
"two.txt",
|
||||
"service10/a/service10",
|
||||
"ten.txt",
|
||||
]),
|
||||
);
|
||||
});
|
||||
@@ -100,6 +100,10 @@ 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);
|
||||
|
||||
@@ -125,7 +129,7 @@ export function sortEntries(
|
||||
const m = (b.mtimeMs ?? 0) - (a.mtimeMs ?? 0);
|
||||
if (m) return m;
|
||||
}
|
||||
return collator.compare(a.name, b.name) || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
||||
return compareNames(a.name, b.name);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -104,12 +104,13 @@ describe("creating packages", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("uses only manifest members in order in workspace Project and Project Files views", () => {
|
||||
it("shows manifest members in name 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(["/z", "/a"]);
|
||||
expect(model.roots.map((r) => r.path)).toEqual(["/a", "/z"]);
|
||||
expect(useProjectTree.getState().members).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"]);
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
GROUP_PREFIX,
|
||||
buildModel,
|
||||
buildPathsModel,
|
||||
compareNames,
|
||||
extraFolders,
|
||||
isGroupKey,
|
||||
isLibKey,
|
||||
@@ -34,7 +35,7 @@ import {
|
||||
} from "./model";
|
||||
import { projectFs } from "./fs";
|
||||
import { projectViewRoots, useProjectViewRoots, type ProjectViewRootContribution } from "@/workbench/contributions";
|
||||
import { ancestorsWithin, isSameOrInside, type PackageKind } from "./paths";
|
||||
import { ancestorsWithin, isSameOrInside, nameOf, type PackageKind } from "./paths";
|
||||
|
||||
export interface ViewOptions extends TreeOptions {
|
||||
/** Select the active editor's file in the tree. */
|
||||
@@ -218,12 +219,9 @@ 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: path.slice(path.lastIndexOf("/") + 1),
|
||||
icon: "",
|
||||
detail: path,
|
||||
}));
|
||||
const memberRoots = s.members
|
||||
?.map((path) => ({ path, label: nameOf(path), icon: "", detail: path }))
|
||||
.sort((a, b) => compareNames(a.label, b.label));
|
||||
const project = () =>
|
||||
memberRoots
|
||||
? withExtraRoots(EMPTY_MODEL, memberRoots, s.listings, options, placeholder)
|
||||
|
||||
@@ -50,6 +50,7 @@ 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,
|
||||
@@ -105,6 +106,16 @@ 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");
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import { showContextMenu } from "@/ui";
|
||||
import { buildTomcatArtifact, syncTomcatOutputs } from "./tomcat";
|
||||
import { runUpdateBuild } from "./buildUpdate";
|
||||
import { startRun, stopSession, useRun, waitForExit, type RunSession } from "./store";
|
||||
import { rerunConfig, 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,14 +72,15 @@ 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", session.config);
|
||||
await executeAction("run.debugConfig", config);
|
||||
} else if (debugging) {
|
||||
await stopSession(session.id);
|
||||
await waitForExit([session.id]);
|
||||
await executeAction("run.debugConfig", session.config);
|
||||
} else await startRun(session.config, session.id);
|
||||
await executeAction("run.debugConfig", config);
|
||||
} else await startRun(config, session.id);
|
||||
return;
|
||||
}
|
||||
await maybeEditorService()?.saveAll();
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from "./configs";
|
||||
import { contextConfig, fileRole } from "./context";
|
||||
import { envFromRows } from "./EnvDialog";
|
||||
import { defaultSelection } from "./store";
|
||||
import { configRemoved, defaultSelection, rerunConfig, useRun, type RunSession } from "./store";
|
||||
|
||||
const cfg = (over: Partial<RunConfig> & { id: string }): RunConfig => ({
|
||||
name: over.id,
|
||||
@@ -344,3 +344,38 @@ 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: [] });
|
||||
});
|
||||
});
|
||||
@@ -98,7 +98,13 @@ 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");
|
||||
const env = await toolEnv(
|
||||
neededTools(fresh),
|
||||
input.path,
|
||||
fresh.root,
|
||||
lang === "python" || lang === "shebang",
|
||||
lang === "php" || lang === "shebang",
|
||||
);
|
||||
return produceContext(fresh, env);
|
||||
}
|
||||
|
||||
|
||||
@@ -226,6 +226,15 @@ 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"] });
|
||||
|
||||
@@ -38,6 +38,8 @@ 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;
|
||||
@@ -288,7 +290,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`).",
|
||||
php: "Install PHP (`brew install php`) or choose an interpreter in Settings › Languages & Frameworks › 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.",
|
||||
@@ -762,6 +764,8 @@ 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;
|
||||
@@ -805,6 +809,8 @@ 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":
|
||||
|
||||
@@ -86,6 +86,14 @@ 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 {
|
||||
@@ -106,10 +114,12 @@ export async function toolEnv(
|
||||
file: string | null,
|
||||
root: string | null,
|
||||
python: boolean,
|
||||
php = false,
|
||||
): Promise<ToolEnv> {
|
||||
const [tools, py, markers] = await Promise.all([
|
||||
const [tools, py, phpInterpreter, 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 {
|
||||
@@ -117,6 +127,7 @@ export async function toolEnv(
|
||||
compileDir: compileDir ?? "/tmp/openidea-run",
|
||||
windows: isWindows(),
|
||||
python: py,
|
||||
php: phpInterpreter,
|
||||
...markers,
|
||||
};
|
||||
}
|
||||
@@ -256,6 +256,17 @@ 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", () => {
|
||||
|
||||
@@ -29,7 +29,16 @@ import { commandLine, configIcon, KIND_OPTIONS, kindLabel } from "./configs";
|
||||
import { SessionPanel } from "./RunView";
|
||||
import { showApplicationUpdate } from "./applicationUpdate";
|
||||
import { isUpdatable } from "./updatePolicies";
|
||||
import { loadConfigs, persistTemporary, startRun, stopSession, useRun, type RunSession } from "./store";
|
||||
import {
|
||||
configRemoved,
|
||||
loadConfigs,
|
||||
persistTemporary,
|
||||
rerunConfig,
|
||||
startRun,
|
||||
stopSession,
|
||||
useRun,
|
||||
type RunSession,
|
||||
} from "./store";
|
||||
import {
|
||||
currentServiceSession,
|
||||
currentServiceSessions,
|
||||
@@ -230,6 +239,7 @@ 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[],
|
||||
@@ -238,11 +248,13 @@ 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];
|
||||
for (const s of executions) if (!kinds.includes(s.config.kind)) kinds.push(s.config.kind);
|
||||
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 id of ran) {
|
||||
const kind = configs.find((c) => c.id === id)?.kind;
|
||||
if (kind && !kinds.includes(kind)) kinds.push(kind);
|
||||
@@ -252,7 +264,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 && !entries.some((c) => c.id === s.config.id)) entries.push(s.config);
|
||||
if (s.config.kind === kind && shown(s) && !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);
|
||||
@@ -298,7 +310,15 @@ function useRoots(): ServiceTreeItem[] {
|
||||
}, [root, typesRoot, ranRoot]);
|
||||
return useMemo(
|
||||
() =>
|
||||
runServiceItems(configs, sessions, typesRoot === root ? types : [], ranRoot === root ? ran : [], order, debug),
|
||||
runServiceItems(
|
||||
configs,
|
||||
sessions,
|
||||
typesRoot === root ? types : [],
|
||||
ranRoot === root ? ran : [],
|
||||
order,
|
||||
debug,
|
||||
configRemoved,
|
||||
),
|
||||
[configs, sessions, types, typesRoot, ran, ranRoot, root, order, debug],
|
||||
);
|
||||
}
|
||||
@@ -335,7 +355,7 @@ function run(r: Resolved) {
|
||||
if (debug) debug.rerun?.();
|
||||
else if (r.session?.run) {
|
||||
const s = r.session.run;
|
||||
void startRun(s.config, s.id, s.baseConfig, { show: false });
|
||||
void startRun(rerunConfig(s), s.id, s.baseConfig, { show: false });
|
||||
} else if (r.config) void startRun(r.config, undefined, undefined, { show: false });
|
||||
}
|
||||
|
||||
|
||||
@@ -1010,7 +1010,7 @@ function notifyExit(sessionId: string, code: number | null) {
|
||||
toolWindowService().show("run", true);
|
||||
},
|
||||
},
|
||||
{ label: "Rerun", run: () => void startRun(session.config, sessionId) },
|
||||
{ label: "Rerun", run: () => void startRun(rerunConfig(session), sessionId, session.baseConfig) },
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -1070,9 +1070,35 @@ 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(session.config, sessionId, session.baseConfig);
|
||||
if (session) void startRun(rerunConfig(session), sessionId, session.baseConfig && baseConfigOf(session));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1082,7 +1108,7 @@ export function rerunSession(sessionId: string) {
|
||||
*/
|
||||
export function debugSession(sessionId: string) {
|
||||
const session = getSession(sessionId);
|
||||
if (session) void executeAction("run.debugConfig", session.baseConfig ?? session.config);
|
||||
if (session) void executeAction("run.debugConfig", baseConfigOf(session));
|
||||
}
|
||||
|
||||
/** Paths of the failed tests of a session's last run. */
|
||||
@@ -1095,7 +1121,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 = session.baseConfig ?? session.config;
|
||||
const base = baseConfigOf(session);
|
||||
const derived = rerunFailedConfig(base, failedTests(sessionId));
|
||||
if (!derived) return false;
|
||||
void startRun(derived, sessionId, base);
|
||||
@@ -1116,7 +1142,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 = session.baseConfig ?? session.config;
|
||||
const base = baseConfigOf(session);
|
||||
const name = nodes.length === 1 ? nodes[0]!.name : `${paths.length} tests`;
|
||||
const derived = testsConfig(base, paths, name);
|
||||
if (!derived) return false;
|
||||
|
||||
@@ -78,7 +78,9 @@ describe("languages settings", () => {
|
||||
}
|
||||
expect(PHP_LANGUAGE_LEVELS[0]).toBe("");
|
||||
expect(PHP_LANGUAGE_LEVELS).toContain("5.6");
|
||||
expect(PHP_LANGUAGE_LEVELS.at(-1)).toBe("8.5");
|
||||
// Newest first after Auto (#1557).
|
||||
expect(PHP_LANGUAGE_LEVELS[1]).toBe("8.5");
|
||||
expect(PHP_LANGUAGE_LEVELS.at(-1)).toBe("5.6");
|
||||
});
|
||||
|
||||
it("finds the PHP page by its language level", () => {
|
||||
@@ -92,7 +94,13 @@ describe("languages settings", () => {
|
||||
validateLanguages({
|
||||
prettier: { path: " ./bin/prettier ", languages: ["markdown", 4, "markdown"], prefer: true, runOnSave: true },
|
||||
}).prettier,
|
||||
).toEqual({ path: "./bin/prettier", languages: ["markdown"], prefer: true, runOnSave: true });
|
||||
).toEqual({
|
||||
path: "./bin/prettier",
|
||||
configurationDirectory: "",
|
||||
languages: ["markdown"],
|
||||
prefer: true,
|
||||
runOnSave: true,
|
||||
});
|
||||
expect(validateLanguages({ prettier: { languages: [] } }).prettier.languages).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -102,10 +110,43 @@ describe("languages settings", () => {
|
||||
prettier: { runOnSave: true },
|
||||
oxfmt: { path: " ./bin/oxfmt ", languages: ["typescript", null, "typescript"], prefer: true },
|
||||
});
|
||||
expect(v.oxfmt).toEqual({ path: "./bin/oxfmt", languages: ["typescript"], prefer: true, runOnSave: false });
|
||||
expect(v.oxfmt).toEqual({
|
||||
path: "./bin/oxfmt",
|
||||
configurationDirectory: "",
|
||||
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 });
|
||||
@@ -143,6 +184,7 @@ describe("language pages", () => {
|
||||
"PHP",
|
||||
"Prettier",
|
||||
"oxfmt",
|
||||
"Ruff",
|
||||
"Ruby",
|
||||
"Rust",
|
||||
"TypeScript",
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useId } from "react";
|
||||
import type { ExternalFormatter } from "@/bindings";
|
||||
import { Input } from "@/ui";
|
||||
import { IconButton, 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",
|
||||
@@ -20,17 +22,28 @@ 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, providerHint, saveHint }: FormatterPageProps) {
|
||||
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) {
|
||||
const id = useId();
|
||||
const [path, setPath] = useSetting<string>(`languages.${tool}.path`);
|
||||
const [languages, setLanguages] = useSetting<string[]>(`languages.${tool}.languages`);
|
||||
@@ -39,13 +52,10 @@ function FormatterPage({ tool, name, pathLabel, providerHint, saveHint }: Format
|
||||
return (
|
||||
<>
|
||||
<Section title={name}>
|
||||
<Row
|
||||
label={pathLabel}
|
||||
htmlFor={id}
|
||||
hint="Auto: nearest project node_modules, managed installation, then PATH. An explicit path overrides auto detection."
|
||||
>
|
||||
<Row label={pathLabel} htmlFor={id} hint={pathHint}>
|
||||
<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"}
|
||||
@@ -75,6 +85,43 @@ function FormatterPage({ tool, name, pathLabel, providerHint, saveHint }: Format
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
@@ -97,3 +144,17 @@ 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,7 +3,8 @@
|
||||
* 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 { useId, useMemo } from "react";
|
||||
import { useEffect, useId, useMemo, useState } from "react";
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
import { IconButton, Input, type SelectOption } from "@/ui";
|
||||
import { PHP_LANGUAGE_LEVELS } from "@/platform/languageSettings";
|
||||
import { GlobListEditor, Row, Section, Select, Toggle } from "../components/Form";
|
||||
@@ -117,13 +118,31 @@ 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 selected = list.find((s) => s.path === interpreter) ?? (interpreter ? undefined : list[0]);
|
||||
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));
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<Section>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Settings › Project Structure (IDEA's module "Sources" tab): the content
|
||||
* root's Sources / Tests / Resources / Test Resources / Excluded folders.
|
||||
* Roots detected from build files (`pom.xml`, `build.gradle`, `Cargo.toml`,
|
||||
* `go.mod`, `pyproject.toml`, `package.json`) apply until the user marks or
|
||||
* `go.mod`, `pyproject.toml`, `package.json`, `composer.json`) apply until the user marks or
|
||||
* unmarks the folder (`projectStructure.*`, project scope). Roots drive the
|
||||
* project tree icons, package inference for new classes, the "Project
|
||||
* Production / Test Files" scopes, and Excluded folders are skipped by the
|
||||
@@ -330,7 +330,7 @@ export function ProjectStructurePage() {
|
||||
<div className={styles.note}>
|
||||
<T>
|
||||
{
|
||||
"Folders are detected from build files (pom.xml, build.gradle, Cargo.toml, go.mod, pyproject.toml, package.json); marking or unmarking a folder overrides detection. Excluded folders are skipped by the Project view, Find in Files and the index (reopen the project to re-index)."
|
||||
"Folders are detected from build files (pom.xml, build.gradle, Cargo.toml, go.mod, pyproject.toml, package.json, composer.json); marking or unmarking a folder overrides detection. Excluded folders are skipped by the Project view, Find in Files and the index (reopen the project to re-index)."
|
||||
}
|
||||
</T>
|
||||
</div>
|
||||
|
||||
@@ -677,7 +677,10 @@ const SMART_KEYS = editorPage({
|
||||
},
|
||||
{
|
||||
title: "Enter",
|
||||
items: [{ t: "check", key: `${P}.smartKeys.smartIndent`, label: "Smart indent" }],
|
||||
items: [
|
||||
{ t: "check", key: `${P}.smartKeys.smartIndent`, label: "Smart indent" },
|
||||
{ t: "check", key: `${P}.smartKeys.insertDocStub`, label: "Insert documentation comment stub" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Backspace",
|
||||
|
||||
Loaded 100 of 155 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user