mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 03:08:18 -10:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc390f48b5 | ||
|
|
a275239138 | ||
|
|
f35026f31c | ||
|
|
875e049dcd | ||
|
|
4ca44ee815 | ||
|
|
1186cdce64 | ||
|
|
f665beb17e | ||
|
|
d1acf9f9ac | ||
|
|
c179b1a822 | ||
|
|
846ac97650 |
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -533,6 +533,7 @@ mod tests {
|
||||
"Julia",
|
||||
"Kotlin",
|
||||
"LiveScript",
|
||||
"Log",
|
||||
"Lua",
|
||||
"mIRC",
|
||||
"Mathematica",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,28 @@ 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, versioned_php_in};
|
||||
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);
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
240 themeable tokens: 121 in the UI theme file, 119 in the editor colour scheme file.
|
||||
|
||||
### Surfaces (UI theme)
|
||||
|
||||
@@ -436,11 +436,16 @@ 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-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,16 @@
|
||||
"--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-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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
+23
-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 })),
|
||||
@@ -3484,12 +3489,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 +3583,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 +4817,7 @@ export type LanguagesSettings = {
|
||||
typescript?: TypeScriptSettings,
|
||||
prettier?: ExternalFormatterSettings,
|
||||
oxfmt?: ExternalFormatterSettings,
|
||||
ruff?: ExternalFormatterSettings,
|
||||
java?: JavaSettings,
|
||||
ruby?: RubySettings,
|
||||
markdown?: MarkdownSettings,
|
||||
|
||||
@@ -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 {
|
||||
@@ -506,6 +507,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(" ") }) : [],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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%",
|
||||
|
||||
@@ -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))",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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: [] });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -92,7 +92,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 +108,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 +182,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."
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
import { GoPage, JavaPage, NodePage, PhpPage, RubyPage, RustPage, TypeScriptPage } from "../languages/LanguagePages";
|
||||
import { MarkdownPage } from "../languages/MarkdownPage";
|
||||
import { OxfmtPage, PrettierPage } from "../languages/FormatterPage";
|
||||
import { OxfmtPage, PrettierPage, RuffPage } from "../languages/FormatterPage";
|
||||
import { ProjectStructurePage } from "../languages/ProjectStructurePage";
|
||||
import { PythonInterpreterPage } from "../languages/PythonInterpreterPage";
|
||||
import { GoBuildTagsPage, GO_BUILD_TAG_SECTIONS } from "../languages/GoBuildTags";
|
||||
@@ -138,6 +138,24 @@ export const LANGUAGE_PAGES: PageSpec[] = [
|
||||
keywords: ["formatter", "oxfmt", "oxc", ".oxfmtrc.json", "format on save", "node_modules"],
|
||||
component: OxfmtPage,
|
||||
},
|
||||
{
|
||||
id: "languages.ruff",
|
||||
projectScope: true,
|
||||
defaultScope: "project",
|
||||
title: "Ruff",
|
||||
group: LANGUAGES_GROUP,
|
||||
order: 403.7,
|
||||
keywords: [
|
||||
"formatter",
|
||||
"ruff",
|
||||
"python",
|
||||
"configuration directory",
|
||||
"pyproject.toml",
|
||||
"ruff.toml",
|
||||
"format on save",
|
||||
],
|
||||
component: RuffPage,
|
||||
},
|
||||
{
|
||||
id: "languages.php",
|
||||
projectScope: true,
|
||||
|
||||
@@ -147,6 +147,25 @@ describe("missing language server prompt", () => {
|
||||
off();
|
||||
});
|
||||
|
||||
it("offers Ruff formatting even when another Python server is installed", async () => {
|
||||
const installed = sampleTools().find((t) => t.name === "basedpyright")!;
|
||||
backend.tools.push({
|
||||
...installed,
|
||||
name: "ruff",
|
||||
displayName: "Ruff",
|
||||
command: "ruff",
|
||||
installMethod: "uv (virtualenv)",
|
||||
languages: ["Python"],
|
||||
alternatives: [],
|
||||
});
|
||||
await promptInstall("ruff", true, new Set(), true);
|
||||
expect(notes.at(-1)).toMatchObject({
|
||||
title: "Ruff is not installed",
|
||||
body: "Reformat Code needs Ruff. Install it via uv (virtualenv)?",
|
||||
key: missingKey("ruff"),
|
||||
});
|
||||
});
|
||||
|
||||
it("stays silent when an installed alternative serves the same languages", async () => {
|
||||
const off = installMissingServerPrompts();
|
||||
// typescript-language-server (on PATH) covers vtsls' languages.
|
||||
|
||||
@@ -46,7 +46,12 @@ export function activate(): () => void {
|
||||
hidden: true,
|
||||
run: async ({ arg }) => {
|
||||
if (arg && typeof arg === "object" && "name" in arg && typeof arg.name === "string") {
|
||||
await promptInstall(arg.name, !("sticky" in arg) || arg.sticky !== false);
|
||||
await promptInstall(
|
||||
arg.name,
|
||||
!("sticky" in arg) || arg.sticky !== false,
|
||||
new Set(),
|
||||
"formatter" in arg && arg.formatter === true,
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -21,12 +21,14 @@ export async function promptInstall(
|
||||
name: string,
|
||||
sticky = true,
|
||||
offered: ReadonlySet<string> = new Set(),
|
||||
formatter = false,
|
||||
): Promise<boolean> {
|
||||
if (!useTools.getState().loaded) await refreshTools();
|
||||
const tool = toolByName(name);
|
||||
if (!tool || !tool.canInstall || tool.status !== "missing") return false;
|
||||
const position = tool.alternatives.indexOf(name);
|
||||
if (position > 0 && tool.alternatives.slice(0, position).some((n) => offered.has(n))) return false;
|
||||
formatter ||= tool.kind === "formatter";
|
||||
// An installed alternative (typescript-language-server for vtsls) already serves these files.
|
||||
const served = useTools
|
||||
.getState()
|
||||
@@ -37,17 +39,16 @@ export async function promptInstall(
|
||||
t.status !== "missing" &&
|
||||
t.languages.some((l) => tool.languages.includes(l)),
|
||||
);
|
||||
if (tool.kind !== "formatter" && served) return false;
|
||||
if (!formatter && served) return false;
|
||||
const key = missingKey(name);
|
||||
const langs = tool.languages.length ? tool.languages.join(", ") : "these files";
|
||||
notifications().notify({
|
||||
group: "tools",
|
||||
level: "warning",
|
||||
title: tx`${tool.displayName} is not installed`,
|
||||
body:
|
||||
tool.kind === "formatter"
|
||||
? tx`Reformat Code needs ${tool.displayName}. Install it via ${tool.installMethod}?`
|
||||
: tx`Code insight for ${langs} needs ${tool.command}. Install it via ${tool.installMethod}?`,
|
||||
body: formatter
|
||||
? tx`Reformat Code needs ${tool.displayName}. Install it via ${tool.installMethod}?`
|
||||
: tx`Code insight for ${langs} needs ${tool.command}. Install it via ${tool.installMethod}?`,
|
||||
key,
|
||||
sticky,
|
||||
actions: [
|
||||
|
||||
@@ -377,6 +377,21 @@
|
||||
text-overflow: ellipsis;
|
||||
color: var(--fg-subtle);
|
||||
}
|
||||
.lineStats {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
flex: none;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.fileRow .lineStats {
|
||||
margin-left: auto;
|
||||
}
|
||||
.lineAdded {
|
||||
color: var(--vcs-added);
|
||||
}
|
||||
.lineRemoved {
|
||||
color: var(--vcs-deleted);
|
||||
}
|
||||
.messageBox {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
|
||||
@@ -104,6 +104,7 @@ import {
|
||||
showChangesDiff,
|
||||
toLogChanges,
|
||||
useLogChanges,
|
||||
withLineStats,
|
||||
type LogChange,
|
||||
} from "./logChanges";
|
||||
import { canDeleteBranch, canUpdateBranch, useBranchesPane, type BranchTarget } from "./logPaneActions";
|
||||
@@ -2175,8 +2176,9 @@ export function CommitDetails({ commits }: { commits: Commit[] }) {
|
||||
const onlyAffected = useLogOptions((s) => s.showOnlyAffected);
|
||||
const preview = useLogOptions((s) => s.diffPreview);
|
||||
const diffLocation = useLogOptions((s) => s.diffLocation);
|
||||
const showLineStats = useLogOptions((s) => s.showLineStats);
|
||||
const filters = useGitLog((s) => s.filters);
|
||||
const filesKey = JSON.stringify([idsKey, fromParents, parentIndex, commitRoot]);
|
||||
const filesKey = JSON.stringify([idsKey, fromParents, parentIndex, commitRoot, showLineStats]);
|
||||
const files = result?.key === filesKey ? result.files : null;
|
||||
const error = result?.key === filesKey ? result.error : null;
|
||||
|
||||
@@ -2225,6 +2227,7 @@ export function CommitDetails({ commits }: { commits: Commit[] }) {
|
||||
return toLogChanges(files, c, commitRoot, readOnly);
|
||||
})();
|
||||
load
|
||||
.then((files) => (showLineStats ? withLineStats(files).catch(() => files) : files))
|
||||
.then((files) => alive && setResult({ key: filesKey, files, error: null }))
|
||||
.catch(
|
||||
(e) => alive && setResult({ key: filesKey, files: null, error: e instanceof Error ? e.message : String(e) }),
|
||||
@@ -2232,7 +2235,7 @@ export function CommitDetails({ commits }: { commits: Commit[] }) {
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [idsKey, chosen, fromParents, parentIndex, commitRoot, filesKey, many, readOnly]);
|
||||
}, [idsKey, chosen, fromParents, parentIndex, commitRoot, filesKey, many, readOnly, showLineStats]);
|
||||
|
||||
const groupByDirectory = useLogOptions((s) => s.groupByDirectory);
|
||||
const [selectedKeys, setSelectedKeys] = useState<ReadonlySet<string>>(() => new Set());
|
||||
@@ -2254,6 +2257,14 @@ export function CommitDetails({ commits }: { commits: Commit[] }) {
|
||||
)
|
||||
: changes;
|
||||
}, [files, commit, many, filters.path, filters.paths, onlyAffected]);
|
||||
const lineTotals = useMemo(() => {
|
||||
if (!showLineStats || !changes.length) return null;
|
||||
if (changes.some((f) => f.added === undefined || f.removed === undefined)) return null;
|
||||
return changes.reduce((sum, f) => ({ added: sum.added + f.added!, removed: sum.removed + f.removed! }), {
|
||||
added: 0,
|
||||
removed: 0,
|
||||
});
|
||||
}, [changes, showLineStats]);
|
||||
const multipleRoots = new Set(chosen.map(commitRepo)).size > 1;
|
||||
const changeTreePath = useCallback(
|
||||
(f: LogChange) => `${multipleRoots ? `${repoName(f.repo ?? "")}/` : ""}${f.group ? `${f.group}/` : ""}${f.path}`,
|
||||
@@ -2341,6 +2352,7 @@ export function CommitDetails({ commits }: { commits: Commit[] }) {
|
||||
{ kind: "separator" },
|
||||
{ kind: "action", action: "vcs.log.changes.diffPreview" },
|
||||
toggle("Show Only Affected Changes", logOptions(), "showOnlyAffected"),
|
||||
toggle("Show lines added and deleted", logOptions(), "showLineStats"),
|
||||
toggle("Show Changes from Parents", logOptions(), "changesFromParents"),
|
||||
{
|
||||
label: "Diff Preview Location",
|
||||
@@ -2404,6 +2416,7 @@ export function CommitDetails({ commits }: { commits: Commit[] }) {
|
||||
{files ? fileCount(changes.length) : ""}
|
||||
{many ? " " + tx`in ${chosen.length} commits` : ""}
|
||||
</span>
|
||||
{lineTotals && <LineStats added={lineTotals.added} removed={lineTotals.removed} />}
|
||||
<span className={styles.grow} />
|
||||
<IconButton
|
||||
icon="expand-all"
|
||||
@@ -2699,13 +2712,50 @@ export function changesMenu(changes: LogChange[]): MenuItem[] {
|
||||
];
|
||||
}
|
||||
|
||||
function lineCount(file: ChangedFile | LogChange): { added: number; removed: number } | null {
|
||||
if (!("added" in file) || typeof file.added !== "number" || typeof file.removed !== "number") return null;
|
||||
return { added: file.added, removed: file.removed };
|
||||
}
|
||||
|
||||
/** Sum of a directory once every file under it has counts; otherwise the row stays without them. */
|
||||
function nodeLineCount(node: FileTreeNode<ChangedFile | LogChange>): { added: number; removed: number } | null {
|
||||
if (node.file) return lineCount(node.file);
|
||||
let added = 0;
|
||||
let removed = 0;
|
||||
for (const child of node.children) {
|
||||
const count = nodeLineCount(child);
|
||||
if (!count) return null;
|
||||
added += count.added;
|
||||
removed += count.removed;
|
||||
}
|
||||
return node.children.length ? { added, removed } : null;
|
||||
}
|
||||
|
||||
function LineStats({ added, removed }: { added: number; removed: number }) {
|
||||
return (
|
||||
<span className={styles.lineStats}>
|
||||
<span className={styles.lineAdded} aria-label={added === 1 ? tx`${added} line added` : tx`${added} lines added`}>
|
||||
+{added}
|
||||
</span>
|
||||
<span
|
||||
className={styles.lineRemoved}
|
||||
aria-label={removed === 1 ? tx`${removed} line deleted` : tx`${removed} lines deleted`}
|
||||
>
|
||||
−{removed}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ChangedFileRow({ node, flat = false }: { node: FileTreeNode<ChangedFile | LogChange>; flat?: boolean }) {
|
||||
const stats = nodeLineCount(node);
|
||||
if (!node.file)
|
||||
return (
|
||||
<span className={styles.fileRow}>
|
||||
<Icon name="folder" size={16} className={styles.fileIcon} />
|
||||
<span className={styles.fileName}>{node.name}</span>
|
||||
<span className={styles.fileDir}>{fileCount(node.count)}</span>
|
||||
{stats && <LineStats added={stats.added} removed={stats.removed} />}
|
||||
</span>
|
||||
);
|
||||
const f = node.file;
|
||||
@@ -2718,6 +2768,7 @@ function ChangedFileRow({ node, flat = false }: { node: FileTreeNode<ChangedFile
|
||||
</span>
|
||||
{flat && dirName(f.path) && <span className={styles.fileDir}>{dirName(f.path)}</span>}
|
||||
{f.oldPath && <span className={styles.fileDir}>{tx`renamed from ${baseName(f.oldPath)}`}</span>}
|
||||
{stats && <LineStats added={stats.added} removed={stats.removed} />}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ const OPTIONS: { key: Flag; label: string }[] = [
|
||||
{ key: "tagNames", label: "Show tag names" },
|
||||
{ key: "commitTimestamp", label: "Show commit time rather than author time" },
|
||||
{ key: "refsOnLeft", label: "Show references on the left" },
|
||||
{ key: "showLineStats", label: "Show lines added and deleted" },
|
||||
];
|
||||
|
||||
const COLUMNS: { key: Flag; label: string }[] = [
|
||||
|
||||
@@ -5,7 +5,7 @@ vi.mock("../dialogs", async (original) => ({
|
||||
...(await original<typeof import("../dialogs")>()),
|
||||
promptText: mocks.prompt,
|
||||
}));
|
||||
import { newBranch, newBranchInRepos } from "../branchOps";
|
||||
import { newBranch, newBranchInRepos, renameBranch } from "../branchOps";
|
||||
import { useVcs } from "../store";
|
||||
|
||||
const branch = (head: string | null): BranchInfo => ({
|
||||
@@ -61,3 +61,16 @@ it("prefills the current branch in the multi-repository dialog too", async () =>
|
||||
await newBranchInRepos(["/p/front", "/p/back"]);
|
||||
expect(mocks.prompt.mock.calls[0]?.[0].initial).toBe("feature/ui");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["New Branch", () => newBranch()],
|
||||
["New Branch from a revision", () => newBranch("origin/main")],
|
||||
["New Branch in several repositories", () => newBranchInRepos(["/p/front", "/p/back"])],
|
||||
["Rename Branch", () => renameBranch("main")],
|
||||
])("%s turns a typed space into a separator, as IDEA does", async (_name, open) => {
|
||||
await open();
|
||||
const { sanitize, validate } = mocks.prompt.mock.calls[0]![0];
|
||||
const typed: string = sanitize("fix login");
|
||||
expect(typed).toBe("fix-login");
|
||||
expect(validate(typed)).toBeNull();
|
||||
});
|
||||
@@ -1,8 +1,9 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import type { Branch, Commit } from "@/bindings";
|
||||
|
||||
const vcsCommitFiles = vi.fn();
|
||||
const vcsDiffNumstat = vi.fn();
|
||||
const vcsTags = vi.fn();
|
||||
|
||||
vi.mock("@/platform/ipc", async (importOriginal) => {
|
||||
@@ -12,6 +13,8 @@ vi.mock("@/platform/ipc", async (importOriginal) => {
|
||||
commands: {
|
||||
...real.commands,
|
||||
vcsCommitFiles: (repo: unknown, id: string) => vcsCommitFiles(repo, id),
|
||||
vcsDiffNumstat: (repo: unknown, from: string, to: string, paths: string[]) =>
|
||||
vcsDiffNumstat(repo, from, to, paths),
|
||||
vcsTags: (repo: unknown) => vcsTags(repo),
|
||||
vcsLog: () => Promise.resolve({ status: "ok", data: [] }),
|
||||
vcsConfigGet: () => Promise.resolve({ status: "ok", data: null }),
|
||||
@@ -21,7 +24,16 @@ vi.mock("@/platform/ipc", async (importOriginal) => {
|
||||
|
||||
import { buildBranchTree, filterBranchTree } from "../branches";
|
||||
import { buildFileTree } from "../fileTree";
|
||||
import { filesUnder, filterPatch, flatFileNodes, toLogChanges, useLogChanges } from "../logChanges";
|
||||
import {
|
||||
EMPTY_TREE,
|
||||
assignLineStats,
|
||||
filesUnder,
|
||||
filterPatch,
|
||||
flatFileNodes,
|
||||
lineStatRanges,
|
||||
toLogChanges,
|
||||
useLogChanges,
|
||||
} from "../logChanges";
|
||||
import { canDeleteBranch, canUpdateBranch, useBranchesPane } from "../logPaneActions";
|
||||
import { BranchesPanel, CommitDetails, changesMenu, graphOptionsMenu } from "../GitLog";
|
||||
import { LogSettingsPage } from "../LogSettingsPage";
|
||||
@@ -61,6 +73,7 @@ afterEach(() => {
|
||||
useGitLog.setState({ commits: [], selection: [], selected: null, filters: emptyFilters() });
|
||||
useLogOptions.setState({ ...DEFAULT_LOG_OPTIONS });
|
||||
vcsCommitFiles.mockReset();
|
||||
vcsDiffNumstat.mockReset();
|
||||
vcsTags.mockReset();
|
||||
});
|
||||
|
||||
@@ -77,6 +90,28 @@ describe("changed files model", () => {
|
||||
expect([mod!.before, mod!.after]).toEqual([commit.parents[0], commit.id]);
|
||||
expect([add!.before, add!.after]).toEqual([null, commit.id]);
|
||||
expect([del!.before, del!.after]).toEqual([commit.parents[0], null]);
|
||||
// Added and deleted files still count against the parent diff, not the empty tree.
|
||||
expect([add!.statsFrom, add!.statsTo, del!.statsTo]).toEqual([commit.parents[0], commit.id, commit.id]);
|
||||
const [rootFile] = toLogChanges([{ path: "a.ts", kind: "added", oldPath: null }], { ...commit, parents: [] });
|
||||
expect(rootFile!.statsFrom).toBe(EMPTY_TREE);
|
||||
});
|
||||
|
||||
it("asks for one numstat per revision pair and matches a rename by either name", () => {
|
||||
const files = toLogChanges(
|
||||
[
|
||||
{ path: "a.ts", kind: "modified", oldPath: null },
|
||||
{ path: "b.ts", kind: "added", oldPath: null },
|
||||
{ path: "new.ts", kind: "renamed", oldPath: "old.ts" },
|
||||
],
|
||||
commit,
|
||||
);
|
||||
expect(lineStatRanges(files).map(({ from, to, paths }) => ({ from, to, paths }))).toEqual([
|
||||
{ from: commit.parents[0], to: commit.id, paths: ["a.ts", "b.ts", "new.ts", "old.ts"] },
|
||||
]);
|
||||
const [renamed] = assignLineStats(files, [
|
||||
{ ...lineStatRanges(files)[0]!, stats: [{ path: "new.ts", oldPath: "old.ts", added: 1, removed: 2 }] },
|
||||
]).filter((f) => f.path === "new.ts");
|
||||
expect([renamed!.added, renamed!.removed]).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("lists files without directories by name, and the files under selected directories", () => {
|
||||
@@ -128,6 +163,34 @@ describe("changed files pane", () => {
|
||||
unmount();
|
||||
expect(useLogChanges.getState().selected).toEqual([]);
|
||||
});
|
||||
|
||||
it("shows added and deleted lines when the log option is on", async () => {
|
||||
vcsCommitFiles.mockResolvedValue({
|
||||
status: "ok",
|
||||
data: [
|
||||
{ path: "src/a.ts", kind: "modified", oldPath: null },
|
||||
{ path: "src/b.ts", kind: "added", oldPath: null },
|
||||
],
|
||||
});
|
||||
vcsDiffNumstat.mockResolvedValue({
|
||||
status: "ok",
|
||||
data: [
|
||||
{ path: "src/a.ts", oldPath: null, added: 2, removed: 1 },
|
||||
{ path: "src/b.ts", oldPath: null, added: 4, removed: 0 },
|
||||
],
|
||||
});
|
||||
useLogOptions.setState({ showLineStats: true });
|
||||
useGitLog.setState({ commits: [commit], selected: commit.id, selection: [commit.id] });
|
||||
render(<CommitDetails commits={[commit]} />);
|
||||
expect(await screen.findByLabelText("2 lines added")).toBeInTheDocument();
|
||||
// The file, its directory and the toolbar each report the deleted line.
|
||||
expect(screen.getAllByLabelText("1 line deleted")).toHaveLength(3);
|
||||
expect(screen.getAllByLabelText("6 lines added")).toHaveLength(2);
|
||||
expect(vcsDiffNumstat.mock.calls[0]!.slice(1)).toEqual([commit.parents[0], commit.id, ["src/a.ts", "src/b.ts"]]);
|
||||
|
||||
useLogOptions.setState({ showLineStats: false });
|
||||
await waitFor(() => expect(screen.queryByLabelText("2 lines added")).not.toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
|
||||
describe("Branches pane", () => {
|
||||
@@ -190,5 +253,7 @@ describe("Settings › Version Control › Log", () => {
|
||||
expect(useLogOptions.getState().tagNames).toBe(!DEFAULT_LOG_OPTIONS.tagNames);
|
||||
fireEvent.click(screen.getByLabelText("Hash"));
|
||||
expect(useLogOptions.getState().showHash).toBe(!DEFAULT_LOG_OPTIONS.showHash);
|
||||
fireEvent.click(screen.getByLabelText("Show lines added and deleted"));
|
||||
expect(useLogOptions.getState().showLineStats).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { cleanUpBranchNameOnTyping } from "@/platform/gitRefName";
|
||||
import { VcsDialogHost, promptText } from "../dialogs";
|
||||
import { validateBranchName } from "../branchOps";
|
||||
|
||||
const open = (initial = "") =>
|
||||
promptText({
|
||||
title: "Create New Branch",
|
||||
label: "New branch name:",
|
||||
initial,
|
||||
okLabel: "Create",
|
||||
sanitize: cleanUpBranchNameOnTyping,
|
||||
validate: validateBranchName,
|
||||
});
|
||||
|
||||
// The dialog stack is module state: close whatever a test left open.
|
||||
afterEach(async () => {
|
||||
for (const cancel of screen.queryAllByRole("button", { name: "Cancel" })) await userEvent.click(cancel);
|
||||
});
|
||||
|
||||
describe("prompt dialog with a sanitizer (branch name)", () => {
|
||||
it("replaces a typed space and keeps Create enabled", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<VcsDialogHost />);
|
||||
let result: ReturnType<typeof open> | undefined;
|
||||
act(() => {
|
||||
result = open();
|
||||
});
|
||||
const input = await screen.findByRole("textbox", { name: "New branch name:" });
|
||||
await user.type(input, "fix login redirect");
|
||||
expect(input).toHaveValue("fix-login-redirect");
|
||||
expect(screen.queryByText("Branch name contains invalid characters")).toBeNull();
|
||||
const create = screen.getByRole("button", { name: "Create" });
|
||||
expect(create).toBeEnabled();
|
||||
await user.click(create);
|
||||
await expect(result).resolves.toEqual({ value: "fix-login-redirect", checked: false });
|
||||
});
|
||||
|
||||
it("converts spaces in pasted text", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<VcsDialogHost />);
|
||||
act(() => void open());
|
||||
const input = await screen.findByRole("textbox", { name: "New branch name:" });
|
||||
await user.click(input);
|
||||
await user.paste("feature/add new thing");
|
||||
expect(input).toHaveValue("feature/add-new-thing");
|
||||
expect(screen.getByRole("button", { name: "Create" })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("keeps the caret after the rewritten character when editing mid-text", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<VcsDialogHost />);
|
||||
act(() => void open("ab"));
|
||||
const input = await screen.findByRole<HTMLInputElement>("textbox", { name: "New branch name:" });
|
||||
input.setSelectionRange(1, 1);
|
||||
await user.keyboard(" ");
|
||||
expect(input).toHaveValue("a-b");
|
||||
await waitFor(() => expect(input.selectionStart).toBe(2));
|
||||
await user.keyboard("x");
|
||||
expect(input).toHaveValue("a-xb");
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import { ask } from "@/workbench/messages";
|
||||
import { isFavoriteBranch, pushRecent, shortRemoteName } from "./branches";
|
||||
import { showCommitRange } from "./CompareBranches";
|
||||
import { confirmDialog, promptText } from "./dialogs";
|
||||
import { cleanUpBranchNameOnTyping } from "@/platform/gitRefName";
|
||||
import { runOp, runRemote, saveAll } from "./ops";
|
||||
import { conflictedFiles, currentRepo, refreshNow, repoName, useVcs } from "./store";
|
||||
import { tx } from "@/platform/i18n";
|
||||
@@ -160,6 +161,7 @@ export async function newBranch(from?: string) {
|
||||
initial: suggestion,
|
||||
mono: true,
|
||||
okLabel: "Create",
|
||||
sanitize: cleanUpBranchNameOnTyping,
|
||||
validate: validateBranchName,
|
||||
validateInitial: true,
|
||||
checkbox: { label: "Checkout branch", initial: true },
|
||||
@@ -236,6 +238,7 @@ export async function renameBranch(name: string) {
|
||||
initial: name,
|
||||
mono: true,
|
||||
okLabel: "Rename",
|
||||
sanitize: cleanUpBranchNameOnTyping,
|
||||
validate: (v) => (v.trim() === name ? "Enter a new name" : validateBranchName(v)),
|
||||
});
|
||||
if (!r) return;
|
||||
@@ -372,6 +375,7 @@ export async function newBranchInRepos(roots: readonly string[], from?: string)
|
||||
initial: from ? (from.includes("/") ? shortRemoteName(from) : "") : (useVcs.getState().branch?.head ?? ""),
|
||||
mono: true,
|
||||
okLabel: "Create",
|
||||
sanitize: cleanUpBranchNameOnTyping,
|
||||
validate: validateBranchName,
|
||||
validateInitial: true,
|
||||
checkbox: { label: "Checkout branch", initial: true },
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Imperative dialogs for VCS actions, rendered by one overlay contribution. */
|
||||
import { Fragment, useState, type ReactNode } from "react";
|
||||
import { create } from "zustand";
|
||||
import { Checkbox, Dialog, Input, T } from "@/ui";
|
||||
import { Checkbox, Dialog, Input, T, useSanitizedField } from "@/ui";
|
||||
import { confirm } from "@/workbench/messages";
|
||||
import styles from "./common.module.css";
|
||||
|
||||
@@ -43,6 +43,8 @@ export interface PromptOptions {
|
||||
mono?: boolean;
|
||||
/** Returns an error message, or null when valid. */
|
||||
validate?: (value: string) => string | null;
|
||||
/** Rewrites the field as the user types or pastes (e.g. spaces in a branch name). */
|
||||
sanitize?: (value: string) => string;
|
||||
/** Show the prefilled value's problem (e.g. the branch exists) before it is edited. */
|
||||
validateInitial?: boolean;
|
||||
/** Optional checkbox below the field. */
|
||||
@@ -69,7 +71,8 @@ export function promptText(opts: PromptOptions): Promise<PromptResult | null> {
|
||||
}
|
||||
|
||||
function PromptDialog(props: PromptOptions & { onDone: (r: PromptResult | null) => void }) {
|
||||
const { title, label, initial = "", placeholder, okLabel = "OK", mono, validate, checkbox, onDone } = props;
|
||||
const { title, label, initial = "", placeholder, okLabel = "OK", mono, validate, sanitize, checkbox, onDone } = props;
|
||||
const { ref, clean } = useSanitizedField(sanitize);
|
||||
const [value, setValue] = useState(initial);
|
||||
const [checked, setChecked] = useState(checkbox?.initial ?? false);
|
||||
const [touched, setTouched] = useState(!!props.validateInitial && initial !== "");
|
||||
@@ -102,6 +105,7 @@ function PromptDialog(props: PromptOptions & { onDone: (r: PromptResult | null)
|
||||
</label>
|
||||
)}
|
||||
<Input
|
||||
ref={ref}
|
||||
data-autofocus
|
||||
aria-label={label ?? title}
|
||||
value={value}
|
||||
@@ -110,7 +114,7 @@ function PromptDialog(props: PromptOptions & { onDone: (r: PromptResult | null)
|
||||
invalid={touched && !!error}
|
||||
onChange={(e) => {
|
||||
setTouched(true);
|
||||
setValue(e.target.value);
|
||||
setValue(clean(e));
|
||||
}}
|
||||
/>
|
||||
{touched && error && value && <div className={styles.error}>{error}</div>}
|
||||
|
||||
@@ -5,9 +5,10 @@ import type { Branch, MergeOptions, Remote, Worktree } from "@/bindings";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
import { Checkbox, Dialog, Icon, IconButton, Input, Select, T, type SelectOption } from "@/ui";
|
||||
import { Checkbox, Dialog, Icon, IconButton, Input, Select, T, useSanitizedField, type SelectOption } from "@/ui";
|
||||
import { sortLocal, sortRemote } from "./branches";
|
||||
import { conflictActions, openConflictsInstead, validateBranchName } from "./branchOps";
|
||||
import { cleanUpBranchNameOnTyping } from "@/platform/gitRefName";
|
||||
import { confirmDialog, openDialog } from "./dialogs";
|
||||
import { errorMessage, runOp, runRemote, saveAll } from "./ops";
|
||||
import { currentRepo, loadBranches, refreshNow, repoName, selectRepo, useVcs } from "./store";
|
||||
@@ -687,6 +688,7 @@ function NewWorktreeDialog({ onClose }: { onClose: (created: boolean) => void })
|
||||
const [source, setSource] = useState<string | null>(null);
|
||||
const [createBranch, setCreateBranch] = useState(true);
|
||||
const [newBranch, setNewBranch] = useState("");
|
||||
const { ref: branchRef, clean: cleanBranch } = useSanitizedField(cleanUpBranchNameOnTyping);
|
||||
const [path, setPath] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const options = useMemo(
|
||||
@@ -744,11 +746,12 @@ function NewWorktreeDialog({ onClose }: { onClose: (created: boolean) => void })
|
||||
<Checkbox label="Create a new branch" checked={createBranch} onChange={setCreateBranch} />
|
||||
{createBranch && (
|
||||
<Input
|
||||
ref={branchRef}
|
||||
aria-label="New branch name"
|
||||
mono
|
||||
value={newBranch}
|
||||
placeholder={suggestedBranch}
|
||||
onChange={(e) => setNewBranch(e.target.value)}
|
||||
onChange={(e) => setNewBranch(cleanBranch(e))}
|
||||
/>
|
||||
)}
|
||||
<div className={styles.fieldLabel}>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* context menu do with them.
|
||||
*/
|
||||
import { create } from "zustand";
|
||||
import type { ChangedFile, Commit, RevisionChange } from "@/bindings";
|
||||
import type { ChangedFile, Commit, FileLineStats, RevisionChange } from "@/bindings";
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
import { editorService } from "@/workbench/contributions";
|
||||
import { tx } from "@/platform/i18n";
|
||||
@@ -18,6 +18,9 @@ import { commitRepo } from "./multiRoot";
|
||||
import { repositoryPath } from "./repositoryPaths";
|
||||
import { confirmDialog } from "./dialogs";
|
||||
|
||||
/** Git's empty tree: a file added in a root commit is diffed against this. */
|
||||
export const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
||||
|
||||
/** A file of the pane, with the revisions holding its content before and after the change. */
|
||||
export interface LogChange {
|
||||
/** Repository-relative path (after the change). */
|
||||
@@ -29,12 +32,28 @@ export interface LogChange {
|
||||
before: string | null;
|
||||
/** `null`: the change deleted the file. */
|
||||
after: string | null;
|
||||
/**
|
||||
* Revisions whose diff holds this file's line counts. For one commit that is its
|
||||
* first parent (or the empty tree) and the commit itself, added files included.
|
||||
*/
|
||||
statsFrom: string;
|
||||
statsTo: string;
|
||||
added?: number;
|
||||
removed?: number;
|
||||
diff: DiffData;
|
||||
readOnly?: boolean;
|
||||
group?: string;
|
||||
mergeParents?: Array<{ rev: string; path: string }>;
|
||||
}
|
||||
|
||||
/** One `git diff --numstat` covering every file that shares its endpoints. */
|
||||
export interface LineStatRange {
|
||||
repo: string | null;
|
||||
from: string;
|
||||
to: string;
|
||||
paths: string[];
|
||||
}
|
||||
|
||||
/** One commit's files (`commit` given) or several commits' combined changes. */
|
||||
export function toLogChanges(
|
||||
files: readonly (ChangedFile | RevisionChange)[],
|
||||
@@ -53,6 +72,8 @@ export function toLogChanges(
|
||||
kind: f.kind,
|
||||
before: f.kind === "added" ? null : parent,
|
||||
after: f.kind === "deleted" ? null : commit.id,
|
||||
statsFrom: parent ?? EMPTY_TREE,
|
||||
statsTo: commit.id,
|
||||
diff: commitFileDiffData({ ...commit, repo }, f),
|
||||
};
|
||||
}
|
||||
@@ -65,11 +86,62 @@ export function toLogChanges(
|
||||
kind: r.kind,
|
||||
before: r.before,
|
||||
after: r.after,
|
||||
statsFrom: r.before ?? EMPTY_TREE,
|
||||
statsTo: r.after ?? EMPTY_TREE,
|
||||
diff: revisionChangeDiffData(r, repo),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Groups files that share a diff, so one numstat covers a commit's additions and edits. */
|
||||
export function lineStatRanges(files: readonly LogChange[]): LineStatRange[] {
|
||||
const groups = new Map<string, LineStatRange>();
|
||||
for (const f of files) {
|
||||
if (!f.statsFrom || !f.statsTo || f.statsFrom === f.statsTo) continue;
|
||||
const repo = f.repo ?? null;
|
||||
const key = `${repo ?? ""}\0${f.statsFrom}\0${f.statsTo}`;
|
||||
let group = groups.get(key);
|
||||
if (!group) groups.set(key, (group = { repo, from: f.statsFrom, to: f.statsTo, paths: [] }));
|
||||
if (!group.paths.includes(f.path)) group.paths.push(f.path);
|
||||
if (f.oldPath && !group.paths.includes(f.oldPath)) group.paths.push(f.oldPath);
|
||||
}
|
||||
return [...groups.values()];
|
||||
}
|
||||
|
||||
/** Writes numstat rows onto the files of the same revision pair, matching a rename by either name. */
|
||||
export function assignLineStats(
|
||||
files: readonly LogChange[],
|
||||
loaded: readonly (LineStatRange & { stats: readonly FileLineStats[] })[],
|
||||
): LogChange[] {
|
||||
const byPath = new Map<string, FileLineStats>();
|
||||
for (const range of loaded)
|
||||
for (const stat of range.stats) {
|
||||
const put = (path: string) => byPath.set(`${range.repo ?? ""}\0${range.from}\0${range.to}\0${path}`, stat);
|
||||
put(stat.path);
|
||||
if (stat.oldPath) put(stat.oldPath);
|
||||
}
|
||||
return files.map((f) => {
|
||||
const repo = f.repo ?? "";
|
||||
const stat =
|
||||
byPath.get(`${repo}\0${f.statsFrom}\0${f.statsTo}\0${f.path}`) ??
|
||||
(f.oldPath ? byPath.get(`${repo}\0${f.statsFrom}\0${f.statsTo}\0${f.oldPath}`) : undefined);
|
||||
return stat ? { ...f, added: stat.added, removed: stat.removed } : f;
|
||||
});
|
||||
}
|
||||
|
||||
/** Fills `added` / `removed` from `git diff --numstat`. A failed diff leaves the files uncounted. */
|
||||
export async function withLineStats(files: LogChange[]): Promise<LogChange[]> {
|
||||
const ranges = lineStatRanges(files);
|
||||
if (!ranges.length) return files;
|
||||
const loaded = await Promise.all(
|
||||
ranges.map(async (range) => ({
|
||||
...range,
|
||||
stats: await call(commands.vcsDiffNumstat(range.repo, range.from, range.to, range.paths)),
|
||||
})),
|
||||
);
|
||||
return assignLineStats(files, loaded);
|
||||
}
|
||||
|
||||
/** Files without directory grouping: by name, each node's path shown next to it. */
|
||||
export function flatFileNodes<F>(files: readonly F[], relPath: (f: F) => string): FileTreeNode<F>[] {
|
||||
return files
|
||||
|
||||
@@ -33,6 +33,8 @@ export interface LogOptions {
|
||||
groupByDirectory: boolean;
|
||||
/** Selecting a changed file shows its diff in the editor's preview tab. */
|
||||
diffPreview: boolean;
|
||||
/** Changed files show how many lines the commit added and deleted. */
|
||||
showLineStats: boolean;
|
||||
showDetails: boolean;
|
||||
showRepository: boolean;
|
||||
showOnlyAffected: boolean;
|
||||
@@ -65,6 +67,7 @@ export const DEFAULT_LOG_OPTIONS: LogOptions = {
|
||||
showBranches: true,
|
||||
groupByDirectory: true,
|
||||
diffPreview: true,
|
||||
showLineStats: false,
|
||||
showDetails: true,
|
||||
showRepository: true,
|
||||
showOnlyAffected: false,
|
||||
|
||||
@@ -110,6 +110,16 @@ function MarkdownPreview({ path, sync }: { path: string; sync: boolean }) {
|
||||
/** scrollTop set by navigation, whose scroll event isn't the user's. */
|
||||
const scrolledTo = useRef<number | null>(null);
|
||||
const scrollFrame = useRef(0);
|
||||
/** `<details>` the reader opened or closed, by position: each edit re-renders them as written. */
|
||||
const toggled = useRef(new Map<number, boolean>());
|
||||
|
||||
useLayoutEffect(() => toggled.current.clear(), [path]);
|
||||
useLayoutEffect(() => {
|
||||
ref.current?.querySelectorAll("details").forEach((d, i) => {
|
||||
const open = toggled.current.get(i);
|
||||
if (open !== undefined) d.open = open;
|
||||
});
|
||||
}, [html]);
|
||||
|
||||
// Mermaid diagrams: before paint, so cached ones don't flash their source.
|
||||
useLayoutEffect(() => {
|
||||
@@ -219,7 +229,13 @@ function MarkdownPreview({ path, sync }: { path: string; sync: boolean }) {
|
||||
|
||||
const onClick = (e: MouseEvent) => {
|
||||
const a = (e.target as Element).closest("a");
|
||||
if (!a) return;
|
||||
if (!a) {
|
||||
const details = (e.target as Element).closest("summary")?.parentElement;
|
||||
if (details instanceof HTMLDetailsElement && ref.current) {
|
||||
toggled.current.set([...ref.current.querySelectorAll("details")].indexOf(details), !details.open);
|
||||
}
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
const target = resolveLink(path, a.getAttribute("href") ?? "");
|
||||
if (!target) return;
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { _addEditor, _removeEditor, editorContributions, type EditorDocContext } from "@/editor/api";
|
||||
import { fontZoom } from "@/editor/core/cm/prefs";
|
||||
import { useContextKeys } from "@/platform/context";
|
||||
import { registerMarkdownLiveSource, useLiveMarkdown } from "./live";
|
||||
import { MarkdownEditor } from "./MarkdownEditor";
|
||||
import { useMarkdownModes } from "./modes";
|
||||
|
||||
const ctx: EditorDocContext = { doc: 1, path: "/p/NOTES.md", language: "markdown", uiMode: null, readOnly: false };
|
||||
const doc =
|
||||
"<details>\n<summary>One</summary>\n\nFirst\n\n</details>\n\n<details open>\n<summary>Two</summary>\n\nSecond\n\n</details>\n";
|
||||
|
||||
describe("preview <details>", () => {
|
||||
const disposers: Array<() => void> = [];
|
||||
let view: EditorView;
|
||||
|
||||
beforeEach(() => {
|
||||
disposers.push(registerMarkdownLiveSource());
|
||||
useContextKeys.setState({ projectOpen: true, textEditorActive: true });
|
||||
const live = editorContributions().find((c) => c.id === "viewers.markdownLive")!;
|
||||
view = new EditorView({ state: EditorState.create({ doc, extensions: [fontZoom, live.extensions(ctx)] }) });
|
||||
const handle = { view, ctx };
|
||||
_addEditor(handle);
|
||||
disposers.push(() => _removeEditor(handle));
|
||||
act(() => useMarkdownModes.setState((s) => ({ byPath: { ...s.byPath, [ctx.path]: "split" } })));
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
view.destroy();
|
||||
for (const dispose of disposers.splice(0).reverse()) dispose();
|
||||
useLiveMarkdown.setState({}, true);
|
||||
useContextKeys.setState({}, true);
|
||||
});
|
||||
|
||||
it("keeps what the reader opened and closed across edits", async () => {
|
||||
render(
|
||||
<MarkdownEditor path={ctx.path} tabId="t">
|
||||
<div />
|
||||
</MarkdownEditor>,
|
||||
);
|
||||
const preview = screen.getByRole("document", { name: "Markdown preview" });
|
||||
const details = () => [...preview.querySelectorAll("details")];
|
||||
await waitFor(() => expect(details().map((d) => d.open)).toEqual([false, true]));
|
||||
expect(details()[0]!.querySelector(":scope > p")?.textContent).toBe("First");
|
||||
|
||||
const [one, two] = details();
|
||||
fireEvent.click(one!.querySelector("summary")!);
|
||||
one!.open = true; // jsdom has no summary activation behaviour
|
||||
fireEvent.click(two!.querySelector("summary")!);
|
||||
two!.open = false;
|
||||
|
||||
act(() => view.dispatch({ changes: { from: view.state.doc.length, insert: "\nMore\n" } }));
|
||||
await waitFor(() => expect(preview.textContent).toContain("More"));
|
||||
expect(details().map((d) => d.open)).toEqual([true, false]);
|
||||
});
|
||||
});
|
||||
@@ -101,6 +101,87 @@ describe("markdown preview rendering", () => {
|
||||
expect(root.querySelector("input[type='checkbox']")).not.toBeNull();
|
||||
});
|
||||
|
||||
const blocks = (md: string) => {
|
||||
const root = document.createElement("div");
|
||||
root.innerHTML = renderMarkdown(md);
|
||||
const lines = [...root.querySelectorAll<HTMLElement>(":scope > [data-line]")].map((e) => [
|
||||
e.dataset.line,
|
||||
e.firstElementChild?.tagName,
|
||||
]);
|
||||
return { root, lines };
|
||||
};
|
||||
|
||||
it("keeps Markdown between a container's tags inside it (#1524)", () => {
|
||||
const { root, lines } = blocks("<details>\n<summary>Summary</summary>\n\nContent\n\n</details>\n\nAfter\n");
|
||||
const details = root.querySelector("details")!;
|
||||
expect(details.open).toBe(false);
|
||||
expect(details.querySelector(":scope > summary")?.textContent).toBe("Summary");
|
||||
expect(details.querySelector(":scope > p")?.textContent).toBe("Content");
|
||||
expect(root.querySelector(":scope > [data-line] > p")?.textContent).toBe("After");
|
||||
expect(lines).toEqual([
|
||||
["0", "DETAILS"],
|
||||
["7", "P"],
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["close right after the text", "<details>\n<summary>S</summary>\n\nContent\n</details>\n", "details > p"],
|
||||
["kept open", "<details open>\n<summary>S</summary>\n\n- a\n- b\n\n</details>\n", "details[open] > ul > li"],
|
||||
["a centred README header", '<div align="center">\n\n# Title\n\n</div>\n', "div[align='center'] > h1"],
|
||||
["a section", "<section>\n\n```js\nx\n```\n\n</section>\n", "section > pre > code"],
|
||||
["upper case", "<DETAILS>\n<SUMMARY>S</SUMMARY>\n\nContent\n\n</DETAILS>\n", "details > p"],
|
||||
["a list item", "- item\n <details>\n <summary>S</summary>\n\n body\n\n </details>\n", "li details"],
|
||||
])("renders the content of a container: %s", (_, md, selector) => {
|
||||
expect(blocks(md).root.querySelector(selector)).not.toBeNull();
|
||||
});
|
||||
|
||||
it("nests containers and ends each at its own close", () => {
|
||||
const { root, lines } = blocks(
|
||||
"<details>\n<summary>A</summary>\n\n<details>\n<summary>B</summary>\n\ninner\n\n</details>\n\nouter\n\n</details>\n\nafter\n",
|
||||
);
|
||||
const [outer, inner] = [...root.querySelectorAll("details")];
|
||||
expect(inner!.parentElement).toBe(outer);
|
||||
expect(inner!.querySelector(":scope > p")?.textContent).toBe("inner");
|
||||
expect(outer!.querySelector(":scope > p")?.textContent).toBe("outer");
|
||||
expect(lines).toEqual([
|
||||
["0", "DETAILS"],
|
||||
["14", "P"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves blocks alone when the container is closed in its own block or only mentioned", () => {
|
||||
expect(blocks("<details><summary>S</summary>x</details>\n\nNext\n").lines).toEqual([
|
||||
["0", "DETAILS"],
|
||||
["2", "P"],
|
||||
]);
|
||||
expect(blocks("<!-- <details> -->\n\nOne\n\nTwo\n").lines.map(([l]) => l)).toEqual(["0", "2", "4"]);
|
||||
expect(blocks("```html\n<details>\n```\n\nOne\n\n`<div>` code\n\nTwo\n").lines.map(([l]) => l)).toEqual([
|
||||
"0",
|
||||
"4",
|
||||
"6",
|
||||
"8",
|
||||
]);
|
||||
expect(blocks('<img src="a.png" alt="<div>">\n\n<br/>\n\nOne\n').lines.map(([l]) => l)).toEqual(["0", "2", "4"]);
|
||||
});
|
||||
|
||||
it("keeps everything after a container that's never closed inside it", () => {
|
||||
const { root, lines } = blocks("Intro\n\n<details>\n<summary>S</summary>\n\nOne\n\nTwo\n");
|
||||
expect([...root.querySelectorAll("details > p")].map((p) => p.textContent)).toEqual(["One", "Two"]);
|
||||
expect(lines).toEqual([
|
||||
["0", "P"],
|
||||
["2", "DETAILS"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores a stray close", () => {
|
||||
expect(blocks("One\n\n</div>\n\nTwo\n").lines.map(([l]) => l)).toEqual(["0", "2", "4"]);
|
||||
expect(blocks("</details>\n\n<details>\n<summary>S</summary>\n\nIn\n\n</details>\n\nOut\n").lines).toEqual([
|
||||
["0", undefined],
|
||||
["2", "DETAILS"],
|
||||
["9", "P"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("dedupes heading anchors", () => {
|
||||
const root = document.createElement("div");
|
||||
root.innerHTML = renderMarkdown("## Same\n\n## Same\n");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Markdown → sanitized HTML for the preview. Every top-level block is wrapped
|
||||
* in an element carrying its 0-based source line (`data-line`) for scroll
|
||||
* sync; images keep their source in `data-src` and are loaded by the preview
|
||||
* Markdown → sanitized HTML for the preview. Every top-level block (or run of
|
||||
* blocks inside one HTML container) is wrapped in an element carrying its
|
||||
* 0-based source line (`data-line`) for scroll sync; images keep their source in `data-src` and are loaded by the preview
|
||||
* (local files through the backend, as the CSP blocks file URLs). Mermaid
|
||||
* blocks become `[data-mermaid]` placeholders, drawn by `./mermaid`. YAML front matter is shown
|
||||
* as a table of its top-level fields.
|
||||
@@ -99,6 +99,37 @@ function frontMatterTable(fields: readonly [string, string][]): string {
|
||||
return `<table data-front-matter=""><tbody>${rows.join("")}</tbody></table>`;
|
||||
}
|
||||
|
||||
// Containers whose open and close tags may sit in separate HTML blocks with Markdown between them.
|
||||
const CONTAINER_TAGS = new Set([
|
||||
"article",
|
||||
"aside",
|
||||
"blockquote",
|
||||
"center",
|
||||
"details",
|
||||
"div",
|
||||
"dl",
|
||||
"figure",
|
||||
"footer",
|
||||
"header",
|
||||
"main",
|
||||
"nav",
|
||||
"ol",
|
||||
"section",
|
||||
"table",
|
||||
"ul",
|
||||
]);
|
||||
const TAG = /<(\/?)([a-zA-Z][\w-]*)(?:"[^"]*"|'[^']*'|[^'">])*?(\/?)>/g;
|
||||
|
||||
/** Applies the container tags an HTML block opens and closes to `open`, the stack of unclosed ones. */
|
||||
function trackContainers(raw: string, open: string[]) {
|
||||
for (const [, closing, name, selfClosing] of raw.replace(/<!--[\s\S]*?(?:-->|$)/g, "").matchAll(TAG)) {
|
||||
const tag = name!.toLowerCase();
|
||||
if (!CONTAINER_TAGS.has(tag) || selfClosing) continue;
|
||||
if (!closing) open.push(tag);
|
||||
else if (open.includes(tag)) open.length = open.lastIndexOf(tag);
|
||||
}
|
||||
}
|
||||
|
||||
export function renderMarkdown(text: string): string {
|
||||
const front = frontMatter(text);
|
||||
const body = front ? text.split("\n").slice(front.lines).join("\n") : text;
|
||||
@@ -106,6 +137,10 @@ export function renderMarkdown(text: string): string {
|
||||
const slugs = new Map<string, number>();
|
||||
let line = front?.lines ?? 0;
|
||||
let html = front ? `<div data-line="0">${frontMatterTable(front.fields)}</div>` : "";
|
||||
// Blocks inside an HTML container left open (`<details>`, `<div>`…) share its wrapper up to
|
||||
// the close: a wrapper per block would end the container early and spill its content out.
|
||||
const open: string[] = [];
|
||||
let group: { line: number; html: string } | null = null;
|
||||
for (const token of tokens) {
|
||||
const start = line;
|
||||
line += countLines(token.raw);
|
||||
@@ -118,8 +153,14 @@ export function renderMarkdown(text: string): string {
|
||||
slugs.set(base, n + 1);
|
||||
body = body.replace(/^<h(\d)>/, `<h$1 id="${ANCHOR_PREFIX}${n ? `${base}-${n}` : base}">`);
|
||||
}
|
||||
html += `<div data-line="${start}">${body}</div>`;
|
||||
group ??= { line: start, html: "" };
|
||||
group.html += body;
|
||||
if (token.type === "html") trackContainers(token.raw, open);
|
||||
if (open.length) continue;
|
||||
html += `<div data-line="${group.line}">${group.html}</div>`;
|
||||
group = null;
|
||||
}
|
||||
if (group) html += `<div data-line="${group.line}">${group.html}</div>`;
|
||||
// The preview shares the IDE's document: a `<style>` in a file would restyle the whole IDE.
|
||||
return purify().sanitize(html, {
|
||||
USE_PROFILES: { html: true },
|
||||
|
||||
@@ -12,6 +12,29 @@ describe("color schemes", () => {
|
||||
expect(schemeTokens("light").get("--syn-keyword")).toBe("#0033b3");
|
||||
expect(schemeTokens("dark").get("--syn-keyword")).toBe("#cf8e6d");
|
||||
expect(schemeTokens("light").get("--bg-editor")).not.toMatch(/var\(/);
|
||||
const dark = schemeTokens("dark");
|
||||
expect(dark.get("--syn-python-builtin")).toBe("#8888c6");
|
||||
expect(dark.get("--syn-python-predefined-definition")).toBe("#b200b2");
|
||||
expect(dark.get("--syn-python-predefined-usage")).toBe("#b200b2");
|
||||
expect(dark.get("--syn-python-instance-attribute")).toBe("#bcbec4");
|
||||
expect(dark.get("--syn-python-fstring")).toBe("#a5c261");
|
||||
expect(dark.get("--syn-python-bytes")).toBe("#a5c261");
|
||||
});
|
||||
|
||||
it("gives PHP variables and function calls their own colours, as PhpStorm (#1532)", () => {
|
||||
expect(schemeTokens("dark").get("--syn-php-variable")).toBe("#c77dbb");
|
||||
expect(schemeTokens("dark").get("--syn-php-function-call")).toBe("#56a8f5");
|
||||
expect(schemeTokens("light").get("--syn-php-variable")).toBe("#871094");
|
||||
expect(schemeTokens("light").get("--syn-php-function-call")).toBe("#00627a");
|
||||
const css = readFileSync("src/styles/tokens.css", "utf8");
|
||||
expect(css).toMatch(/\.cm-editor\[data-language="PHP"\] \{\s*--syn-variable: var\(--syn-php-variable\);/);
|
||||
// An imported PHP_VAR still wins: language overrides are emitted for the same element, !important.
|
||||
const imported = schemeCss(
|
||||
"theme-default",
|
||||
{ dark: { "php.identifier": { foreground: "#48d8ee" } } },
|
||||
document.documentElement,
|
||||
);
|
||||
expect(imported).toMatch(/\.cm-editor\[data-language="PHP"\] \{\n {2}--syn-variable: #48d8ee !important;/);
|
||||
});
|
||||
|
||||
it("emits scheme and override declarations", () => {
|
||||
|
||||
@@ -142,6 +142,7 @@ const BASE_ATTRIBUTES: readonly AttributeDef[] = [
|
||||
syn("pyBraces", "Braces", ["Python"], "python-braces"),
|
||||
syn("pyNestedFunction", "Nested function definition", ["Python"], "python-nested-function"),
|
||||
syn("pyBytes", "Byte string", ["Python"], "python-bytes"),
|
||||
syn("pyFString", "F-string", ["Python"], "python-fstring"),
|
||||
syn("pyPredefinedDefinition", "Predefined function definition", ["Python"], "python-predefined-definition"),
|
||||
syn("pyPredefinedUsage", "Predefined name usage", ["Python"], "python-predefined-usage"),
|
||||
syn("pyFormat", "Format specifier", ["Python"], "python-format"),
|
||||
@@ -156,7 +157,7 @@ const BASE_ATTRIBUTES: readonly AttributeDef[] = [
|
||||
},
|
||||
syn("pyKeywordArg", "Keyword argument", ["Python"], "keyword-arg"),
|
||||
syn("pyDocstring", "Docstring", ["Python"], "docstring"),
|
||||
syn("pyBuiltin", "Built-in name", ["Python"], "predefined"),
|
||||
syn("pyBuiltin", "Built-in name", ["Python"], "python-builtin"),
|
||||
syn("pyDecorator", "Decorator", ["Python"], "annotation"),
|
||||
syn("pyLocalVariable", "Local variable", ["Python"], "python-local-variable"),
|
||||
syn("pyGlobalVariable", "Global variable", ["Python"], "python-global-variable"),
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { cleanUpBranchNameOnTyping } from "./gitRefName";
|
||||
|
||||
describe("cleanUpBranchNameOnTyping", () => {
|
||||
it.each([
|
||||
// The reported case: a space typed or pasted between words.
|
||||
["feature x", "feature-x"],
|
||||
["fix login redirect", "fix-login-redirect"],
|
||||
// Repeated spaces collapse to one separator.
|
||||
["a b", "a-b"],
|
||||
["feature ", "feature-"],
|
||||
// Space pressed again after the substituted separator, and spaces around a typed one.
|
||||
["feature- ", "feature-"],
|
||||
["feature - x", "feature-x"],
|
||||
["a-b", "a-b"],
|
||||
["a--b", "a--b"],
|
||||
// A leading space would form a leading `-`, which git rejects: dropped.
|
||||
[" feature", "feature"],
|
||||
[" ", ""],
|
||||
// Tabs and other control characters count as whitespace too.
|
||||
["a\tb", "a-b"],
|
||||
["a\u007fb", "a-b"],
|
||||
// Characters git forbids are dropped rather than substituted.
|
||||
["a~b^c:d?e*f[g\\h", "abcdefgh"],
|
||||
['say "hi"', "say-hi"],
|
||||
["a@{b", "ab"],
|
||||
["a..b", "a.b"],
|
||||
["a//b", "a/b"],
|
||||
["/a", "a"],
|
||||
[".a", "a"],
|
||||
["-a", "a"],
|
||||
["a/.b", "a/b"],
|
||||
["a./b", "a/b"],
|
||||
// Already valid names are untouched, including Unicode.
|
||||
["feature/login-v2", "feature/login-v2"],
|
||||
["release/1.2.3", "release/1.2.3"],
|
||||
["fix_bug", "fix_bug"],
|
||||
["功能/登录", "功能/登录"],
|
||||
["", ""],
|
||||
])("%j → %j", (input, expected) => {
|
||||
expect(cleanUpBranchNameOnTyping(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it("is idempotent, so a cleaned field is stable under re-typing", () => {
|
||||
for (const s of ["a b", " a", "a .. b", "@{ x", "x/ /y", "- - a"]) {
|
||||
const once = cleanUpBranchNameOnTyping(s);
|
||||
expect(cleanUpBranchNameOnTyping(once)).toBe(once);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
/** Ports IntelliJ's `GitRefNameValidator.cleanUpBranchNameOnTyping` (git4idea). */
|
||||
|
||||
/** IntelliJ's advanced setting `git.branch.cleanup.symbol` (default `-`). */
|
||||
export const BRANCH_CLEANUP_SYMBOL = "-";
|
||||
|
||||
// Characters and sequences git forbids that cannot be repaired by substitution are dropped.
|
||||
const DROP = /^\.|^-|^\/|[~:^?*"[\\]+|(?:@\{)+|\/(?=\/)|(?:\.(?=\.))+|\.(?=\/)/g;
|
||||
// IntelliJ drops a dot after a slash with a lookbehind, which pre-16.4 WebKit cannot parse.
|
||||
const DOT_AFTER_SLASH = /\/\./g;
|
||||
// A run of whitespace / control characters becomes one separator. It also absorbs separators
|
||||
// mixed into or next to it, so pressing Space again after the substituted one doesn't stack them (IntelliJ
|
||||
// collapses only spaces typed in one go).
|
||||
const SEPARATOR = BRANCH_CLEANUP_SYMBOL.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const REPLACE = new RegExp(`(?:${SEPARATOR}|[ \\p{Cc}])*[ \\p{Cc}](?:${SEPARATOR}|[ \\p{Cc}])*`, "gu");
|
||||
|
||||
function cleanOnce(name: string): string {
|
||||
return name.replace(DROP, "").replace(DOT_AFTER_SLASH, "/").replace(REPLACE, BRANCH_CLEANUP_SYMBOL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes `name` typable as a branch name: forbidden characters are dropped and spaces become the
|
||||
* cleanup symbol, so a space never reaches the validator. Runs to a fixed point because the
|
||||
* substitution can itself form a forbidden start (" x" → "-x" → "x").
|
||||
*/
|
||||
export function cleanUpBranchNameOnTyping(name: string): string {
|
||||
let cleaned = name;
|
||||
for (;;) {
|
||||
const next = cleanOnce(cleaned);
|
||||
if (next === cleaned) return cleaned;
|
||||
cleaned = next;
|
||||
}
|
||||
}
|
||||
@@ -122,6 +122,14 @@ describe(".icls schemes", () => {
|
||||
for (const [name, value] of names) expect(attrs[name]?.foreground).toBe(`#${value}`);
|
||||
});
|
||||
|
||||
it("imports the Python f-string colour", () => {
|
||||
const xml =
|
||||
'<scheme name="F"><attributes><option name="PY.STRING.F"><value>' +
|
||||
'<option name="FOREGROUND" value="808080"/></value></option></attributes></scheme>';
|
||||
const { id, customs } = importScheme({}, parseIcls(xml).scheme);
|
||||
expect(parseIcls(exportIcls(id, customs)).scheme.attributes.pyFString?.foreground).toBe("#808080");
|
||||
});
|
||||
|
||||
it("imports the global variable colour", () => {
|
||||
const xml =
|
||||
'<scheme name="G" parent_scheme="Darcula"><attributes><option name="DEFAULT_GLOBAL_VARIABLE"><value>' +
|
||||
|
||||
@@ -149,6 +149,7 @@ Object.assign(ATTRIBUTE_NAMES, {
|
||||
"PY.PREDEFINED_DEFINITION": "pyPredefinedDefinition",
|
||||
"PY.PREDEFINED_USAGE": "pyPredefinedUsage",
|
||||
"PY.STRING.B": "pyBytes",
|
||||
"PY.STRING.F": "pyFString",
|
||||
"PY.VALID_STRING_ESCAPE": "escape",
|
||||
"PY.OPERATION_SIGN": "operator",
|
||||
});
|
||||
|
||||
@@ -25,9 +25,10 @@ const DEFAULT_GO_BUILD_TAGS: GoBuildTags = {
|
||||
experiments: "",
|
||||
};
|
||||
|
||||
/** An external formatter (Prettier, oxfmt) and the languages it formats. */
|
||||
/** An external formatter (Prettier, oxfmt, Ruff) and the languages it formats. */
|
||||
interface ExternalFormatterValid {
|
||||
path: string;
|
||||
configurationDirectory: string;
|
||||
languages: string[];
|
||||
prefer: boolean;
|
||||
runOnSave: boolean;
|
||||
@@ -35,6 +36,7 @@ interface ExternalFormatterValid {
|
||||
|
||||
const DEFAULT_FORMATTER: ExternalFormatterValid = {
|
||||
path: "",
|
||||
configurationDirectory: "",
|
||||
languages: [
|
||||
"markdown",
|
||||
"yaml",
|
||||
@@ -55,6 +57,11 @@ const DEFAULT_FORMATTER: ExternalFormatterValid = {
|
||||
runOnSave: false,
|
||||
};
|
||||
|
||||
const DEFAULT_RUFF_FORMATTER: ExternalFormatterValid = {
|
||||
...DEFAULT_FORMATTER,
|
||||
languages: ["python"],
|
||||
};
|
||||
|
||||
/** `languages.php.languageLevel` values; `""` = Auto (from `composer.json`). */
|
||||
export const PHP_LANGUAGE_LEVELS = [
|
||||
"",
|
||||
@@ -99,6 +106,7 @@ export interface LanguagesValid {
|
||||
typescript: { version: string };
|
||||
prettier: ExternalFormatterValid;
|
||||
oxfmt: ExternalFormatterValid;
|
||||
ruff: ExternalFormatterValid;
|
||||
java: {
|
||||
jdk: string;
|
||||
jdtlsJdk: string;
|
||||
@@ -159,6 +167,7 @@ export const DEFAULT_LANGUAGES: LanguagesValid = {
|
||||
typescript: { version: "bundled" },
|
||||
prettier: DEFAULT_FORMATTER,
|
||||
oxfmt: DEFAULT_FORMATTER,
|
||||
ruff: DEFAULT_RUFF_FORMATTER,
|
||||
java: {
|
||||
jdk: "",
|
||||
jdtlsJdk: "",
|
||||
@@ -228,11 +237,11 @@ const folders = (v: unknown): string[] =>
|
||||
const folderKey = (stored: string) => (stored === "." ? "" : stored);
|
||||
const storedFolder = (rel: string) => normalizeFolder(rel) || ".";
|
||||
|
||||
function validateFormatter(raw: unknown): ExternalFormatterValid {
|
||||
const d = DEFAULT_FORMATTER;
|
||||
function validateFormatter(raw: unknown, d = DEFAULT_FORMATTER): ExternalFormatterValid {
|
||||
const f = obj(raw);
|
||||
return {
|
||||
path: text(f.path, d.path),
|
||||
configurationDirectory: text(f.configurationDirectory, d.configurationDirectory),
|
||||
languages: Array.isArray(f.languages)
|
||||
? [...new Set(f.languages.filter((v): v is string => typeof v === "string"))]
|
||||
: d.languages,
|
||||
@@ -299,6 +308,7 @@ export function validateLanguages(raw: unknown): LanguagesValid {
|
||||
typescript: { version: text(ts.version, d.typescript.version) || d.typescript.version },
|
||||
prettier: validateFormatter(l.prettier),
|
||||
oxfmt: validateFormatter(l.oxfmt),
|
||||
ruff: validateFormatter(l.ruff, DEFAULT_RUFF_FORMATTER),
|
||||
java: {
|
||||
jdk: text(java.jdk, d.java.jdk),
|
||||
jdtlsJdk: text(java.jdtlsJdk, d.java.jdtlsJdk),
|
||||
|
||||
@@ -12,5 +12,23 @@
|
||||
"Errors the project's vendor/bin/phpstan reports in a PHP file when it is saved, shown at their lines. PHPStan reads phpstan.neon, phpstan.neon.dist or phpstan.dist.neon from the project root, or the configuration file chosen in Settings › Languages & Frameworks › PHP. Each rule PHPStan has reported is listed below by its identifier and can be turned off or re-graded on its own.": "Errors the project's vendor/bin/phpstan reports in a PHP file when it is saved, shown at their lines. PHPStan reads phpstan.neon, phpstan.neon.dist or phpstan.dist.neon from the project root, or the configuration file chosen in Settings › Languages & Frameworks › PHP. Each rule PHPStan has reported is listed below by its identifier and can be turned off or re-graded on its own.",
|
||||
"phpstan.neon, phpstan.neon.dist or phpstan.dist.neon": "phpstan.neon, phpstan.neon.dist or phpstan.dist.neon",
|
||||
"The configuration vendor/bin/phpstan checks saved files with. Empty uses phpstan.neon, phpstan.neon.dist or phpstan.dist.neon in the project root. Absolute, or relative to the project.": "The configuration vendor/bin/phpstan checks saved files with. Empty uses phpstan.neon, phpstan.neon.dist or phpstan.dist.neon in the project root. Absolute, or relative to the project.",
|
||||
"Select PHPStan Configuration File": "Select PHPStan Configuration File"
|
||||
"Select PHPStan Configuration File": "Select PHPStan Configuration File",
|
||||
"Reformat with Ruff": "Reformat with Ruff",
|
||||
"Ruff path": "Ruff path",
|
||||
"Auto: managed installation, then PATH. An explicit path overrides auto detection.": "Auto: managed installation, then PATH. An explicit path overrides auto detection.",
|
||||
"Configuration directory": "Configuration directory",
|
||||
"Project configuration": "Project configuration",
|
||||
"Select Ruff Configuration Directory": "Select Ruff Configuration Directory",
|
||||
"Empty uses the nearest project pyproject.toml ([tool.ruff]), ruff.toml, or .ruff.toml. A configured directory must contain one of them.": "Empty uses the nearest project pyproject.toml ([tool.ruff]), ruff.toml, or .ruff.toml. A configured directory must contain one of them.",
|
||||
"Ruff reads the nearest pyproject.toml ([tool.ruff]), ruff.toml, or .ruff.toml. Use it before the language server when preferred.": "Ruff reads the nearest pyproject.toml ([tool.ruff]), ruff.toml, or .ruff.toml. Use it before the language server when preferred.",
|
||||
"Run Ruff on save for enabled languages when it is the selected formatting provider.": "Run Ruff on save for enabled languages when it is the selected formatting provider.",
|
||||
"Ruff formatting failed": "Ruff formatting failed",
|
||||
"Ruff formatting timed out": "Ruff formatting timed out",
|
||||
"Ruff configuration path does not exist: {path}": "Ruff configuration path does not exist: {path}",
|
||||
"Ruff configuration directory has no ruff.toml, .ruff.toml, or pyproject.toml with [tool.ruff]: {path}": "Ruff configuration directory has no ruff.toml, .ruff.toml, or pyproject.toml with [tool.ruff]: {path}",
|
||||
"Show lines added and deleted": "Show lines added and deleted",
|
||||
"{added} line added": "{added} line added",
|
||||
"{added} lines added": "{added} lines added",
|
||||
"{removed} line deleted": "{removed} line deleted",
|
||||
"{removed} lines deleted": "{removed} lines deleted"
|
||||
}
|
||||
@@ -30,6 +30,7 @@
|
||||
"Predefined name usage": "预定义名称引用",
|
||||
"Predefined function definition": "预定义函数定义",
|
||||
"Byte string": "字节字符串",
|
||||
"F-string": "F 字符串",
|
||||
"Nested function definition": "嵌套函数定义",
|
||||
"Local variable": "局部变量",
|
||||
"Global variable": "全局变量",
|
||||
@@ -1713,15 +1714,24 @@
|
||||
"Reformat Code": "重新格式化代码",
|
||||
"Reformat with Prettier": "使用 Prettier 重新格式化",
|
||||
"Reformat with oxfmt": "使用 oxfmt 重新格式化",
|
||||
"Reformat with Ruff": "使用 Ruff 重新格式化",
|
||||
"Prettier path": "Prettier 路径",
|
||||
"oxfmt path": "oxfmt 路径",
|
||||
"Ruff path": "Ruff 路径",
|
||||
"Auto: nearest project node_modules, managed installation, then PATH. An explicit path overrides auto detection.": "自动:依次查找最近的项目 node_modules、受管安装和 PATH。指定路径将覆盖自动检测。",
|
||||
"Auto: managed installation, then PATH. An explicit path overrides auto detection.": "自动:依次查找受管安装和 PATH。指定路径将覆盖自动检测。",
|
||||
"Configuration directory": "配置目录",
|
||||
"Project configuration": "项目配置",
|
||||
"Select Ruff Configuration Directory": "选择 Ruff 配置目录",
|
||||
"Empty uses the nearest project pyproject.toml ([tool.ruff]), ruff.toml, or .ruff.toml. A configured directory must contain one of them.": "留空时使用最近的 pyproject.toml([tool.ruff])、ruff.toml 或 .ruff.toml。指定目录必须包含其中一种配置。",
|
||||
"Formatting provider": "格式化工具",
|
||||
"Automatic (language server first)": "自动(优先语言服务器)",
|
||||
"Run on save": "保存时运行",
|
||||
"Run Prettier on save for enabled languages when it is the selected formatting provider.": "对已启用的语言,在选择 Prettier 作为格式化工具时于保存时运行。",
|
||||
"Run oxfmt on save for enabled languages when it is the selected formatting provider.": "对已启用的语言,在选择 oxfmt 作为格式化工具时于保存时运行。",
|
||||
"Run Ruff on save for enabled languages when it is the selected formatting provider.": "对已启用的语言,在选择 Ruff 作为格式化工具时于保存时运行。",
|
||||
"oxfmt formats an enabled language instead of Prettier below an oxfmt configuration file (.oxfmtrc.json, oxfmt.config.ts) or when it is the selected provider.": "在 oxfmt 配置文件(.oxfmtrc.json、oxfmt.config.ts)所在目录及其子目录中,或选择 oxfmt 作为格式化工具时,oxfmt 将代替 Prettier 格式化已启用的语言。",
|
||||
"Ruff reads the nearest pyproject.toml ([tool.ruff]), ruff.toml, or .ruff.toml. Use it before the language server when preferred.": "Ruff 会读取最近的 pyproject.toml([tool.ruff])、ruff.toml 或 .ruff.toml。选择优先使用 Ruff 时,将优先于语言服务器。",
|
||||
"Enabled languages": "启用的语言",
|
||||
"Reformat Code needs {name}. Install it via {method}?": "重新格式化代码需要 {name}。是否通过 {method} 安装?",
|
||||
"Install Missing Tool": "安装缺失的工具",
|
||||
@@ -1729,6 +1739,10 @@
|
||||
"Prettier formatting timed out": "Prettier 格式化超时",
|
||||
"oxfmt formatting failed": "oxfmt 格式化失败",
|
||||
"oxfmt formatting timed out": "oxfmt 格式化超时",
|
||||
"Ruff formatting failed": "Ruff 格式化失败",
|
||||
"Ruff formatting timed out": "Ruff 格式化超时",
|
||||
"Ruff configuration path does not exist: {path}": "Ruff 配置路径不存在:{path}",
|
||||
"Ruff configuration directory has no ruff.toml, .ruff.toml, or pyproject.toml with [tool.ruff]: {path}": "Ruff 配置目录中没有 ruff.toml、.ruff.toml 或包含 [tool.ruff] 的 pyproject.toml:{path}",
|
||||
"{name} does not support selection formatting for this language; formatted the whole file": "{name} 不支持此语言的选区格式化,已格式化整个文件",
|
||||
"Formatting timed out": "格式化超时",
|
||||
"{name} executable": "{name} 可执行文件",
|
||||
@@ -4854,6 +4868,11 @@
|
||||
"Revert Selected Changes": "还原所选更改",
|
||||
"Show only the first reference": "仅显示第一个引用",
|
||||
"Show tag names": "显示标签名称",
|
||||
"Show lines added and deleted": "显示增加和删除的行数",
|
||||
"{added} line added": "增加了 {added} 行",
|
||||
"{added} lines added": "增加了 {added} 行",
|
||||
"{removed} line deleted": "删除了 {removed} 行",
|
||||
"{removed} lines deleted": "删除了 {removed} 行",
|
||||
"Show commit time rather than author time": "显示提交时间而非作者时间",
|
||||
"Show references on the left": "在左侧显示引用",
|
||||
"Visible columns": "可见列",
|
||||
|
||||
@@ -91,6 +91,7 @@ const UNTRANSLATED = new Set<string>([
|
||||
"CMake",
|
||||
"Prettier",
|
||||
"oxfmt",
|
||||
"Ruff",
|
||||
"IntelliJ IDEA",
|
||||
"PHPStan",
|
||||
// Go environment variables and placeholder values
|
||||
|
||||
+38
-4
@@ -127,11 +127,19 @@ html {
|
||||
--syn-constant: #c77dbb;
|
||||
--syn-python-local-variable: var(--syn-variable);
|
||||
--syn-python-global-variable: var(--syn-global);
|
||||
--syn-python-instance-attribute: var(--syn-property);
|
||||
--syn-python-instance-attribute: var(--syn-variable);
|
||||
--syn-python-class-attribute: var(--syn-property);
|
||||
--syn-python-constant: var(--syn-constant);
|
||||
--syn-python-property: var(--syn-property);
|
||||
--syn-python-type-parameter: var(--syn-type);
|
||||
--syn-python-builtin: var(--syn-builtin);
|
||||
--syn-python-predefined-definition: var(--syn-predefined);
|
||||
--syn-python-predefined-usage: var(--syn-predefined);
|
||||
--syn-python-fstring: #a5c261;
|
||||
--syn-python-bytes: #a5c261;
|
||||
/* PhpStorm colours PHP variables and function calls on their own */
|
||||
--syn-php-variable: var(--syn-property);
|
||||
--syn-php-function-call: var(--syn-function);
|
||||
--syn-builtin: #8888c6;
|
||||
--syn-self: #94558d;
|
||||
--syn-keyword-arg: #aa4926;
|
||||
@@ -518,11 +526,18 @@ html {
|
||||
--syn-constant: #871094;
|
||||
--syn-python-local-variable: var(--syn-variable);
|
||||
--syn-python-global-variable: var(--syn-global);
|
||||
--syn-python-instance-attribute: var(--syn-property);
|
||||
--syn-python-instance-attribute: var(--syn-variable);
|
||||
--syn-python-class-attribute: var(--syn-property);
|
||||
--syn-python-constant: var(--syn-constant);
|
||||
--syn-python-property: var(--syn-property);
|
||||
--syn-python-type-parameter: var(--syn-type);
|
||||
--syn-python-builtin: var(--syn-builtin);
|
||||
--syn-python-predefined-definition: var(--syn-predefined);
|
||||
--syn-python-predefined-usage: var(--syn-predefined);
|
||||
--syn-python-fstring: var(--syn-string);
|
||||
--syn-python-bytes: var(--syn-string);
|
||||
--syn-php-variable: var(--syn-property);
|
||||
--syn-php-function-call: var(--syn-function);
|
||||
--syn-builtin: #000080;
|
||||
--syn-self: #94558d;
|
||||
--syn-keyword-arg: #660099;
|
||||
@@ -784,11 +799,18 @@ html {
|
||||
--syn-constant: #e0a0d6;
|
||||
--syn-python-local-variable: var(--syn-variable);
|
||||
--syn-python-global-variable: var(--syn-global);
|
||||
--syn-python-instance-attribute: var(--syn-property);
|
||||
--syn-python-instance-attribute: var(--syn-variable);
|
||||
--syn-python-class-attribute: var(--syn-property);
|
||||
--syn-python-constant: var(--syn-constant);
|
||||
--syn-python-property: var(--syn-property);
|
||||
--syn-python-type-parameter: var(--syn-type);
|
||||
--syn-python-builtin: var(--syn-builtin);
|
||||
--syn-python-predefined-definition: var(--syn-predefined);
|
||||
--syn-python-predefined-usage: var(--syn-predefined);
|
||||
--syn-python-fstring: var(--syn-string);
|
||||
--syn-python-bytes: var(--syn-string);
|
||||
--syn-php-variable: var(--syn-property);
|
||||
--syn-php-function-call: var(--syn-function);
|
||||
--syn-builtin: #b0b0f0;
|
||||
--syn-self: #d08ac8;
|
||||
--syn-keyword-arg: #e0805c;
|
||||
@@ -1135,11 +1157,18 @@ html {
|
||||
--syn-constant: var(--palette-number);
|
||||
--syn-python-local-variable: var(--syn-variable);
|
||||
--syn-python-global-variable: var(--syn-global);
|
||||
--syn-python-instance-attribute: var(--syn-property);
|
||||
--syn-python-instance-attribute: var(--syn-variable);
|
||||
--syn-python-class-attribute: var(--syn-property);
|
||||
--syn-python-constant: var(--syn-constant);
|
||||
--syn-python-property: var(--syn-property);
|
||||
--syn-python-type-parameter: var(--syn-type);
|
||||
--syn-python-builtin: var(--syn-builtin);
|
||||
--syn-python-predefined-definition: var(--syn-predefined);
|
||||
--syn-python-predefined-usage: var(--syn-predefined);
|
||||
--syn-python-fstring: var(--syn-string);
|
||||
--syn-python-bytes: var(--syn-string);
|
||||
--syn-php-variable: var(--syn-property);
|
||||
--syn-php-function-call: var(--syn-function);
|
||||
--syn-builtin: var(--palette-cyan);
|
||||
--syn-self: var(--palette-red);
|
||||
--syn-keyword-arg: var(--palette-red);
|
||||
@@ -1429,3 +1458,8 @@ html {
|
||||
--db-connected: var(--success);
|
||||
--db-connected-ring: var(--success);
|
||||
}
|
||||
/* PHP editors: variables and function calls use the PHP colours (PHP_VAR / PHP_FUNCTION_CALL). */
|
||||
.cm-editor[data-language="PHP"] {
|
||||
--syn-variable: var(--syn-php-variable);
|
||||
--syn-function-call: var(--syn-php-function-call);
|
||||
}
|
||||
@@ -36,4 +36,5 @@ export { lazyComponent, prefetchLazyComponents } from "./lazy";
|
||||
export { T } from "./T";
|
||||
export { selectableText, copyMenuFor, copyText } from "./selectableText";
|
||||
export { useSelectionBoundary } from "./useSelectionBoundary";
|
||||
export { useSanitizedField } from "./useSanitizedField";
|
||||
export * from "./rowSelection";
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useLayoutEffect, useRef, type ChangeEvent } from "react";
|
||||
|
||||
/**
|
||||
* Rewrites what the user types or pastes into a text input as IntelliJ does for ref names,
|
||||
* keeping the caret where the typed character was. Use as
|
||||
* `<Input ref={ref} onChange={(e) => setValue(clean(e))} />`.
|
||||
*/
|
||||
export function useSanitizedField(sanitize?: (value: string) => string) {
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
const caret = useRef<number | null>(null);
|
||||
|
||||
const place = () => {
|
||||
if (caret.current === null) return;
|
||||
ref.current?.setSelectionRange(caret.current, caret.current);
|
||||
caret.current = null;
|
||||
};
|
||||
// A controlled input whose text was rewritten loses its caret on re-render.
|
||||
useLayoutEffect(place);
|
||||
|
||||
const clean = (e: ChangeEvent<HTMLInputElement>): string => {
|
||||
const raw = e.target.value;
|
||||
const value = sanitize ? sanitize(raw) : raw;
|
||||
if (value !== raw) {
|
||||
const at = e.target.selectionStart ?? raw.length;
|
||||
caret.current = sanitize ? sanitize(raw.slice(0, at)).length : at;
|
||||
// Nothing re-renders when the rewrite equals the current state (a dropped character), and
|
||||
// React then puts the caret at the end once this handler returns: a microtask runs after that.
|
||||
queueMicrotask(place);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
return { ref, clean };
|
||||
}
|
||||
Reference in new issue
Block a user