Compare commits

...
Author SHA1 Message Date
l0ng-ai 4f89cc7456 feat(vcs): delete changed files from the Changes view
Delete… (⌫ / Delete) in the Commit view's Changes tree was only offered for
unversioned files. It now applies to any selected files still on disk, moving
them to the Trash; a tracked file then shows as a deleted change.
2026-11-06 01:05:22 +08:00
l0ng-ai 7dbce43c88 feat(debugger): console actions in a toolbar on the left of the debug Console (#1595)
Scroll to End, Soft-Wrap and Clear sat at the far right of the evaluate row, easy to miss or cut off in a narrow window. The Console tab now has the same vertical toolbar as the Run window: Find, Scroll to End, Soft-Wrap and Clear.
2026-11-06 01:03:56 +08:00
l0ng-ai f68d8f499a fix(editor): open the launched file when the window restores its last tabs (#1594)
Starting OpenIDEA with a file showed the last session's tabs without the file: restoring the saved tabs counted as the user activating another tab, so the pending open of the launched file was dropped as superseded. Restoring a session no longer counts as an activation; the launched file opens next to the restored tabs and becomes active (also for a file opened with its project at startup).
2026-11-06 00:58:18 +08:00
l0ng-ai 7a715758a6 feat(database): go to a column of the table editor with File Structure's shortcut (#1592)
⌘F12 / Ctrl+F12 in a focused table editor now opens Go to Column: the table's columns with types and key icons, filtered as you type; Enter selects that column's cell in the current row and scrolls to it. The grid also scrolls sideways when arrow keys or the search bar's current match move to a column outside the view.
2026-11-06 00:43:01 +08:00
l0ng-ai e5c250aad8 feat(vcs): resize the Push dialog's commits and changed files with a splitter (#1591)
The commits list and the changed files tree had fixed heights, so a push with one commit and many files left the files in a small scroll area and empty space above the buttons. They now sit in a vertical split that fills the dialog body, and the divider position is remembered.
2026-11-06 00:37:50 +08:00
l0ng-ai ad64506336 fix(plugins): explain how to approve a process program installed after its grant (#1590)
A process: permission allowed while its program was missing has no pinned binary, so installing the program and restarting changes nothing. The error now says the program must be on PATH and that the permission has to be cleared and selected again in Settings › Plugins.
2026-11-06 00:28:10 +08:00
Qingandqtvbwfn f3fe837a66 fix: detect external file edits that preserve mtime and length (#1582)
An external write that keeps a file's byte length and mtime was missed twice: the Maven launch fingerprint reused the stale class snapshot, and watcher events for clean open documents were dropped by the (mtime, length) stamp check.

The launch fingerprint now records a blake3 content hash for project files (toolchain inputs such as the JDK's lib/modules keep the stamp only) and bumps its version. Watcher events for clean documents compare the disk bytes with what saving the buffer would write; blind focus scans keep the stamp dedup, and forced refreshes after IDE disk operations still reload clean buffers.

Co-authored-by: qtvbwfn <[email protected]>
2026-11-06 00:24:56 +08:00
Shohruh Ismoilovandshohruh-genius af0ffb809e fix(project-tree): sort workspace projects by folder name (#1583)
Workspace member roots were listed in manifest order. The Project and Project Files views now sort them by the displayed folder name with the same natural, case-insensitive comparison as tree entries; the stored member order is unchanged.

Co-authored-by: shohruh-genius <[email protected]>
2026-11-06 00:17:37 +08:00
Shohruh Ismoilovandshohruh-genius 31e477b7fb fix(themes): include PHP tokens in Harbor Night and the reference (#1585)
Harbor Night and the theme reference were missing the --syn-php-variable / --syn-php-function-call tokens added for PHP highlighting, so scripts/theme/theme.test.ts failed on main.

Co-authored-by: shohruh-genius <[email protected]>
2026-11-06 00:16:38 +08:00
l0ng-aiandClaude Opus 5.5 425801be34 feat(database): submit and revert table edits from the keyboard (#1589)
The table editor's Submit and Revert were toolbar buttons only. They are now the db.submitChanges and db.revertChanges actions, enabled while a table editor has focus and pending edits, bound to ⌘↩ / Ctrl+Enter and ⌥⌘Z / Ctrl+Alt+Z in the IntelliJ keymaps (unbound in Eclipse, where ⌥⌘Z is Surround With). A text field keeps ⌘↩ for its own edit (Value panel, large-value dialog), and query consoles keep Execute.

Closes #1549

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 00:08:37 +08:00
l0ng-ai 269f3b22fd fix(terminal): disable pwsh update banners by default on Windows (#1588) 2026-11-06 00:08:35 +08:00
l0ng-aiandClaude Opus 5.5 2c6131999c feat(php): list PHP versions newest first (#1587)
Detected PHP interpreters are sorted newest first, number by number and stable (PATH install first among equal versions), and the language level select and status bar menu list Auto, then 8.5 down to 5.6. With Default selected, the Version row now shows the version of the php found on PATH instead of the first list entry.

Closes #1557

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 23:54:45 +08:00
l0ng-aiandClaude Opus 5.5 97177486e9 fix(sdk): detect versioned PHP binaries such as php8.1 on PATH
PHP discovery matched only the exact name php on PATH, so Debian/Ubuntu installs with several versions 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 /usr/bin/php is kept over the php8.3 it links to.

Fixes #1558

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 23:50:08 +08:00
l0ng-aiandClaude Opus 5.5 57d35ecdb4 fix(run): run PHP files with the interpreter chosen in Settings (#1584)
Running a .php file or a #!/usr/bin/env php script used the PATH php: the file producer resolves tools on PATH and the settings pass only replaces a bare php command. A new sdk_php_effective command returns the configured interpreter (like sdk_python_effective) and the producer prefers it; the not-found text points to the PHP settings page.

Fixes #1555

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 23:48:13 +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
132 changed files with 3193 additions and 342 deletions

No files matched your search

Generated
+1
View File
@@ -4121,6 +4121,7 @@ dependencies = [
name = "ide-build"
version = "0.1.37"
dependencies = [
"blake3",
"dirs 6.0.0",
"dunce",
"ide-core",
+1
View File
@@ -6,6 +6,7 @@ edition.workspace = true
license.workspace = true
[dependencies]
blake3.workspace = true
dunce.workspace = true
ide-core.workspace = true
zip.workspace = true
+57 -6
View File
@@ -2,18 +2,49 @@
use super::*;
use std::fs;
use std::hash::{Hash, Hasher};
use std::io::Read;
use std::time::SystemTime;
/// `(byte length, mtime, blake3 hex)`; the hash is recorded for files inside the project only.
type Stamp = (u64, SystemTime, Option<String>);
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct LaunchFingerprint {
version: u32,
parameters: String,
files: BTreeMap<PathBuf, Option<(u64, SystemTime)>>,
/// Per input: a [`Stamp`] for regular files, `None` for
/// directories and inputs that cannot be resolved. The content hash catches external
/// edits that preserve both length and mtime (timestamp-preserving tools, coarse
/// timestamp granularity), which `(length, mtime)` alone would miss. Only files inside
/// the project are hashed; toolchain inputs such as the JDK's `lib/modules` keep the stamp.
files: BTreeMap<PathBuf, Option<Stamp>>,
}
/// `blake3` hex of a file's bytes, streamed in chunks so memory stays bounded. An
/// unreadable file records a sentinel so one permission problem does not drop the whole
/// fingerprint (that would just force a normal rebuild).
fn content_hash(path: &Path) -> IdeResult<String> {
let mut file = match fs::File::open(path) {
Ok(file) => file,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok("missing".into()),
Err(e) => return Err(e.into()),
};
let mut hasher = blake3::Hasher::new();
let mut buffer = [0; 64 * 1024];
loop {
let count = file.read(&mut buffer)?;
if count == 0 {
break;
}
hasher.update(&buffer[..count]);
}
Ok(hasher.finalize().to_hex().to_string())
}
fn scan(
root: &Path,
path: &Path,
files: &mut BTreeMap<PathBuf, Option<(u64, SystemTime)>>,
files: &mut BTreeMap<PathBuf, Option<Stamp>>,
ancestors: &mut HashSet<PathBuf>,
cancel: &CancellationToken,
) -> IdeResult<()> {
@@ -36,11 +67,12 @@ fn scan(
// Directory mtimes change on irrelevant JDT writes. File paths detect additions/deletions.
files.insert(path.to_path_buf(), None);
for entry in fs::read_dir(path)? {
scan(&entry?.path(), files, ancestors, cancel)?;
scan(root, &entry?.path(), files, ancestors, cancel)?;
}
ancestors.remove(&canonical);
} else if meta.is_file() {
files.insert(path.to_path_buf(), Some((meta.len(), meta.modified()?)));
let hash = if path.starts_with(root) { Some(content_hash(path)?) } else { None };
files.insert(path.to_path_buf(), Some((meta.len(), meta.modified()?, hash)));
} else {
return Err(IdeError::internal("unsupported launch input file"));
}
@@ -145,11 +177,11 @@ pub fn launch_fingerprint(
.map_err(|e| IdeError::internal(e.to_string()))?;
let mut files = BTreeMap::new();
for path in inputs {
scan(&path, &mut files, &mut HashSet::new(), cancel)?;
scan(root, &path, &mut files, &mut HashSet::new(), cancel)?;
}
// Store project inputs relative to the project, including each file's relative path.
let files = files.into_iter().map(|(p, stamp)| (p.strip_prefix(root).unwrap_or(&p).to_path_buf(), stamp)).collect();
Ok(Some(LaunchFingerprint { version: 1, parameters, files }))
Ok(Some(LaunchFingerprint { version: 2, parameters, files }))
}
#[cfg(test)]
@@ -231,6 +263,25 @@ mod tests {
}
}
/// An external tool that keeps the byte length and the mtime (timestamp-preserving
/// writes, coarse-granularity filesystems) still changes the content: the fingerprint
/// must differ via the content hash, not via `(length, mtime)`.
#[test]
fn external_edit_preserving_length_and_mtime_still_invalidates() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let model = fixture(root);
let source = root.join("lib/src/main/kotlin/Channel.kt");
let before = fingerprint(root, &model);
assert!(before.is_some());
let mtime = fs::metadata(&source).unwrap().modified().unwrap();
// Same 5-byte length, different content.
write(root, "lib/src/main/kotlin/Channel.kt", "valve");
fs::File::options().write(true).open(&source).unwrap().set_modified(mtime).unwrap();
let after = fingerprint(root, &model);
assert_ne!(before, after, "a same-length, same-mtime content change must invalidate the fingerprint");
}
#[test]
fn profile_options_jdk_goal_and_wrapper_invalidate_without_using_class_outputs() {
let dir = tempfile::tempdir().unwrap();
+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",
+1 -1
View File
@@ -143,7 +143,7 @@ fn nvm_bins(dir: &Path) -> Vec<PathBuf> {
}
/// `v22.10.1` as `[22, 10, 1]`: v22 is newer than v9, v20.10 newer than v20.9.
fn version_key(name: &str) -> Vec<u64> {
pub fn version_key(name: &str) -> Vec<u64> {
name.trim_start_matches('v').split('.').map(|n| n.parse().unwrap_or(0)).collect()
}
+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,
}
);
}
}
+23
View File
@@ -242,6 +242,29 @@ mod tests {
assert!(build().is_err());
assert_eq!(fs::read_to_string(root.join("calls")).unwrap().lines().count(), 5);
}
/// An external tool (e.g. an AI code editor) rewrites a source file with the same byte
/// length and preserves its mtime. The `(length, mtime)` fingerprint reads that as
/// "unchanged" and would reuse the stale snapshot; the content hash must catch it.
#[test]
fn external_edit_preserving_length_and_mtime_rebuilds_the_snapshot() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let model = fixture(root);
let cache = root.join("cache");
let cancel = CancellationToken::new();
let mut session = Session::new(cache.clone());
let first = session.build(root, &model, root, false, None, &cancel).unwrap();
assert_eq!(fs::read_to_string(first.snapshot.join("0/Main.class")).unwrap(), "first");
let source = root.join("src/main/kotlin/Main.kt");
let mtime = fs::metadata(&source).unwrap().modified().unwrap();
fs::write(&source, "other").unwrap(); // 5 bytes, same length as "first"
fs::File::options().write(true).open(&source).unwrap().set_modified(mtime).unwrap();
let rebuilt = session.build(root, &model, root, false, None, &cancel).unwrap();
assert_ne!(rebuilt.snapshot, first.snapshot, "a same-length, same-mtime content change must rebuild");
assert_eq!(fs::read_to_string(rebuilt.snapshot.join("0/Main.class")).unwrap(), "other");
}
#[test]
fn session_drop_removes_superseded_snapshots_and_preserves_records_across_reopen() {
let dir = tempfile::tempdir().unwrap();
+9
View File
@@ -52,6 +52,14 @@ pub fn sdk_python_effective(host: &Host, window: &str) -> IdeResult<Option<ide_c
Ok(ide_sdk::apply::python_interpreter(&project.root, &project.settings.effective()).map(Into::into))
}
/// The PHP interpreter chosen in Settings › Languages & Frameworks › PHP; `None`: the `php` on `PATH`.
pub fn sdk_php_effective(host: &Host, window: &str) -> IdeResult<Option<ide_core::IpcPath>> {
host.require_trust(window)?;
let project = host.project(window)?;
let settings = project.settings.effective();
Ok(ide_sdk::apply::setting_path(&project.root, &settings.languages.php.interpreter).map(Into::into))
}
/// The PHP language level of the project `path` belongs to (a workspace member, by path, as
/// the language server configuration picks it).
#[derive(Clone, Debug, serde::Serialize, specta::Type)]
@@ -198,6 +206,7 @@ pub async fn sdk_python_pip(
pub(crate) fn register(d: &mut Dispatcher) {
d.add("sdk_detect", |c| async move { sdk_detect(&c.host, &c.window, c.arg("kind")?).await });
d.add("sdk_python_effective", |c| async move { sdk_python_effective(&c.host, &c.window) });
d.add("sdk_php_effective", |c| async move { sdk_php_effective(&c.host, &c.window) });
d.add(
"sdk_php_language_level",
|c| async move { sdk_php_language_level(&c.host, &c.window, c.arg("path")?).await },
+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
});
+5 -3
View File
@@ -306,8 +306,9 @@ impl PluginApi {
// allowed, and installing it later approves nothing by itself.
let pin = self.shared.pins.read().get(&(self.id().to_owned(), cap.clone())).cloned().ok_or_else(|| {
HostError::Failed(format!(
"{cap}: `{program}` was not found when this permission was allowed; \
install it, then allow {cap} again in Settings › Plugins"
"{cap}: `{program}` was not found on PATH when this permission was allowed. \
Restarting doesn't approve a program installed later: once `{program}` is on PATH, \
clear {cap} in Settings › Plugins and select it again"
))
})?;
pin.verify().map(Path::to_path_buf).map_err(|e| HostError::Denied(format!("{cap}: {e}")))
@@ -831,7 +832,8 @@ mod tests {
// Allowed while the program was missing: say how to fix it rather than "not granted".
let err = api.approved_program("lint").unwrap_err().to_string();
assert!(
err.contains("was not found when this permission was allowed") && err.contains("allow process:lint again"),
err.contains("was not found on PATH when this permission was allowed")
&& err.contains("clear process:lint in Settings › Plugins and select it again"),
"{err}"
);
+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
}
+48 -4
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()
@@ -332,7 +367,7 @@ fn php() -> Vec<Sdk> {
}
}
let found = dedupe_by_real_path(found);
par_map(&found, |(path, source)| {
let mut sdks = par_map(&found, |(path, source)| {
let version = query(path, &["-r", "echo PHP_VERSION;"], VERSION_TIMEOUT)?;
let version = version.trim().to_owned();
if version.is_empty() {
@@ -346,7 +381,16 @@ fn php() -> Vec<Sdk> {
home: path.parent().and_then(Path::parent).map(Into::into),
extra: None,
})
})
});
newest_first(&mut sdks);
sdks
}
/// Newest version first, number by number (8.3.12 before 8.3.9); stable, so on equal
/// versions the `PATH` install stays first.
pub(crate) fn newest_first(sdks: &mut [Sdk]) {
let key = |s: &Sdk| ide_core::tool_path::version_key(s.version.as_deref().unwrap_or(""));
sdks.sort_by_key(|s| std::cmp::Reverse(key(s)));
}
fn node() -> Vec<Sdk> {
+77 -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,50 @@ fn php_sources_and_selected_interpreter() {
assert_eq!(source("/opt/homebrew/opt/[email protected]/bin/php"), "homebrew");
assert_eq!(source("/home/me/bin/php"), "path");
use crate::discover::{is_versioned_php_name, newest_first, versioned_php_in};
let sdk = |path: &str, version: &str| crate::Sdk {
path: std::path::PathBuf::from(path).into(),
name: format!("PHP {version}"),
version: Some(version.into()),
source: "path".into(),
home: None,
extra: None,
};
let mut sdks = vec![
sdk("/opt/homebrew/bin/php", "8.4.1"),
sdk("/opt/homebrew/opt/[email protected]/bin/php", "8.1.31"),
sdk("/opt/homebrew/opt/[email protected]/bin/php", "8.2.20"),
sdk("/home/me/.phpbrew/php/php-8.3.9/bin/php", "8.3.9"),
sdk("/home/me/.phpbrew/php/php-8.3.12/bin/php", "8.3.12"),
sdk("/home/me/.phpbrew/php/php-7.4.33/bin/php", "7.4.33"),
sdk("/usr/bin/php", "8.4.1"),
];
newest_first(&mut sdks);
let order: Vec<_> = sdks.iter().map(|s| s.version.clone().unwrap()).collect();
assert_eq!(order, ["8.4.1", "8.4.1", "8.3.12", "8.3.9", "8.2.20", "8.1.31", "7.4.33"]);
// Stable: on equal versions the PATH install stays first.
assert_eq!(sdks[0].path.0, std::path::PathBuf::from("/opt/homebrew/bin/php"));
for name in ["php8.1", "php81", "php8", "php8.3.exe"] {
assert!(is_versioned_php_name(name), "{name}");
}
for name in ["php", "php-cgi8.1", "phpize8.1", "phpdbg8.1", "php-fpm8.1", "php8.", "php.8", "php8.1.2x", "phpunit"]
{
assert!(!is_versioned_php_name(name), "{name}");
}
if cfg!(windows) {
return; // Unix paths
}
let bins = tempfile::tempdir().unwrap();
for name in ["php", "php8.3", "php8.1", "php-cgi8.1", "phpize8.1", "phpdbg8.1"] {
std::fs::write(bins.path().join(name), "").unwrap();
}
std::fs::create_dir(bins.path().join("php9.0")).unwrap();
assert_eq!(
versioned_php_in([bins.path().to_path_buf()]),
vec![bins.path().join("php8.1"), bins.path().join("php8.3")]
);
let tmp = tempfile::tempdir().unwrap();
let mut s = Settings::default();
assert_eq!(crate::apply::php_dir(tmp.path(), &s), None);
+63
View File
@@ -120,6 +120,7 @@ impl TerminalManager {
for (k, v) in env {
cmd.env(k, v);
}
apply_powershell_env(&shell, &mut cmd);
let child = pair
.slave
@@ -633,6 +634,18 @@ fn terminal_env(user: &BTreeMap<String, String>) -> BTreeMap<String, String> {
env
}
/// Older pwsh versions can crash while formatting a localized update banner
/// (PowerShell/PowerShell#28131). Keep explicit preferences in the final environment.
fn apply_powershell_env(shell: &str, cmd: &mut CommandBuilder) {
let name = shell.rsplit(['/', '\\']).next().unwrap_or(shell);
if cfg!(windows)
&& (name.eq_ignore_ascii_case("pwsh") || name.eq_ignore_ascii_case("pwsh.exe"))
&& cmd.get_env("POWERSHELL_UPDATECHECK").is_none_or(|v| v.is_empty())
{
cmd.env("POWERSHELL_UPDATECHECK", "Off");
}
}
/// Git Bash's MSYSTEM profile on Windows, when nobody chose one. A bare
/// `bash.exe` would run with the `MSYS` default, whose PATH leaves `git` and
/// the mingw64 tools out unless `<Git>\cmd` happens to be inherited — the Git
@@ -938,6 +951,56 @@ mod tests {
mod shell_start_on_every_platform {
use super::*;
#[test]
fn powershell_update_notifications_default_to_off_on_windows() {
for shell in ["pwsh", "pwsh.exe", "PWSH.EXE", "C:\\Program Files\\PowerShell\\7\\PwSh.ExE", "C:/pwsh/pwsh.exe"]
{
for value in [None, Some("")] {
let mut cmd = CommandBuilder::new(shell);
cmd.env_clear();
if let Some(value) = value {
cmd.env("POWERSHELL_UPDATECHECK", value);
}
apply_powershell_env(shell, &mut cmd);
let want = if cfg!(windows) { Some("Off") } else { value };
assert_eq!(cmd.get_env("POWERSHELL_UPDATECHECK"), want.map(std::ffi::OsStr::new), "{shell}");
}
}
}
#[test]
fn powershell_update_notification_preferences_are_preserved() {
for value in ["Default", "LTS", "Off"] {
let mut cmd = CommandBuilder::new("pwsh.exe");
cmd.env_clear();
cmd.env("POWERSHELL_UPDATECHECK", value);
apply_powershell_env("pwsh.exe", &mut cmd);
assert_eq!(cmd.get_env("POWERSHELL_UPDATECHECK"), Some(std::ffi::OsStr::new(value)));
}
}
#[cfg(windows)]
#[test]
fn powershell_update_notification_preferences_ignore_key_case() {
let mut cmd = CommandBuilder::new("pwsh.exe");
cmd.env_clear();
cmd.env("POWERSHELL_UPDATECHECK", "Off");
cmd.env("powershell_updatecheck", "Default");
apply_powershell_env("pwsh.exe", &mut cmd);
assert_eq!(cmd.get_env("POWERSHELL_UPDATECHECK"), Some(std::ffi::OsStr::new("Default")));
assert_eq!(cmd.iter_full_env_as_str().count(), 1);
}
#[test]
fn other_shells_keep_their_update_notification_environment() {
for shell in ["powershell.exe", "cmd.exe", "wsl.exe", "bash.exe", "C:\\pwsh.exe\\other.exe", "other-pwsh.exe"] {
let mut cmd = CommandBuilder::new(shell);
cmd.env_clear();
apply_powershell_env(shell, &mut cmd);
assert_eq!(cmd.get_env("POWERSHELL_UPDATECHECK"), None, "{shell}");
}
}
#[test]
fn posix_shells_start_as_login_shells_everywhere() {
let (shell, args) = resolve_shell(Some("C:\\Program Files\\Git\\bin\\bash.exe"), &[]);
+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]
+21 -4
View File
@@ -868,17 +868,18 @@ impl Documents {
.then(|| st.path.clone())
})
.collect();
self.reload_from_disk(&paths, force_clean);
self.reload_from_disk(&paths, force_clean, true);
}
/// Re-reads from disk after an external change. Clean documents reload
/// silently; dirty ones are left alone and reported via `IdeEvent::Notify`.
/// Changes matching our own last save are ignored.
pub fn on_disk_changed(&self, paths: &[PathBuf]) {
self.reload_from_disk(paths, false);
self.reload_from_disk(paths, false, false);
}
fn reload_from_disk(&self, paths: &[PathBuf], force_clean: bool) {
/// `dedup`: blind scans skip files whose `(mtime, length)` stamp still matches.
fn reload_from_disk(&self, paths: &[PathBuf], force_clean: bool, dedup: bool) {
for path in paths {
let Some(id) = self.by_path(path) else { continue };
let result = self.with_doc(id, |st| {
@@ -887,7 +888,23 @@ impl Documents {
}
let Ok(meta) = std::fs::metadata(&st.path) else { return Ok(()) };
let mtime = ide_vfs::mtime_ms(&meta);
if mtime == st.disk_mtime && meta.len() == st.disk_len && (!force_clean || st.dirty) {
let unchanged = mtime == st.disk_mtime && meta.len() == st.disk_len;
// Blind scans trust the stamp. OS events are authoritative, but they also
// echo our own saves: a clean document whose stamp matches is re-checked by
// bytes — an external write that preserved both length and mtime differs
// from what saving this buffer would write, our own save's echo does not.
// Native-to-ascii properties documents and dirty documents keep the stamp
// dedup: the former's disk bytes legitimately differ from the buffer text,
// the latter warn once per disk state.
if unchanged
&& (!force_clean || st.dirty)
&& (dedup
|| st.dirty
|| st.native_to_ascii
|| ide_vfs::encode(&st.doc.text_for_save(), &st.encoding, st.has_bom)
.map(|bytes| bytes == std::fs::read(&st.path).unwrap_or_default())
.unwrap_or(true))
{
return Ok(());
}
if st.dirty {
+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);
+40
View File
@@ -403,6 +403,46 @@ fn external_changes() {
f.docs.on_disk_changed(&[f.root.join("other.txt")]);
}
/// An IDE operation that rewrites a file with the same length and mtime still refreshes
/// the clean buffer.
#[test]
fn own_disk_change_preserving_length_and_mtime_reloads_a_clean_document() {
let f = fixture();
let path = f.root.join("stamp.txt");
fs::write(&path, "first").unwrap();
let id = f.docs.open(&path).unwrap().id;
let mtime = fs::metadata(&path).unwrap().modified().unwrap();
fs::write(&path, "other").unwrap();
fs::File::options().write(true).open(&path).unwrap().set_modified(mtime).unwrap();
f.docs.sync_after_disk_change();
assert_eq!(f.docs.snapshot(id).unwrap().text, "other");
}
/// An external write that keeps the byte length and the mtime (timestamp-preserving
/// tools, coarse-granularity filesystems) leaves the `(mtime, length)` stamp equal:
/// the OS event is still authoritative, so a clean document reloads.
#[test]
fn external_change_preserving_length_and_mtime_reloads_a_clean_document() {
let f = fixture();
let path = f.root.join("stamp.txt");
fs::write(&path, "first").unwrap(); // 5 bytes
let id = f.docs.open(&path).unwrap().id;
let mtime = fs::metadata(&path).unwrap().modified().unwrap();
fs::write(&path, "other").unwrap(); // 5 bytes, different content
fs::File::options().write(true).open(&path).unwrap().set_modified(mtime).unwrap();
f.docs.on_disk_changed(std::slice::from_ref(&path));
let snap = f.docs.snapshot(id).unwrap();
assert_eq!(snap.text, "other");
assert!(!snap.dirty);
// The reload recorded the new stamp: repeating the same event changes nothing.
let before = f.events.lock().len();
f.docs.on_disk_changed(std::slice::from_ref(&path));
assert_eq!(f.events.lock().len(), before);
assert_eq!(f.docs.snapshot(id).unwrap().text, "other");
}
#[test]
fn encodings_and_read_only() {
let f = fixture();
+9 -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.
242 themeable tokens: 121 in the UI theme file, 121 in the editor colour scheme file.
### Surfaces (UI theme)
@@ -436,11 +436,18 @@ a token.
| `--syn-constant` | `#c77dbb` | `#871094` |
| `--syn-python-local-variable` | `var(--syn-variable)` | `var(--syn-variable)` |
| `--syn-python-global-variable` | `var(--syn-global)` | `var(--syn-global)` |
| `--syn-python-instance-attribute` | `var(--syn-property)` | `var(--syn-property)` |
| `--syn-python-instance-attribute` | `var(--syn-variable)` | `var(--syn-variable)` |
| `--syn-python-class-attribute` | `var(--syn-property)` | `var(--syn-property)` |
| `--syn-python-constant` | `var(--syn-constant)` | `var(--syn-constant)` |
| `--syn-python-property` | `var(--syn-property)` | `var(--syn-property)` |
| `--syn-python-type-parameter` | `var(--syn-type)` | `var(--syn-type)` |
| `--syn-python-builtin` | `var(--syn-builtin)` | `var(--syn-builtin)` |
| `--syn-python-predefined-definition` | `var(--syn-predefined)` | `var(--syn-predefined)` |
| `--syn-python-predefined-usage` | `var(--syn-predefined)` | `var(--syn-predefined)` |
| `--syn-python-fstring` | `#a5c261` | `var(--syn-string)` |
| `--syn-python-bytes` | `#a5c261` | `var(--syn-string)` |
| `--syn-php-variable` | `var(--syn-property)` | `var(--syn-property)` |
| `--syn-php-function-call` | `var(--syn-function)` | `var(--syn-function)` |
| `--syn-builtin` | `#8888c6` | `#000080` |
| `--syn-self` | `#94558d` | `#94558d` |
| `--syn-keyword-arg` | `#aa4926` | `#660099` |
+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,18 @@
"--syn-constant": "#e58fa3",
"--syn-python-local-variable": "#d9dfe8",
"--syn-python-global-variable": "#d9dfe8",
"--syn-python-instance-attribute": "#b9a3f0",
"--syn-python-instance-attribute": "#d9dfe8",
"--syn-python-class-attribute": "#b9a3f0",
"--syn-python-constant": "#e58fa3",
"--syn-python-property": "#b9a3f0",
"--syn-python-type-parameter": "#8fd4c9",
"--syn-python-builtin": "#6fc3df",
"--syn-python-predefined-definition": "#c4a0f2",
"--syn-python-predefined-usage": "#c4a0f2",
"--syn-python-fstring": "#9ccc7a",
"--syn-python-bytes": "#9ccc7a",
"--syn-php-variable": "#b9a3f0",
"--syn-php-function-call": "#f2d07a",
"--syn-builtin": "#6fc3df",
"--syn-self": "#d6849c",
"--syn-keyword-arg": "#c4a0f2",
+2
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,
@@ -397,6 +398,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
tools::jdk_download,
sdk::sdk_detect,
sdk::sdk_python_effective,
sdk::sdk_php_effective,
sdk::sdk_php_language_level,
sdk::sdk_project_libraries,
sdk::sdk_locate_tools,
+7
View File
@@ -24,6 +24,13 @@ pub fn sdk_python_effective(window: Window, state: State<'_, AppState>) -> IdeRe
ide_host::ops::sdk::sdk_python_effective(&state.host, window.label())
}
/// The PHP interpreter chosen in Settings › Languages & Frameworks › PHP; `None`: the `php` on `PATH`.
#[tauri::command]
#[specta::specta]
pub fn sdk_php_effective(window: Window, state: State<'_, AppState>) -> IdeResult<Option<ide_core::IpcPath>> {
ide_host::ops::sdk::sdk_php_effective(&state.host, window.label())
}
/// The PHP language level of the project `path` belongs to: the effective one and Auto's.
#[tauri::command]
#[specta::specta]
+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]
+25 -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 })),
@@ -767,6 +772,8 @@ export const commands = {
sdkDetect: (kind: SdkKind) => typedError<Sdk[], IdeError>(__TAURI_INVOKE("sdk_detect", { kind })),
/** The interpreter the project uses: the configured one, else its virtualenv. */
sdkPythonEffective: () => typedError<string | null, IdeError>(__TAURI_INVOKE("sdk_python_effective")),
/** The PHP interpreter chosen in Settings › Languages & Frameworks › PHP; `None`: the `php` on `PATH`. */
sdkPhpEffective: () => typedError<string | null, IdeError>(__TAURI_INVOKE("sdk_php_effective")),
/** The PHP language level of the project `path` belongs to: the effective one and Auto's. */
sdkPhpLanguageLevel: (path: string) => typedError<PhpLanguageLevels, IdeError>(__TAURI_INVOKE("sdk_php_language_level", { path })),
/**
@@ -3484,12 +3491,14 @@ export type ExtensionCount = {
};
/** A formatter that runs outside the language servers, with its own settings group. */
export type ExternalFormatter = "prettier" | "oxfmt";
export type ExternalFormatter = "prettier" | "oxfmt" | "ruff";
/** An external formatter (Prettier, oxfmt) and the languages it formats. */
/** An external formatter (Prettier, oxfmt, Ruff) and the languages it formats. */
export type ExternalFormatterSettings = {
/** Empty path: project node_modules, managed installation, then PATH. */
/** Empty path: project node_modules (Prettier/oxfmt), managed installation, then PATH. */
path?: string,
/** Ruff only: directory containing its configuration, or a configuration file. */
configurationDirectory?: string,
languages?: string[],
/** Prefer this formatter even when a language server offers formatting. */
prefer?: boolean,
@@ -3576,6 +3585,18 @@ export type FileIdentifiers = {
occurrences: IdentifierOccurrence[],
};
/**
* Added and deleted lines of one file between two revisions (`git diff --numstat`).
* A rename keeps the counts of the content change; a pure rename is `0`/`0`.
*/
export type FileLineStats = {
/** Path after the change (the only path, when the file was not renamed). */
path: string,
oldPath: string | null,
added: number,
removed: number,
};
export type FileMatch = {
path: IpcPath,
/** Path relative to the project root, as displayed. */
@@ -4798,6 +4819,7 @@ export type LanguagesSettings = {
typescript?: TypeScriptSettings,
prettier?: ExternalFormatterSettings,
oxfmt?: ExternalFormatterSettings,
ruff?: ExternalFormatterSettings,
java?: JavaSettings,
ruby?: RubySettings,
markdown?: MarkdownSettings,
+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%",
+18
View File
@@ -262,6 +262,24 @@ describe("EditorController", () => {
useProject.setState({ lightEdit: false });
});
it("opens a file requested while the LightEdit session is being restored", async () => {
ctrl = new EditorController(createMemoryBackend({ "/x/a.txt": "a", "/y/b.md": "b", "/z/launch.lua": "l" }));
ctrl.start();
useProject.setState({ lightEdit: true });
await ctrl.openFile("/x/a.txt", { focus: false });
await ctrl.openFile("/y/b.md", { focus: false });
useProject.setState({ lightEdit: false });
// A launch with a file path: LightEdit starts and the file opens before the old tabs are back.
useProject.setState({ lightEdit: true });
await ctrl.openFile("/z/launch.lua", { focus: false });
expect(openFilePaths(useTabs.getState())).toEqual(["/x/a.txt", "/y/b.md", "/z/launch.lua"]);
const s = useTabs.getState();
const active = s.panes[s.activePane]!;
expect(active.tabs.find((t) => t.id === active.active)?.input.path).toBe("/z/launch.lua");
await ctrl.closeEverything();
useProject.setState({ lightEdit: false });
});
it("closes a tab by its input key", async () => {
ctrl = new EditorController(createMemoryBackend());
ctrl.start();
+9 -1
View File
@@ -237,6 +237,8 @@ export class EditorController implements SlotHost {
private selectionRequests = new Map<string, number>();
private activationGeneration = 0;
private admitting = false;
/** Tabs restored from a saved session: not a user's activation, so a pending open still activates its tab. */
private restoring = false;
private closeRequests = 0;
private projectTransition: Promise<void> = Promise.resolve();
@@ -269,6 +271,7 @@ export class EditorController implements SlotHost {
prevTabs = s;
if (
!this.admitting &&
!this.restoring &&
(s.activePane !== prev.activePane || s.panes[s.activePane]?.active !== prev.panes[prev.activePane]?.active)
)
++this.activationGeneration;
@@ -486,7 +489,12 @@ export class EditorController implements SlotHost {
pane.tabs = pane.tabs.map((tab) => (documentLifecycle(tab.input, kinds) ? { ...tab, preview: false } : tab));
this.restoredViews = new Map(Object.entries(session.files));
this.restoredTabs = new Set(Object.values(state.panes).flatMap((p) => p.tabs.map((t) => t.id)));
useTabs.setState(state, true);
this.restoring = true;
try {
useTabs.setState(state, true);
} finally {
this.restoring = false;
}
for (const pane of Object.values(state.panes)) if (pane.active) void this.ensureSlot(pane.active);
}
+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))",
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { filterColumns } from "./ColumnsPopup";
const columns = ["id", "user_id", "name", "created_at", "username"].map((name) => ({ name, type: "TEXT" }));
describe("filterColumns", () => {
it("lists every column for an empty query", () => {
expect(filterColumns(columns, " ")).toEqual([0, 1, 2, 3, 4]);
});
it("puts prefix matches first, then other matches, ignoring case", () => {
expect(filterColumns(columns, "US")).toEqual([1, 4]);
expect(filterColumns(columns, "name")).toEqual([2, 4]);
expect(filterColumns(columns, "_id")).toEqual([1]);
});
});
+92
View File
@@ -0,0 +1,92 @@
/**
* Go to Column in a table editor (⌘F12 / Ctrl+F12): the grid's columns, filtered as you type;
* Enter selects that column's cell in the current row.
*/
import { useState } from "react";
import { Icon, Input, List, Popup, listNavigate } from "@/ui";
import { tx } from "@/platform/i18n";
import type { GridColumn } from "./Grid";
import styles from "./Database.module.css";
export function filterColumns(columns: readonly GridColumn[], query: string): number[] {
const q = query.trim().toLowerCase();
const all = columns.map((_, i) => i);
if (!q) return all;
const starts = all.filter((i) => columns[i]!.name.toLowerCase().startsWith(q));
const contains = all.filter((i) => !starts.includes(i) && columns[i]!.name.toLowerCase().includes(q));
return [...starts, ...contains];
}
export function ColumnsPopup(props: {
anchor: { x: number; y: number };
table: string;
columns: readonly GridColumn[];
onPick: (col: number) => void;
onClose: () => void;
}) {
const [query, setQuery] = useState("");
const [active, setActive] = useState(0);
const shown = filterColumns(props.columns, query);
const current = Math.min(active, shown.length - 1);
const pick = (i: number) => {
const col = shown[i];
if (col !== undefined) props.onPick(col);
};
return (
<Popup
open
anchor={props.anchor}
placement="bottom"
onClose={props.onClose}
aria-label={tx`Columns of ${props.table}`}
className={styles.columnsPopup}
trapFocus
>
<Input
autoFocus
aria-label={tx("Search columns")}
placeholder={tx`Columns of ${props.table}`}
value={query}
onChange={(e) => {
setQuery(e.target.value);
setActive(0);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
pick(current);
return;
}
const next = listNavigate(e.key, { items: shown, active: current });
if (next !== null) {
e.preventDefault();
setActive(next);
}
}}
/>
<List
items={shown}
getKey={(col) => String(col)}
activeIndex={current}
onActiveChange={setActive}
onActivate={(_, i) => pick(i)}
focusable={false}
hoverSelects
rowHeight={22}
aria-label={tx("Columns")}
className={styles.columnsList}
empty={<span>{tx("No matching columns")}</span>}
renderItem={(col) => {
const c = props.columns[col]!;
return (
<span className={styles.columnsRow}>
<Icon name={c.primaryKey ? "db-column-key" : c.foreignKey ? "db-column-fk" : "db-column"} size={16} />
{c.name}
{c.type && c.type !== "NULL" && <span className={styles.treeType}>{c.type}</span>}
</span>
);
}}
/>
</Popup>
);
}
+2
View File
@@ -90,6 +90,7 @@ export interface DataViewProps extends Pick<
/** The search bar is shown (the toolbar's search button, ⌘F in the grid). */
searchOpen?: boolean;
onSearchOpenChange?: (open: boolean) => void;
revealColumn?: GridProps["revealColumn"];
"aria-label"?: string;
}
@@ -200,6 +201,7 @@ export function DataView(props: DataViewProps) {
isMatch={test ? (r, c) => matchSet.has(`${r}:${c}`) : undefined}
currentMatch={currentMatch}
onFind={onSearchOpenChange && openSearch}
revealColumn={props.revealColumn}
aria-label={props["aria-label"]}
columns={columns}
rowCount={shown}
+19
View File
@@ -823,6 +823,25 @@
font: inherit;
cursor: pointer;
}
/* Go to Column (ColumnsPopup.tsx). */
.columnsPopup {
display: flex;
flex-direction: column;
gap: 6px;
width: 320px;
padding: 8px;
}
.columnsList {
height: min(320px, 50vh);
}
.columnsRow {
display: flex;
flex: 1;
align-items: center;
gap: 6px;
min-width: 0;
white-space: nowrap;
}
.filterPopup {
display: flex;
flex-direction: column;
+25
View File
@@ -57,6 +57,8 @@ export interface GridProps {
currentMatch?: { row: number; col: number } | null;
/** ⌘F / Ctrl+F in the grid (opens the search bar). */
onFind?: () => void;
/** Go to Column: a new object selects that column's cell in the current row, scrolls to it and focuses the grid. */
revealColumn?: { col: number } | null;
"aria-label"?: string;
}
@@ -160,15 +162,37 @@ export function Grid(props: GridProps) {
estimateSize: () => ROW_H,
overscan: 12,
});
/** Scrolls sideways so column `c` is visible (the first one with the row numbers). */
const scrollColumnIntoView = (c: number) => {
const el = scrollRef.current;
if (!el || c < 0 || c >= widths.length) return;
const left = c ? NUM_W + widths.slice(0, c).reduce((a, b) => a + b, 0) : 0;
const right = NUM_W + widths.slice(0, c + 1).reduce((a, b) => a + b, 0);
if (left < el.scrollLeft) el.scrollLeft = left;
else if (right > el.scrollLeft + el.clientWidth) el.scrollLeft = Math.min(left, right - el.clientWidth);
};
const { currentMatch } = props;
useEffect(() => {
if (!currentMatch) return;
const { row, col } = currentMatch;
setSel({ r0: row, c0: col, r1: row, c1: col });
virtualizer.scrollToIndex(row, { align: "auto" });
scrollColumnIntoView(col);
// Follows the match only, not the virtualizer's identity.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentMatch?.row, currentMatch?.col]);
const { revealColumn } = props;
useEffect(() => {
if (!revealColumn || !rowCount) return;
const { col } = revealColumn;
const row = sel ? Math.min(sel.r1, rowCount - 1) : 0;
setSel({ r0: row, c0: col, r1: row, c1: col });
virtualizer.scrollToIndex(row, { align: "auto" });
scrollColumnIntoView(col);
scrollRef.current?.focus({ preventScroll: true });
// Runs once per request.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [revealColumn]);
const totalWidth = useMemo(() => NUM_W + widths.reduce((a, b) => a + b, 0), [widths]);
const template = `${NUM_W}px ${widths.map((w) => `${w}px`).join(" ")}`;
@@ -255,6 +279,7 @@ export function Grid(props: GridProps) {
const c = Math.max(0, Math.min(columns.length - 1, sel.c1 + m[1]));
setSel(e.shiftKey ? { ...sel, r1: r, c1: c } : { r0: r, c0: c, r1: r, c1: c });
virtualizer.scrollToIndex(r, { align: "auto" });
scrollColumnIntoView(c);
};
const onCellMouseDown = (e: MouseEvent, r: number, c: number) => {
+38 -2
View File
@@ -3,7 +3,7 @@
* a WHERE filter, and pending cell edits / added / deleted rows submitted
* together in one transaction. Tables without a primary key are read-only.
*/
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
call,
commands,
@@ -16,6 +16,7 @@ import {
import { Button, EmptyState, Icon, IconButton, Select, Spinner, T, showContextMenu } from "@/ui";
import { notifications, type EditorInput } from "@/workbench/contributions";
import { SqlFilterInput } from "./SqlFilterInput";
import { ColumnsPopup } from "./ColumnsPopup";
import { DataView, ExtractorSelect, PanelToggle, exportMenu, type DataPanel } from "./DataView";
import type { ExportFormat } from "./export";
import {
@@ -30,6 +31,7 @@ import {
import { openDialog, sourceById, withConnection } from "./store";
import type { TableRef } from "./tableInput";
import styles from "./Database.module.css";
import { registerTableEditor, tableEditorChanged, type TableEditorHandle } from "./tableEditorFocus";
import { tx } from "@/platform/i18n";
import { shortcutLabel } from "@/platform/keymap";
@@ -179,9 +181,30 @@ export function TableEditor({ input }: { input: EditorInput }) {
});
const dirty = hasEdits(edits);
// Submit / Revert actions (⌘↩ / ⌥⌘Z) reach the latest state through the handle.
const viewRef = useRef<HTMLDivElement>(null);
const handle = useRef<TableEditorHandle>({
dirty: false,
submit: () => {},
revert: () => {},
showColumns: () => {},
}).current;
handle.dirty = editable && dirty;
handle.submit = () => {
if (editable && dirty) void submit();
};
handle.revert = () => setEdits(emptyEdits());
const [columnsAt, setColumnsAt] = useState<{ x: number; y: number } | null>(null);
const [revealColumn, setRevealColumn] = useState<{ col: number } | null>(null);
handle.showColumns = () => {
const r = viewRef.current?.getBoundingClientRect();
if (page && r) setColumnsAt({ x: r.left + r.width / 2, y: r.top + 48 });
};
useEffect(() => registerTableEditor(viewRef.current!, handle), [handle]);
useEffect(() => tableEditorChanged(handle), [handle, dirty, editable]);
const orderInvalid = orderDraft !== null && !parseOrderBy(orderDraft, names);
return (
<div className={styles.view}>
<div className={styles.view} ref={viewRef} data-db-table-editor="">
<div className={styles.toolbar}>
<IconButton icon="refresh" title="Reload Page" onClick={reload} disabled={loading} />
<div className={styles.pager}>
@@ -328,6 +351,7 @@ export function TableEditor({ input }: { input: EditorInput }) {
columns={gridColumns}
rowCount={rowCount}
fixedRows={loaded}
revealColumn={revealColumn}
value={value}
panel={panel}
onPanelChange={setPanel}
@@ -355,6 +379,18 @@ export function TableEditor({ input }: { input: EditorInput }) {
/>
</div>
)}
{columnsAt && (
<ColumnsPopup
anchor={columnsAt}
table={ref.table}
columns={gridColumns}
onPick={(col) => {
setColumnsAt(null);
setRevealColumn({ col });
}}
onClose={() => setColumnsAt(null)}
/>
)}
</div>
);
}
+26
View File
@@ -7,6 +7,7 @@
import { lazyComponent } from "@/ui";
import { activeEditor, registerEditorContribution } from "@/editor/api";
import { registerActions } from "@/platform/actions";
import { activeTableEditor, installTableEditorFocus } from "./tableEditorFocus";
import { useProject } from "@/platform/project";
import {
registerEditorKind,
@@ -94,6 +95,7 @@ export function activate(): () => void {
}),
() => setFileDecorations("database", new Map()),
watchIdleConnections(),
installTableEditorFocus(),
registerDatabaseTreeActions(),
registerActions([
{
@@ -152,6 +154,30 @@ export function activate(): () => void {
await executeInView(e.view, e.ctx.path);
},
},
{
id: "db.goToColumn",
title: "Go to Column",
category: "Tools",
keywords: ["table editor", "file structure", "columns"],
when: "dbTableEditorFocus",
run: () => activeTableEditor()?.showColumns(),
},
{
id: "db.submitChanges",
title: "Submit",
category: "Tools",
keywords: ["table editor", "apply changes", "commit"],
when: "dbTableEditorFocus && dbTableEditorChanges",
run: () => activeTableEditor()?.submit(),
},
{
id: "db.revertChanges",
title: "Revert",
category: "Tools",
keywords: ["table editor", "discard changes", "undo"],
when: "dbTableEditorFocus && dbTableEditorChanges",
run: () => activeTableEditor()?.revert(),
},
{
id: "db.explainPlan",
title: "Explain Plan",
@@ -1,7 +1,9 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { DbDriver, DbTablePage } from "@/platform/ipc";
import { getContext } from "@/platform/context";
import { TableEditor } from "./TableEditor";
import { activeTableEditor, installTableEditorFocus } from "./tableEditorFocus";
import { tableInput } from "./tableInput";
const backend = vi.hoisted(() => ({
@@ -87,4 +89,40 @@ describe("table editor capabilities", () => {
expect(screen.getByRole("button", { name: "Add Row" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Submit" })).toBeTruthy();
});
it("offers Submit and Revert to the keyboard while the grid has focus and edits are pending (#1549)", async () => {
backend.driver = "postgres";
backend.applyEdits.mockResolvedValue({ status: "ok", data: 1 });
const uninstall = installTableEditorFocus();
try {
await act(async () => {
render(<TableEditor input={tableInput("source", "public", "users")} />);
});
const grid = screen.getByRole("grid");
act(() => grid.focus());
expect(getContext()).toMatchObject({ dbTableEditorFocus: true, dbTableEditorChanges: false });
fireEvent.doubleClick(await screen.findByRole("gridcell", { name: "Ann" }));
const input = screen.getByRole("textbox", { name: "Edit name" });
// A text field keeps ⌘↩ for its own edit.
expect(getContext().dbTableEditorFocus).toBe(false);
fireEvent.change(input, { target: { value: "Bob" } });
fireEvent.keyDown(input, { key: "Enter" });
act(() => grid.focus());
expect(getContext()).toMatchObject({ dbTableEditorFocus: true, dbTableEditorChanges: true });
act(() => activeTableEditor()!.revert());
expect(screen.getByRole("gridcell", { name: "Ann" })).toBeTruthy();
expect(getContext().dbTableEditorChanges).toBe(false);
fireEvent.doubleClick(screen.getByRole("gridcell", { name: "Ann" }));
fireEvent.change(screen.getByRole("textbox", { name: "Edit name" }), { target: { value: "Bob" } });
fireEvent.keyDown(screen.getByRole("textbox", { name: "Edit name" }), { key: "Enter" });
act(() => grid.focus());
await act(async () => activeTableEditor()!.submit());
expect(backend.applyEdits).toHaveBeenCalledTimes(1);
} finally {
uninstall();
}
});
});
+59
View File
@@ -0,0 +1,59 @@
/**
* The table editor that has focus, for Submit / Revert (⌘↩ / ⌥⌘Z) and Go to Column (⌘F12). Not
* while a text field has it: ⌘↩ there applies the field's own edit (the Value panel, the cell editor).
*/
import { setContext } from "@/platform/context";
export interface TableEditorHandle {
dirty: boolean;
submit(): void;
revert(): void;
showColumns(): void;
}
const handles = new WeakMap<Element, TableEditorHandle>();
let active: TableEditorHandle | null = null;
const isTextField = (el: Element) => !!el.closest("input, textarea, select, [contenteditable='true']");
function update() {
setContext("dbTableEditorFocus", !!active);
setContext("dbTableEditorChanges", !!active?.dirty);
}
function onFocusIn(event: FocusEvent) {
const target = event.target instanceof Element ? event.target : null;
const root = target?.closest("[data-db-table-editor]");
active = (root && !isTextField(target!) && handles.get(root)) || null;
update();
}
/** Registers the editor rooted at `root`; returns the function that unregisters it. */
export function registerTableEditor(root: Element, handle: TableEditorHandle): () => void {
handles.set(root, handle);
if (root.contains(document.activeElement) && !isTextField(document.activeElement!)) active = handle;
update();
return () => {
handles.delete(root);
if (active === handle) active = null;
update();
};
}
/** The handle's state changed (pending edits). */
export function tableEditorChanged(handle: TableEditorHandle) {
if (active === handle) update();
}
export function activeTableEditor(): TableEditorHandle | null {
return active;
}
export function installTableEditorFocus(): () => void {
document.addEventListener("focusin", onFocusIn);
return () => {
document.removeEventListener("focusin", onFocusIn);
active = null;
update();
};
}
@@ -84,7 +84,7 @@ class BreakpointMarker extends GutterMarker {
}
}
const bpLine = Decoration.line({ class: styles.bpLine });
const bpLine = Decoration.line({ class: `${styles.bpLine} cm-openidea-lineBg` });
const DISABLED_LOOKS = new Set<BreakpointLook>(["disabled", "muted", "methodDisabled", "fieldDisabled"]);
const setMarkers = StateEffect.define<RangeSet<BreakpointMarker>>();
@@ -126,11 +126,11 @@
/* Beat the caret row (`.cm-activeLine`), as IDEA does; the suspended line wins over a breakpoint. */
:global(.cm-editor) :global(.cm-line).bpLine {
background: var(--bg-breakpoint-line);
--openidea-line-bg: var(--bg-breakpoint-line);
}
:global(.cm-editor) :global(.cm-line).execLine {
background: var(--bg-debug-line);
--openidea-line-bg: var(--bg-debug-line);
}
.inlineValue {
+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 {
+100 -91
View File
@@ -365,111 +365,120 @@ export function DebugConsole({ session, interactive = true }: { session: DebugSe
};
return (
<div className={styles.console}>
{findOpen && (
<ConsoleFindBar
query={query}
onChange={changeQuery}
options={findOptions}
onOptionsChange={changeOptions}
count={found.matches.length}
active={activeIndex}
overflow={found.overflow}
invalid={found.invalid}
inputRef={findInput}
onNext={() => goToMatch(1)}
onPrevious={() => goToMatch(-1)}
onClose={closeFind}
/>
)}
<div className={styles.consoleArea}>
<div
ref={scroller}
className={clsx(styles.consoleOutput, ansi.palette, !softWrap && styles.noWrap)}
onScroll={(e) => {
if (programmatic.current) return;
const el = e.currentTarget;
const atEnd = el.scrollHeight - el.scrollTop - el.clientHeight < 24;
if (atEnd !== stick.current) setFollow(atEnd);
}}
onContextMenu={onContextMenu}
role="log"
onKeyDown={onOutputKeyDown}
aria-label={tx("Debug console")}
tabIndex={0}
className={styles.consoleToolbar}
role="toolbar"
aria-orientation="vertical"
aria-label={tx("Console actions")}
>
{!lines?.length && session.launch?.request === "attach" && (
<T>The attached process may not provide standard output to the debugger.</T>
)}
<div ref={inner} className={styles.consoleInner} style={{ height: virtualizer.getTotalSize() }}>
{virtualizer.getVirtualItems().map((item) => {
const l = lines?.[item.index];
if (!l) return null;
return (
<span
key={item.key}
data-index={item.index}
ref={virtualizer.measureElement}
className={clsx(styles.out, styles[`cat-${l.category}`])}
style={{ transform: `translateY(${item.start}px)` }}
>
{findSegments(l.spans, marksByLine.get(item.index) ?? [], current).map(segmentNode)}
</span>
);
})}
</div>
</div>
<div className={styles.repl}>
{interactive && (
<>
<span className={styles.prompt} aria-hidden>
›
</span>
<input
ref={inputRef}
className={styles.evalInput}
onFocus={() => setConsoleHistoryBrowser(browseHistory)}
onBlur={() => {
setConsoleHistoryBrowser(null);
completion.close();
}}
value={input}
onChange={(e) => {
setInput(e.target.value);
completion.onInput();
}}
onKeyDown={onKeyDown}
placeholder={isLive(session) ? "Evaluate in the debug console" : "Session finished"}
disabled={!isLive(session)}
aria-label={tx("Debug console input")}
spellCheck={false}
autoComplete="off"
/>
{completion.popup}
</>
)}
<IconButton
icon="search"
iconSize={14}
title="Find…"
shortcut={formatBinding(hostBinding("Meta+F"))}
pressed={findOpen}
onClick={() => (findOpen ? closeFind() : openFind())}
/>
<IconButton
icon="arrow-down"
iconSize={14}
title="Scroll to End"
size="small"
iconSize={12}
pressed={follow}
onClick={() => setFollow((v) => !v)}
/>
<IconButton
icon="soft-wrap"
iconSize={14}
title="Soft-Wrap"
size="small"
iconSize={12}
pressed={softWrap}
onClick={() => setWrapOverride(!softWrap)}
/>
<IconButton
icon="trash"
title="Clear Console"
size="small"
iconSize={12}
onClick={() => clearConsole(session.id)}
/>
<IconButton icon="trash" iconSize={14} title="Clear Console" onClick={() => clearConsole(session.id)} />
</div>
<div className={styles.console}>
{findOpen && (
<ConsoleFindBar
query={query}
onChange={changeQuery}
options={findOptions}
onOptionsChange={changeOptions}
count={found.matches.length}
active={activeIndex}
overflow={found.overflow}
invalid={found.invalid}
inputRef={findInput}
onNext={() => goToMatch(1)}
onPrevious={() => goToMatch(-1)}
onClose={closeFind}
/>
)}
<div
ref={scroller}
className={clsx(styles.consoleOutput, ansi.palette, !softWrap && styles.noWrap)}
onScroll={(e) => {
if (programmatic.current) return;
const el = e.currentTarget;
const atEnd = el.scrollHeight - el.scrollTop - el.clientHeight < 24;
if (atEnd !== stick.current) setFollow(atEnd);
}}
onContextMenu={onContextMenu}
role="log"
onKeyDown={onOutputKeyDown}
aria-label={tx("Debug console")}
tabIndex={0}
>
{!lines?.length && session.launch?.request === "attach" && (
<T>The attached process may not provide standard output to the debugger.</T>
)}
<div ref={inner} className={styles.consoleInner} style={{ height: virtualizer.getTotalSize() }}>
{virtualizer.getVirtualItems().map((item) => {
const l = lines?.[item.index];
if (!l) return null;
return (
<span
key={item.key}
data-index={item.index}
ref={virtualizer.measureElement}
className={clsx(styles.out, styles[`cat-${l.category}`])}
style={{ transform: `translateY(${item.start}px)` }}
>
{findSegments(l.spans, marksByLine.get(item.index) ?? [], current).map(segmentNode)}
</span>
);
})}
</div>
</div>
<div className={styles.repl}>
{interactive && (
<>
<span className={styles.prompt} aria-hidden>
›
</span>
<input
ref={inputRef}
className={styles.evalInput}
onFocus={() => setConsoleHistoryBrowser(browseHistory)}
onBlur={() => {
setConsoleHistoryBrowser(null);
completion.close();
}}
value={input}
onChange={(e) => {
setInput(e.target.value);
completion.onInput();
}}
onKeyDown={onKeyDown}
placeholder={isLive(session) ? "Evaluate in the debug console" : "Session finished"}
disabled={!isLive(session)}
aria-label={tx("Debug console input")}
spellCheck={false}
autoComplete="off"
/>
{completion.popup}
</>
)}
</div>
</div>
</div>
);
@@ -218,6 +218,23 @@
}
/* console */
/* The Console tab: actions in a vertical strip on the left, as in the Run window. */
.consoleArea {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
}
.consoleToolbar {
flex: none;
width: 32px;
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 4px 0;
border-right: 1px solid var(--border);
}
.console {
flex: 1;
min-width: 0;
@@ -0,0 +1,19 @@
import { act, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, it } from "vitest";
import { GithubDialogHost } from "../ui/dialogs";
import { remoteBranchNameDialog } from "./RemoteBranchDialog";
it("turns spaces typed in the remote branch name into separators", async () => {
const user = userEvent.setup();
render(<GithubDialogHost />);
let result: Promise<string | null> | undefined;
act(() => {
result = remoteBranchNameDialog("");
});
const input = await screen.findByRole("textbox", { name: "Remote branch name" });
await user.type(input, "my feature");
expect(input).toHaveValue("my-feature");
await user.click(screen.getByRole("button", { name: "OK" }));
await expect(result).resolves.toBe("my-feature");
});
@@ -1,6 +1,7 @@
/** "New Remote Branch for Pull Request": the name to push a local head without a push target to. */
import { useState } from "react";
import { Dialog, Input, T } from "@/ui";
import { cleanUpBranchNameOnTyping } from "@/platform/gitRefName";
import { Dialog, Input, T, useSanitizedField } from "@/ui";
import { openDialog } from "../ui/dialogs";
import styles from "./Create.module.css";
@@ -11,6 +12,7 @@ export function remoteBranchNameDialog(suggested: string): Promise<string | null
function RemoteBranchDialog(props: { suggested: string; onDone: (name: string | null) => void }) {
const [name, setName] = useState(props.suggested);
const { ref, clean } = useSanitizedField(cleanUpBranchNameOnTyping);
const valid = !!name.trim();
return (
<Dialog
@@ -33,7 +35,13 @@ function RemoteBranchDialog(props: { suggested: string; onDone: (name: string |
<label className={styles.dialogLabel}>
<T>Remote branch name:</T>
</label>
<Input data-autofocus aria-label="Remote branch name" value={name} onChange={(e) => setName(e.target.value)} />
<Input
ref={ref}
data-autofocus
aria-label="Remote branch name"
value={name}
onChange={(e) => setName(clean(e))}
/>
<div className={styles.dialogComment}>
<T>Your changes will be pushed to the remote repository to create the pull request</T>
</div>
+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(),
+18 -4
View File
@@ -664,10 +664,24 @@ describe("Show Members", () => {
});
});
it("renders workspace member roots with their own paths, without the settings directory", async () => {
fs = new MemoryFs({ "/z/first.txt": "", "/a/second.txt": "", "/workspace/.openidea/workspace.json": "{}" });
it("sorts workspace member roots by the folder names shown in the tree", async () => {
fs = new MemoryFs({
"/a/service10/ten.txt": "",
"/z/Service2/two.txt": "",
"/m/alpha/first.txt": "",
"/workspace/.openidea/workspace.json": "{}",
});
setProjectFsForTests(fs, "/workspace");
initProjectTree("/workspace", "Workspace", ["/z", "/a"]);
initProjectTree("/workspace", "Workspace", ["/a/service10", "/z/Service2", "/m/alpha"]);
const { rows } = setup();
await waitFor(() => expect(rows()).toEqual(["z/z", "first.txt", "a/a", "second.txt"]));
await waitFor(() =>
expect(rows()).toEqual([
"alpha/m/alpha",
"first.txt",
"Service2/z/Service2",
"two.txt",
"service10/a/service10",
"ten.txt",
]),
);
});
+5 -1
View File
@@ -100,6 +100,10 @@ export function isVisible(entry: FileEntry, opts: TreeOptions): boolean {
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
export function compareNames(a: string, b: string): number {
return collator.compare(a, b) || (a < b ? -1 : a > b ? 1 : 0);
}
/** Symlinks report the target's metadata; folders have size 0 (the entry has no target kind). */
export const isDirEntry = (e: FileEntry) => e.kind === "dir" || (e.kind === "symlink" && e.size === 0);
@@ -125,7 +129,7 @@ export function sortEntries(
const m = (b.mtimeMs ?? 0) - (a.mtimeMs ?? 0);
if (m) return m;
}
return collator.compare(a.name, b.name) || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
return compareNames(a.name, b.name);
});
}
+3 -2
View File
@@ -104,12 +104,13 @@ describe("creating packages", () => {
});
});
it("uses only manifest members in order in workspace Project and Project Files views", () => {
it("shows manifest members in name order in workspace Project and Project Files views", () => {
for (const mode of ["project", "projectFiles"] as const) {
initProjectTree("/workspace", "Workspace", ["/z", "/a"]);
useProjectTree.setState({ mode });
const model = selectModel(useProjectTree.getState());
expect(model.roots.map((r) => r.path)).toEqual(["/z", "/a"]);
expect(model.roots.map((r) => r.path)).toEqual(["/a", "/z"]);
expect(useProjectTree.getState().members).toEqual(["/z", "/a"]);
expect(model.byKey.has("/workspace")).toBe(false);
initProjectTree("/workspace", "Workspace", ["/a"]);
expect(selectModel(useProjectTree.getState()).roots.map((r) => r.path)).toEqual(["/a"]);
+5 -7
View File
@@ -14,6 +14,7 @@ import {
GROUP_PREFIX,
buildModel,
buildPathsModel,
compareNames,
extraFolders,
isGroupKey,
isLibKey,
@@ -34,7 +35,7 @@ import {
} from "./model";
import { projectFs } from "./fs";
import { projectViewRoots, useProjectViewRoots, type ProjectViewRootContribution } from "@/workbench/contributions";
import { ancestorsWithin, isSameOrInside, type PackageKind } from "./paths";
import { ancestorsWithin, isSameOrInside, nameOf, type PackageKind } from "./paths";
export interface ViewOptions extends TreeOptions {
/** Select the active editor's file in the tree. */
@@ -218,12 +219,9 @@ export function selectModel(s: ProjectTreeState): TreeModel {
const extras = useProjectViewRoots.getState().roots;
const options = treeOptions(s);
const contributed = projectViewRoots(extras);
const memberRoots = s.members?.map((path) => ({
path,
label: path.slice(path.lastIndexOf("/") + 1),
icon: "",
detail: path,
}));
const memberRoots = s.members
?.map((path) => ({ path, label: nameOf(path), icon: "", detail: path }))
.sort((a, b) => compareNames(a.label, b.label));
const project = () =>
memberRoots
? withExtraRoots(EMPTY_MODEL, memberRoots, s.listings, options, placeholder)
@@ -50,6 +50,7 @@ vi.mock("@/platform/javaLaunch", async (original) => ({
vi.mock("./buildUpdate", () => ({ runUpdateBuild: mocks.runBuild }));
vi.mock("./tomcat", () => ({ buildTomcatArtifact: mocks.build, syncTomcatOutputs: mocks.sync }));
vi.mock("./store", () => ({
rerunConfig: (s: Pick<RunSession, "config">) => mocks.state.configs.find((c) => c.id === s.config.id) ?? s.config,
startRun: mocks.start,
stopSession: mocks.stop,
waitForExit: mocks.wait,
@@ -105,6 +106,16 @@ describe("Tomcat application updates", () => {
expect(mocks.wait.mock.invocationCallOrder[0]).toBeLessThan(mocks.action.mock.invocationCallOrder[0]!);
expect(mocks.action).toHaveBeenCalledWith("run.debugConfig", session.config);
});
it("restarts with the configuration as edited since the server started", async () => {
const edited = { ...session.config, name: "Tomcat (edited)" };
mocks.state.configs = [edited];
try {
await updateTomcat(session, "restart");
expect(mocks.action).toHaveBeenCalledWith("run.debugConfig", edited);
} finally {
mocks.state.configs = [];
}
});
it("redeploys when the debugger cannot perform HotSwap", async () => {
mocks.action.mockResolvedValue(false);
await updateTomcat(session, "classes");
+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: [] });
});
});
+7 -1
View File
@@ -98,7 +98,13 @@ export async function produceResolved(input: ContextInput, dir = false): Promise
// Project configurations (tests, cargo, gradle…) need no runtime lookup.
if (named.config && !named.config.id.startsWith("context:file:")) return named;
const lang = scriptLanguage(input.path, input.content);
const env = await toolEnv(neededTools(fresh), input.path, fresh.root, lang === "python" || lang === "shebang");
const env = await toolEnv(
neededTools(fresh),
input.path,
fresh.root,
lang === "python" || lang === "shebang",
lang === "php" || lang === "shebang",
);
return produceContext(fresh, env);
}
+9
View File
@@ -226,6 +226,15 @@ describe("single-file producers (scratches)", () => {
it("Ruby, PHP, Perl, Lua, R, Swift, Dart, Elixir, Scala CLI, Kotlin script", () => {
expect(run(scratch("a.rb"))).toMatchObject({ command: "/bin/ruby", args: ["a.rb"] });
expect(run(scratch("a.php"))).toMatchObject({ command: "/bin/php", args: ["a.php"] });
// The PHP chosen in Settings wins over the PATH one, also with no PHP on PATH (#1555).
const php81 = "/opt/homebrew/opt/[email protected]/bin/php";
expect(run(scratch("a.php"), { ...ALL, php: php81 })).toMatchObject({ command: php81, args: ["a.php"] });
expect(run(scratch("a.php"), env({}, { php: php81 }))).toMatchObject({ command: php81, args: ["a.php"] });
expect(run(scratch("tool", "#!/usr/bin/env php\n<?php echo 1;"), { ...ALL, php: php81 })).toMatchObject({
command: php81,
args: ["tool"],
});
expect(produceContext(scratch("a.php"), env({}))!.missing!.body).toMatch(/Settings › Languages & Frameworks › PHP/);
expect(run(scratch("a.pl"))).toMatchObject({ command: "/bin/perl" });
expect(run(scratch("a.lua"))).toMatchObject({ command: "/bin/lua" });
expect(run(scratch("a.R"))).toMatchObject({ command: "/bin/Rscript", args: ["a.R"] });
+7 -1
View File
@@ -38,6 +38,8 @@ export interface ToolEnv {
windows?: boolean;
/** The project interpreter from settings (Python). */
python?: string | null;
/** The PHP interpreter chosen in settings (else the `php` on `PATH`). */
php?: string | null;
/** The project root has `deno.json(c)` / `bun.lock(b)`. */
deno?: boolean;
bun?: boolean;
@@ -288,7 +290,7 @@ const INSTALL: Record<string, string> = {
js: "Install Node.js from https://nodejs.org (or `brew install node`).",
python: "Install Python (`brew install python`) or choose an interpreter in Settings › Python Interpreter.",
ruby: "Install Ruby (`brew install ruby`).",
php: "Install PHP (`brew install php`).",
php: "Install PHP (`brew install php`) or choose an interpreter in Settings › Languages & Frameworks › PHP.",
perl: "Install Perl (`brew install perl`).",
lua: "Install Lua (`brew install lua`).",
r: "Install R (`brew install r`) so that `Rscript` is on the PATH.",
@@ -762,6 +764,8 @@ export function produceFile(input: ContextInput, env: ToolEnv): Produced | null
config: fileConfig(s, "python", py.command, [...py.prefix, s.arg], "debugpy"),
};
}
if (interp === "php" && env.php)
return { name: s.file, scope: "file", config: fileConfig(s, "php", env.php, [...sb.slice(1), s.arg]) };
const t = sb[0]!.startsWith("/") ? { command: sb[0]!, name: interp } : pick(env, [interp]);
if (!t) return { name: s.file, scope: "file", missing: missing(lang, s.file, [interp]) };
const kind = /^(ba|z|k|da|fi)?sh$/.test(interp) ? "shell" : interp === "node" ? "node" : interp;
@@ -805,6 +809,8 @@ export function produceFile(input: ContextInput, env: ToolEnv): Produced | null
// Sources roots go on the load path (`-Ilib`), as `rake test` does; paths are project-relative.
return simple(lang, s, env, lang, () => [...(s.inProject ? rubyLoadPath(currentRoots()) : []), s.arg]);
case "php":
if (env.php) return { name: s.file, scope: "file", config: fileConfig(s, "php", env.php, [s.arg]) };
return simple(lang, s, env, lang, () => [s.arg]);
case "perl":
case "lua":
case "swift":
+12 -1
View File
@@ -86,6 +86,14 @@ async function projectPython(): Promise<string | null> {
}
}
async function projectPhp(): Promise<string | null> {
try {
return await call(commands.sdkPhpEffective());
} catch {
return null;
}
}
async function rootMarkers(root: string | null): Promise<{ deno: boolean; bun: boolean }> {
if (!root) return { deno: false, bun: false };
try {
@@ -106,10 +114,12 @@ export async function toolEnv(
file: string | null,
root: string | null,
python: boolean,
php = false,
): Promise<ToolEnv> {
const [tools, py, markers] = await Promise.all([
const [tools, py, phpInterpreter, markers] = await Promise.all([
locateTools(names, file),
python ? projectPython() : Promise.resolve(null),
php ? projectPhp() : Promise.resolve(null),
names.includes("deno") || names.includes("bun") ? rootMarkers(root) : Promise.resolve({ deno: false, bun: false }),
]);
return {
@@ -117,6 +127,7 @@ export async function toolEnv(
compileDir: compileDir ?? "/tmp/openidea-run",
windows: isWindows(),
python: py,
php: phpInterpreter,
...markers,
};
}
+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;
@@ -78,7 +78,9 @@ describe("languages settings", () => {
}
expect(PHP_LANGUAGE_LEVELS[0]).toBe("");
expect(PHP_LANGUAGE_LEVELS).toContain("5.6");
expect(PHP_LANGUAGE_LEVELS.at(-1)).toBe("8.5");
// Newest first after Auto (#1557).
expect(PHP_LANGUAGE_LEVELS[1]).toBe("8.5");
expect(PHP_LANGUAGE_LEVELS.at(-1)).toBe("5.6");
});
it("finds the PHP page by its language level", () => {
@@ -92,7 +94,13 @@ describe("languages settings", () => {
validateLanguages({
prettier: { path: " ./bin/prettier ", languages: ["markdown", 4, "markdown"], prefer: true, runOnSave: true },
}).prettier,
).toEqual({ path: "./bin/prettier", languages: ["markdown"], prefer: true, runOnSave: true });
).toEqual({
path: "./bin/prettier",
configurationDirectory: "",
languages: ["markdown"],
prefer: true,
runOnSave: true,
});
expect(validateLanguages({ prettier: { languages: [] } }).prettier.languages).toEqual([]);
});
@@ -102,10 +110,43 @@ describe("languages settings", () => {
prettier: { runOnSave: true },
oxfmt: { path: " ./bin/oxfmt ", languages: ["typescript", null, "typescript"], prefer: true },
});
expect(v.oxfmt).toEqual({ path: "./bin/oxfmt", languages: ["typescript"], prefer: true, runOnSave: false });
expect(v.oxfmt).toEqual({
path: "./bin/oxfmt",
configurationDirectory: "",
languages: ["typescript"],
prefer: true,
runOnSave: false,
});
expect(v.prettier).toMatchObject({ runOnSave: true, prefer: false, path: "" });
});
it("enables Ruff for Python by default and keeps its settings separate", () => {
expect(validateLanguages({}).ruff).toMatchObject({
path: "",
configurationDirectory: "",
languages: ["python"],
prefer: false,
runOnSave: false,
});
const v = validateLanguages({
ruff: {
path: " ./bin/ruff ",
configurationDirectory: " ./config/ruff ",
languages: ["python", null, "python", "pyi"],
prefer: true,
runOnSave: true,
},
});
expect(v.ruff).toEqual({
path: "./bin/ruff",
configurationDirectory: "./config/ruff",
languages: ["python", "pyi"],
prefer: true,
runOnSave: true,
});
expect(v.prettier).toEqual(DEFAULT_LANGUAGES.prettier);
});
it("leaves the Markdown preview font size unset (editor font) and keeps a set one in 8..72", () => {
expect(validateLanguages({}).markdown).toEqual({ previewFontSize: null });
expect(validateLanguages({ markdown: { previewFontSize: 90 } }).markdown).toEqual({ previewFontSize: 72 });
@@ -143,6 +184,7 @@ describe("language pages", () => {
"PHP",
"Prettier",
"oxfmt",
"Ruff",
"Ruby",
"Rust",
"TypeScript",
@@ -1,9 +1,11 @@
import { useId } from "react";
import type { ExternalFormatter } from "@/bindings";
import { Input } from "@/ui";
import { IconButton, Input } from "@/ui";
import { Row, Section, Select, Toggle } from "../components/Form";
import { useSetting } from "../components/context";
import { DEFAULT_LANGUAGES } from "@/platform/languageSettings";
import { choosePath } from "./api";
import styles from "./Sdk.module.css";
const names: Record<string, string> = {
markdown: "Markdown",
@@ -20,17 +22,28 @@ const names: Record<string, string> = {
json: "JSON",
jsonc: "JSON with Comments",
vue: "Vue",
python: "Python",
};
interface FormatterPageProps {
tool: ExternalFormatter;
name: string;
pathLabel: string;
pathHint?: string;
configurationDirectory?: boolean;
providerHint?: string;
saveHint: string;
}
function FormatterPage({ tool, name, pathLabel, providerHint, saveHint }: FormatterPageProps) {
function FormatterPage({
tool,
name,
pathLabel,
pathHint = "Auto: nearest project node_modules, managed installation, then PATH. An explicit path overrides auto detection.",
configurationDirectory = false,
providerHint,
saveHint,
}: FormatterPageProps) {
const id = useId();
const [path, setPath] = useSetting<string>(`languages.${tool}.path`);
const [languages, setLanguages] = useSetting<string[]>(`languages.${tool}.languages`);
@@ -39,13 +52,10 @@ function FormatterPage({ tool, name, pathLabel, providerHint, saveHint }: Format
return (
<>
<Section title={name}>
<Row
label={pathLabel}
htmlFor={id}
hint="Auto: nearest project node_modules, managed installation, then PATH. An explicit path overrides auto detection."
>
<Row label={pathLabel} htmlFor={id} hint={pathHint}>
<Input id={id} mono value={path} placeholder="Auto" onChange={(e) => setPath(e.target.value)} />
</Row>
{configurationDirectory && <ConfigurationDirectory settingKey={`languages.${tool}.configurationDirectory`} />}
<Row label="Formatting provider" hint={providerHint}>
<Select
value={prefer ? tool : "auto"}
@@ -75,6 +85,43 @@ function FormatterPage({ tool, name, pathLabel, providerHint, saveHint }: Format
);
}
function ConfigurationDirectory({ settingKey }: { settingKey: string }) {
const id = useId();
const [path, setPath] = useSetting<string>(settingKey);
return (
<Row
label="Configuration directory"
htmlFor={id}
hint="Empty uses the nearest project pyproject.toml ([tool.ruff]), ruff.toml, or .ruff.toml. A configured directory must contain one of them."
>
<span className={styles.pathRow}>
<Input
id={id}
mono
className={styles.pathInput}
value={path}
placeholder="Project configuration"
onChange={(e) => setPath(e.target.value)}
/>
<IconButton
icon="folder-open"
size="small"
title="Browse…"
aria-label="Select Ruff Configuration Directory"
onClick={async () => {
const picked = await choosePath({
directory: true,
title: "Select Ruff Configuration Directory",
defaultPath: path || undefined,
});
if (picked) setPath(picked);
}}
/>
</span>
</Row>
);
}
export function PrettierPage() {
return (
<FormatterPage
@@ -97,3 +144,17 @@ export function OxfmtPage() {
/>
);
}
export function RuffPage() {
return (
<FormatterPage
tool="ruff"
name="Ruff"
pathLabel="Ruff path"
pathHint="Auto: managed installation, then PATH. An explicit path overrides auto detection."
configurationDirectory
providerHint="Ruff reads the nearest pyproject.toml ([tool.ruff]), ruff.toml, or .ruff.toml. Use it before the language server when preferred."
saveHint="Run Ruff on save for enabled languages when it is the selected formatting provider."
/>
);
}
@@ -3,7 +3,8 @@
* Build Tools › Maven: the toolchain each language's server and run configurations
* use (applied by `ide-sdk` when language servers start and when run configurations load).
*/
import { useId, useMemo } from "react";
import { useEffect, useId, useMemo, useState } from "react";
import { call, commands } from "@/platform/ipc";
import { IconButton, Input, type SelectOption } from "@/ui";
import { PHP_LANGUAGE_LEVELS } from "@/platform/languageSettings";
import { GlobListEditor, Row, Section, Select, Toggle } from "../components/Form";
@@ -117,13 +118,31 @@ const PHP_LEVEL_OPTIONS: SelectOption[] = PHP_LANGUAGE_LEVELS.map((level) =>
level ? { value: level, label: level } : { value: "", label: "Auto", detail: "from composer.json" },
);
/** The `php` on `PATH` (what the Default interpreter runs), looked up while `enabled`. */
function usePathPhp(enabled: boolean): string | null {
const [path, setPath] = useState<string | null>(null);
useEffect(() => {
if (!enabled) return;
let live = true;
call(commands.sdkLocateTools(["php"], null))
.then((r) => live && setPath(r.tools[0]?.path ?? null))
.catch(() => {});
return () => {
live = false;
};
}, [enabled]);
return path;
}
export function PhpPage() {
const [interpreter] = useSetting<string>("languages.php.interpreter");
const [level, setLevel] = useSetting<string>("languages.php.languageLevel");
const [includePaths, setIncludePaths] = useSetting<string[]>("languages.php.includePaths");
const [stubs, setStubs] = useSetting<string[]>("languages.php.stubs");
const { list } = useDetected("php");
const selected = list.find((s) => s.path === interpreter) ?? (interpreter ? undefined : list[0]);
const pathPhp = usePathPhp(!interpreter);
// Default runs the `php` on PATH: the list is newest first, so it isn't necessarily `list[0]`.
const selected = list.find((s) => s.path === (interpreter || pathPhp));
return (
<div className={styles.page}>
<Section>
+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: [
+17 -17
View File
@@ -48,7 +48,7 @@ import {
toggledCheck,
type ChangeNode,
} from "./changes";
import { applyStash, commitChanges, deleteUnversioned, dropStash, showUnstashDialog, stashChanges } from "./changeOps";
import { applyStash, commitChanges, deleteFiles, dropStash, showUnstashDialog, stashChanges } from "./changeOps";
import { CommitOptionsPopup } from "./CommitOptions";
import { DEFAULT_LIST_NAME, useChangelists, withListDefaults } from "./changelists";
import { moveToChangelist } from "./changelistsUi";
@@ -226,14 +226,16 @@ export function listMenu(id: string, active: boolean, files: readonly FileStatus
}
/** Paths Delete… applies to: only when every selected file is unversioned (versioned ones are rolled back). */
export function deletableUnversioned(nodes: ChangeNode[]): string[] {
/** The selected files that still exist in the working tree (Delete… removes them from disk). */
export function deletableFiles(nodes: ChangeNode[]): string[] {
const files = filesOf(nodes);
return files.length && files.every((f) => f.unstaged === "untracked") ? files.map((f) => f.path) : [];
const onDisk = (f: FileStatus) => f.unstaged !== "deleted" && !(f.staged === "deleted" && !f.unstaged);
return files.length && files.every(onDisk) ? files.map((f) => f.path) : [];
}
export function nodeMenu(nodes: ChangeNode[], repos: readonly string[]): MenuItem[] {
const files = filesOf(nodes);
const deletable = deletableUnversioned(nodes);
const deletable = deletableFiles(nodes);
const paths = files.map((f) => f.path);
const first = nodes.find((n) => n.type === "file");
const untracked = files.filter((f) => f.unstaged === "untracked");
@@ -254,18 +256,16 @@ export function nodeMenu(nodes: ChangeNode[], repos: readonly string[]): MenuIte
items.push(
{ kind: "action", action: "vcs.rollback", arg: paths, label: "Rollback…" },
...(untracked.length
? [{ kind: "action" as const, action: "vcs.addToVcs", arg: untracked.map((f) => f.path) }]
: []),
...(deletable.length
? [
{ kind: "action" as const, action: "vcs.addToVcs", arg: untracked.map((f) => f.path) },
...(deletable.length
? [
{
label: "Delete…",
icon: "trash",
shortcut: formatBinding(isMacHost() ? "Backspace" : "Delete"),
run: () => void deleteUnversioned(deletable),
},
]
: []),
{
label: "Delete…",
icon: "trash",
shortcut: formatBinding(isMacHost() ? "Backspace" : "Delete"),
run: () => void deleteFiles(deletable),
},
]
: []),
...(toIgnore.length
@@ -534,7 +534,7 @@ export function ChangesTree({ mode }: { mode: "commit" | "local" | "dialog" }) {
} else if ((e.key === "Delete" || e.key === "Backspace") && !e.metaKey) {
const nodes = selectedNodes();
const group = nodes.find((x) => x.type === "group" && x.changelist);
const deletable = group ? [] : deletableUnversioned(nodes);
const deletable = group ? [] : deletableFiles(nodes);
if (group?.changelist) {
e.preventDefault();
e.stopPropagation();
@@ -542,7 +542,7 @@ export function ChangesTree({ mode }: { mode: "commit" | "local" | "dialog" }) {
} else if (deletable.length) {
e.preventDefault();
e.stopPropagation();
void deleteUnversioned(deletable);
void deleteFiles(deletable);
}
} else if (e.key === "F4") {
const n = selectedNodes().find((x) => x.type === "file");
+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();
});
@@ -13,11 +13,11 @@ vi.mock("../dialogs", async (original) => ({
confirmDialog: mocks.confirm,
}));
const { deletableUnversioned, nodeMenu } = await import("../CommitView");
const { deleteUnversioned } = await import("../changeOps");
const { deletableFiles, nodeMenu } = await import("../CommitView");
const { deleteFiles } = await import("../changeOps");
const file = (rel: string, unstaged: FileStatus["unstaged"]): ChangeNode => {
const f: FileStatus = { path: `/p/${rel}`, relPath: rel, staged: null, unstaged, renamedFrom: null };
const file = (rel: string, unstaged: FileStatus["unstaged"], staged: FileStatus["staged"] = null): ChangeNode => {
const f: FileStatus = { path: `/p/${rel}`, relPath: rel, staged, unstaged, renamedFrom: null };
return { key: f.path, type: "file", label: rel, group: "unversioned", files: [f], path: f.path };
};
const deleteItem = (menu: MenuItem[]) =>
@@ -29,30 +29,30 @@ beforeEach(() => {
mocks.call.mockResolvedValue(null);
});
describe("Delete… for unversioned files", () => {
it("is offered only when every selected file is unversioned", () => {
describe("Delete… in the Changes view", () => {
it("is offered when every selected file is still on disk", () => {
expect(deleteItem(nodeMenu([file("a.txt", "untracked"), file("b.txt", "untracked")], ["/p"]))).toBeDefined();
expect(deleteItem(nodeMenu([file("a.txt", "untracked"), file("c.txt", "modified")], ["/p"]))).toBeUndefined();
expect(deleteItem(nodeMenu([file("c.txt", "modified")], ["/p"]))).toBeUndefined();
expect(deleteItem(nodeMenu([file("a.txt", "untracked"), file("c.txt", "modified")], ["/p"]))).toBeDefined();
expect(deleteItem(nodeMenu([file("c.txt", "modified")], ["/p"]))).toBeDefined();
expect(deleteItem(nodeMenu([file("d.txt", null, "added")], ["/p"]))).toBeDefined();
expect(deleteItem(nodeMenu([file("c.txt", "modified"), file("e.txt", "deleted")], ["/p"]))).toBeUndefined();
expect(deleteItem(nodeMenu([file("f.txt", null, "deleted")], ["/p"]))).toBeUndefined();
});
it("shows the Delete key and gives the key handler the same paths", () => {
expect(deleteItem(nodeMenu([file("a.txt", "untracked")], ["/p"]))).toMatchObject({ shortcut: "⌫" });
expect(deletableUnversioned([file("a.txt", "untracked"), file("b.txt", "untracked")])).toEqual([
"/p/a.txt",
"/p/b.txt",
]);
expect(deletableUnversioned([file("a.txt", "untracked"), file("c.txt", "modified")])).toEqual([]);
expect(deletableUnversioned([])).toEqual([]);
expect(deletableFiles([file("a.txt", "untracked"), file("c.txt", "modified")])).toEqual(["/p/a.txt", "/p/c.txt"]);
expect(deletableFiles([file("a.txt", "untracked"), file("e.txt", "deleted")])).toEqual([]);
expect(deletableFiles([])).toEqual([]);
});
it("asks first, then deletes each file", async () => {
mocks.confirm.mockResolvedValueOnce(false);
await deleteUnversioned(["/p/a.txt"]);
await deleteFiles(["/p/a.txt"]);
expect(mocks.fsDelete).not.toHaveBeenCalled();
mocks.confirm.mockResolvedValueOnce(true);
await deleteUnversioned(["/p/a.txt", "/p/b.txt"]);
await deleteFiles(["/p/a.txt", "/p/b.txt"]);
expect(mocks.fsDelete.mock.calls.map((c) => c[0])).toEqual(["/p/a.txt", "/p/b.txt"]);
});
});
+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);
});
});
Loaded 100 of 132 files, more files were not shown because too many files have changed in this diff. Show more