Compare commits

...
Author SHA1 Message Date
l0ng-ai bc390f48b5 fix(sdk): detect versioned PHP binaries such as php8.1 on PATH
PHP discovery matched only the exact name php on PATH, so Debian and
Ubuntu installs with several versions (ppa:ondrej/php, packages.sury.org)
listed only the alternatives default. PATH directories are now also
scanned for php<version> (php8.1, php81), skipping shim directories and
the php-cgi / phpize / phpdbg helpers. The plain php comes first, so
deduplication by real path keeps /usr/bin/php over the php8.3 it links to.
2026-11-05 23:49:44 +08:00
a275239138 feat(python): support Ruff formatting and config directories (#1573)
Ruff is now an external formatter for Python next to the Ruff language server: Settings › Languages & Frameworks › Ruff has the executable path, a configuration directory (or file), the provider preference, enabled languages, run-on-save, and Reformat with Ruff. Formatting runs on the unsaved document through stdin (with Ruff's --range for selections, in 1-based code-point columns) and never writes the file first. The configuration directory is shared with Ruff LSP, which can then start without a root marker. Tools that can't format a language are rejected even when requested.

Co-authored-by: muutot <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 23:41:44 +08:00
f35026f31c feat(vcs): let the Git log show lines added and deleted (#1564)
The Git log's changed-files pane can show the lines a commit added and deleted per file and as a total per directory and for the selection, from git diff --numstat -z -M over the same revision pairs as the file list (renames matched by either name). Off by default; turned on in Settings › Version Control › Log or the pane's View Options, so selecting a commit runs no extra diff unless asked.

Co-authored-by: liqiujiong <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 23:39:40 +08:00
875e049dcd fix(editor): align Python identifier colors and unused-parameter styling (#1572)
Python defaults diverged in several places: built-ins used the predefined magenta, __init__/__doc__ used function/self colours, instance fields the property colour, f-strings and byte strings the generic string colour, and the PEP 695 type keyword was uncoloured. Python now has its own tokens for built-ins, predefined definitions/usages, f-strings and byte strings; instance fields follow identifiers; type is a keyword. Unused parameters (not reported by Pyrefly) are greyed by a scope-aware local check that skips self/cls/_ names and signatures dictated elsewhere: stubs, @abstractmethod/@overload/@override, dunder methods and methods of classes with bases.

Co-authored-by: muutot <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 23:38:03 +08:00
JawandClaude Opus 5.5 4ca44ee815 feat(editor): highlight log files by level, timestamp and stack frame (#1577)
.log files rendered as plain text: the log language had no ui-mode and CodeMirror ships no log grammar. A lightweight Log stream mode now colours severity levels (upper case anywhere, any case only when marked: [warn], <Info>, level=error), timestamps, exception names, URLs and stack frames, reusing scheme colours; WARN uses the UI warning colour.

Closes #1541

Co-authored-by: jaw52 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 23:37:26 +08:00
l0ng-ai 1186cdce64 fix(editor): keep selections whole over line backgrounds and indent guides (#1568)
* fix(editor): keep selections whole over line backgrounds and indent guides

Multi-line selections could show a 1px seam between their stacked
rectangles, indent guides showed as short dashes inside a selection,
and a breakpoint or execution line hid the selection past its text.

- Snap selection rectangle edges to device pixels so stacked pieces
  share one edge.
- Paint caret-row, breakpoint and execution line backgrounds on a
  pseudo-element below the selection layer.
- Draw indent guides on a pseudo-element above the text.

* test(e2e): check the visible selection layer in the theme flow
2026-11-05 23:31:14 +08:00
l0ng-aiandClaude Opus 5.5 f665beb17e fix(run): rerun with the configuration as edited, and drop deleted ones from Services (#1579)
Rerunning a run tab launched the configuration snapshot taken when the tab first ran, so edits from Edit Configurations were ignored until the tab was closed. Every rerun path (Run tool window, Services, the exit notification, Debug from a run tab, Rerun Failed Tests / selected tests, Update Application's restart) now uses the saved configuration with the same id, falling back to the snapshot when it was deleted or never saved. Services also drops a configuration deleted after it ran once its run has ended; ad-hoc runs (build tasks, external tools) still show.

Fixes #1497

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 23:12:13 +08:00
JawandClaude Opus 5.5 d1acf9f9ac feat(vcs): replace spaces typed in a branch name with a separator (#1571)
Ref-name inputs only validated after the fact, so a space typed or pasted into a branch name showed "Branch name contains invalid characters" and disabled Create. Branch-name fields (New Branch, from a revision and in several repositories, Rename Branch, New Worktree, GitHub New Remote Branch) now clean the name while typing: forbidden characters are dropped and whitespace becomes '-', without stacking separators, keeping the caret in place. Shared via src/platform/gitRefName.ts and the useSanitizedField hook in src/ui.

Closes #1553

Co-authored-by: jaw52 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 23:09:55 +08:00
JawandClaude Opus 5.5 c179b1a822 fix(markdown): keep the blocks between an HTML container's tags inside it (#1566)
The preview wraps each top-level block in its own data-line element for scroll sync, so an HTML container (<details>, <div align=center>, <section>…) whose close tag came in a later block was closed at the first wrapper and its Markdown content spilled out: <details> couldn't be collapsed and centred README headers weren't centred. Blocks inside an unclosed container now share one wrapper up to its close, and the reader's open/closed state of each <details> survives edits in split mode.

Closes #1524

Co-authored-by: jaw52 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 23:08:55 +08:00
l0ng-aiandClaude Opus 5.5 846ac97650 fix(editor): colour PHP variables and function calls (#1581)
PHP editors showed $variables and function calls in the plain text colour because Language Defaults leave both uncoloured. The built-in schemes now give PHP its own colours (PHP_VAR, PHP_FUNCTION_CALL): each scheme's instance-field purple and function colour; imported PHP colours and user PHP overrides still win.

Fixes #1532

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 23:08:14 +08:00
88 changed files with 2209 additions and 129 deletions

No files matched your search

+32 -3
View File
@@ -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",
+113 -16
View File
@@ -3,7 +3,7 @@
use crate::Host;
use ide_core::event::NotifyLevel;
use ide_core::settings::{ExternalFormatter, LanguagesSettings};
use ide_core::{DocId, IdeError, IdeEvent, IdeResult, 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,
}
);
}
}
+16
View File
@@ -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
});
+1
View File
@@ -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"
+1
View File
@@ -533,6 +533,7 @@ mod tests {
"Julia",
"Kotlin",
"LiveScript",
"Log",
"Lua",
"mIRC",
"Mathematica",
+51
View File
@@ -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
}
+37 -2
View File
@@ -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()
+55 -1
View File
@@ -1,6 +1,6 @@
use crate::apply::{
apply_to_run_configs, configure_server, jdtls_jdk, maven_command, maven_options, project_jdk, python_interpreter,
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);
+94 -12
View File
@@ -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"));
}
}
+9 -1
View File
@@ -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())
}
+67
View File
@@ -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"
);
}
+18
View File
@@ -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));
+52 -1
View File
@@ -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 },
]
);
}
}
+36
View File
@@ -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]
+4
View File
@@ -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);
+7 -2
View File
@@ -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` |
+70
View File
@@ -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();
});
+9
View File
@@ -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!,
+10 -2
View File
@@ -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
View File
@@ -111,7 +111,7 @@ for (const palette of palettes) {
);
await editor.locator(".cm-content").click();
await page.keyboard.press("Meta+a");
await expect(editor.locator(".cm-selectionBackground").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");
+1
View File
@@ -2,3 +2,4 @@ id = "log"
name = "Log"
extensions = ["log"]
indent = { tab-size = 4, insert-spaces = true }
ui-mode = "Log"
+1
View File
@@ -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",
+1
View File
@@ -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,
+15
View File
@@ -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
View File
@@ -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,
+5 -1
View File
@@ -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)" },
+40
View File
@@ -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 = [
+5
View File
@@ -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 () => {
+83
View File
@@ -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);
+2
View File
@@ -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(" ") }) : [],
];
}
+21
View File
@@ -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);
});
});
+48
View File
@@ -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",
},
}),
];
}
+18 -2
View File
@@ -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%",
+10 -3
View File
@@ -124,12 +124,12 @@ function editorAction(
return { id, title, category, when: EDITOR_WHEN, run: withEditor(run), ...extra };
}
/** Whether saving a `language` file formats it: Format on save, or Run on save of 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")),
),
+2
View File
@@ -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);
+12 -6
View File
@@ -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,
+6 -5
View File
@@ -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();
+65
View File
@@ -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(
+89 -2
View File
@@ -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;
+2
View File
@@ -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",
]);
+8 -3
View File
@@ -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 {
+1 -1
View File
@@ -61,7 +61,7 @@ export function inlineValuesFor(text: string, values: Record<string, string>): s
function lineDecorations(state: EditorState, exec: Exec | null): DecorationSet {
if (!exec || exec.line >= state.doc.lines) return Decoration.none;
const line = state.doc.line(exec.line + 1);
return Decoration.set([Decoration.line({ class: styles.execLine }).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>
+2
View File
@@ -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");
+5 -4
View File
@@ -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();
+36 -1
View File
@@ -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: [] });
});
});
+11
View File
@@ -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", () => {
+25 -5
View File
@@ -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 });
}
+31 -5
View File
@@ -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."
/>
);
}
+19 -1
View File
@@ -5,7 +5,7 @@
*/
import { GoPage, JavaPage, NodePage, PhpPage, RubyPage, RustPage, TypeScriptPage } from "../languages/LanguagePages";
import { MarkdownPage } from "../languages/MarkdownPage";
import { OxfmtPage, PrettierPage } 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.
+6 -1
View File
@@ -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,
);
}
},
},
+6 -5
View File
@@ -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: [
+15
View File
@@ -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;
+53 -2
View File
@@ -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>
);
}
+1
View File
@@ -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 }[] = [
+14 -1
View File
@@ -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();
});
+67 -2
View File
@@ -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");
});
});
+4
View File
@@ -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 },
+7 -3
View File
@@ -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 -2
View File
@@ -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}>
+73 -1
View File
@@ -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
+3
View File
@@ -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");
+45 -4
View File
@@ -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 },
+23
View File
@@ -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", () => {
+2 -1
View File
@@ -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"),
+50
View File
@@ -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);
}
});
});
+32
View File
@@ -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;
}
}
+8
View File
@@ -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>' +
+1
View File
@@ -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",
});
+13 -3
View File
@@ -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),
+19 -1
View File
@@ -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"
}
+19
View File
@@ -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": "可见列",
+1
View File
@@ -91,6 +91,7 @@ const UNTRANSLATED = new Set<string>([
"CMake",
"Prettier",
"oxfmt",
"Ruff",
"IntelliJ IDEA",
"PHPStan",
// Go environment variables and placeholder values
+38 -4
View File
@@ -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);
}
+1
View File
@@ -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";
+34
View File
@@ -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 };
}