Compare commits

...
Author SHA1 Message Date
arookieofc fabce49e61 fix(java): re-stamp mtime-preserving external edits before launch builds
Launch fingerprints detect external edits via content hash, but the Maven
rebuild that triggers is timestamp-incremental: sources whose mtime did not
advance are skipped, so the rebuild compiles nothing and the stale snapshot
is re-recorded as fresh. After an IDE restart the launch then reuses those
stale classes (NoClassDefFoundError).

Session::build now diffs the stored fingerprint against the fresh one,
re-stamps (bumps mtime on) sources whose content changed while
(length, mtime) stayed the same, and re-scans before building, so the
rebuild actually recompiles them and the record written afterwards matches.

Related to #2476
2026-11-10 20:43:00 +08:00
arookieofc c49cd874af test(themes): palette themes inherit selection foreground from base
Solarized/One keep the base default (preserve syntax colours on
selection); record that intent in INHERITED_FROM_BASE.
2026-11-10 18:03:05 +08:00
arookieofc c82e5da0d3 feat(scheme): support a selection foreground colour (#2446)
Settings › Editor › Color Scheme › General › Editor gains a
Selection foreground attribute next to Selection background, wired
to a new --fg-editor-selection token. The editor paints selected
text with it (.cm-openidea-selected, including the diff viewer);
unset it keeps the previous rendering, with syntax colours showing
through (token default: inherit). .icls import/export round trips
it via SELECTION_FOREGROUND.
2026-11-10 18:03:05 +08:00
14 changed files with 225 additions and 6 deletions

No files matched your search

@@ -184,6 +184,30 @@ pub fn launch_fingerprint(
Ok(Some(LaunchFingerprint { version: 2, parameters, files }))
}
impl LaunchFingerprint {
/// Relative paths whose content changed while `(length, mtime)` stayed the same
/// compared to `previous` (timestamp-preserving external edits, coarse-granularity
/// filesystems). Timestamp-incremental builders (Maven, the Kotlin plugin) would
/// skip these sources as "unchanged", so the rebuild the fingerprint triggers
/// compiles nothing and the stale outputs get re-recorded as fresh. Callers
/// re-stamp the returned paths before building. Inputs without a content hash
/// (directories, files outside the project) are never reported.
pub fn mtime_preserved_edits(&self, current: &Self) -> Vec<PathBuf> {
let mut edits = Vec::new();
for (path, current_stamp) in &current.files {
let Some(current_stamp) = current_stamp else { continue };
let Some(previous_stamp) = self.files.get(path).and_then(|stamp| stamp.as_ref()) else { continue };
let (len, mtime, Some(current_hash)) = current_stamp else { continue };
let (prev_len, prev_mtime, Some(prev_hash)) = previous_stamp else { continue };
if current_hash != prev_hash && len == prev_len && mtime == prev_mtime {
edits.push(path.clone());
}
}
edits.sort();
edits
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -282,6 +306,60 @@ mod tests {
assert_ne!(before, after, "a same-length, same-mtime content change must invalidate the fingerprint");
}
#[test]
fn mtime_preserved_edits_only_reports_lying_timestamps() {
fn stamp(len: u64, hash: &str) -> Option<Stamp> {
Some((len, SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(42), Some(hash.into())))
}
fn prints(
previous: BTreeMap<PathBuf, Option<Stamp>>,
current: BTreeMap<PathBuf, Option<Stamp>>,
) -> Vec<PathBuf> {
let previous = LaunchFingerprint { version: 2, parameters: String::new(), files: previous };
let current = LaunchFingerprint { version: 2, parameters: String::new(), files: current };
previous.mtime_preserved_edits(&current)
}
// Same length and mtime, different content: the builder would skip it.
assert_eq!(
prints([("a.java".into(), stamp(5, "old"))].into(), [("a.java".into(), stamp(5, "new"))].into()),
vec![PathBuf::from("a.java")]
);
// A normal edit bumps the mtime: the builder already sees it.
let mut touched = stamp(5, "new").unwrap();
touched.1 += std::time::Duration::from_secs(1);
assert!(
prints([("a.java".into(), stamp(5, "old"))].into(), [("a.java".into(), Some(touched))].into()).is_empty()
);
// Added, deleted, and hash-less inputs (directories, files outside the
// project) carry no comparable content: never reported.
assert!(
prints(
[("gone.java".into(), stamp(5, "old")), ("dir".into(), None)].into(),
[("new.java".into(), stamp(5, "new")), ("dir".into(), None)].into()
)
.is_empty()
);
}
#[test]
fn lying_source_is_found_against_a_real_fingerprint() {
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).unwrap();
let mtime = fs::metadata(&source).unwrap().modified().unwrap();
// Same 5-byte length, different content, preserved mtime.
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).unwrap();
assert_eq!(before.mtime_preserved_edits(&after), [PathBuf::from("lib/src/main/kotlin/Channel.kt")]);
// A normal edit (mtime advances) needs no repair.
write(root, "lib/src/main/kotlin/Channel.kt", "valve!");
let normal = fingerprint(root, &model).unwrap();
assert!(before.mtime_preserved_edits(&normal).is_empty());
}
#[test]
fn profile_options_jdk_goal_and_wrapper_invalidate_without_using_class_outputs() {
let dir = tempfile::tempdir().unwrap();
+76 -1
View File
@@ -88,8 +88,28 @@ impl Session {
Ok(None)
}
};
let before = fingerprint()?;
let mut before = fingerprint()?;
let previous = fs::read(&record).ok().and_then(|bytes| serde_json::from_slice::<Outputs>(&bytes).ok());
// Timestamp-incremental builders (Maven, the Kotlin plugin) skip sources whose
// mtime did not advance. An external edit that preserves both length and mtime
// invalidates the fingerprint but would still compile to nothing below, and the
// stale outputs would be re-recorded as fresh. Re-stamp those sources first so
// the rebuild sees them; then re-scan so the record written afterwards matches.
let restamp: Vec<PathBuf> = match (before.as_ref(), previous.as_ref().and_then(|o| o.fingerprint.as_ref())) {
(Some(current), Some(recorded)) => recorded.mtime_preserved_edits(current),
_ => Vec::new(),
};
if !restamp.is_empty() {
let now = std::time::SystemTime::now();
for rel in &restamp {
let path = root.join(rel);
if let Err(error) = fs::File::options().write(true).open(&path).and_then(|file| file.set_modified(now))
{
tracing::debug!(path = %path.display(), %error, "Cannot re-stamp externally edited source");
}
}
before = fingerprint()?;
}
if let Some(outputs) = previous.as_ref()
&& policy != BuildPolicy::Rebuild
&& outputs.reusable(&before, &snapshots)
@@ -234,6 +254,22 @@ mod tests {
ide_build::load(root, &root.join("cache"), None)
}
/// Like [`fixture`], but the build script skips sources whose mtime did not advance,
/// mirroring Maven's (and the Kotlin plugin's) timestamp-incremental compilation.
fn fixture_timestamp_incremental(root: &Path) -> ide_build::BuildModel {
fs::write(
root.join("pom.xml"),
r#"<project><groupId>demo</groupId><artifactId>app</artifactId><version>1</version></project>"#,
)
.unwrap();
let script = root.join("mvnw");
fs::write(&script, "#!/bin/sh\necho build \"$@\" >> calls\nif [ -f src/main/kotlin/broken ]; then exit 1; fi\nmkdir -p target/classes\nif [ ! -f target/classes/Main.class ] || [ src/main/kotlin/Main.kt -nt target/classes/Main.class ]; then\ncp src/main/kotlin/Main.kt target/classes/Main.class\nfi\n").unwrap();
fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
fs::create_dir_all(root.join("src/main/kotlin")).unwrap();
fs::write(root.join("src/main/kotlin/Main.kt"), "first").unwrap();
ide_build::load(root, &root.join("cache"), None)
}
#[test]
fn rebuild_bypasses_snapshots_and_error_continuation_retains_previous_classes() {
let dir = tempfile::tempdir().unwrap();
@@ -357,6 +393,45 @@ mod tests {
assert_eq!(fs::read_to_string(rebuilt.snapshot.join("0/Main.class")).unwrap(), "other");
}
#[test]
fn restart_after_mtime_preserving_external_edit_actually_recompiles() {
// The content hash triggers a rebuild for a same-length, same-mtime external
// edit, but a timestamp-incremental builder (real Maven) would still compile
// nothing and the stale snapshot would be re-recorded as fresh. Dropping the
// session simulates the IDE restart from the report.
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let model = fixture_timestamp_incremental(root);
let cache = root.join("cache");
let cancel = CancellationToken::new();
let mut session = Session::new(cache.clone());
let first = session.build(root, &model, root, BuildOptions::default(), None, &cancel).unwrap();
assert_eq!(fs::read_to_string(first.snapshot.join("0/Main.class")).unwrap(), "first");
// Pin the old output's mtime so the `-nt` check below is deterministic even on
// coarse-granularity filesystems.
fs::File::options()
.write(true)
.open(root.join("target/classes/Main.class"))
.unwrap()
.set_modified(std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(42))
.unwrap();
drop(session);
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 mut session = Session::new(cache.clone());
let rebuilt = session.build(root, &model, root, BuildOptions::default(), None, &cancel).unwrap();
assert_ne!(rebuilt.snapshot, first.snapshot);
assert_eq!(
fs::read_to_string(rebuilt.snapshot.join("0/Main.class")).unwrap(),
"other",
"the re-stamped source must actually recompile instead of re-recording stale outputs"
);
}
#[test]
fn session_drop_removes_superseded_snapshots_and_preserves_records_across_reopen() {
let dir = tempfile::tempdir().unwrap();
@@ -79,6 +79,7 @@
"--border-right-margin": "#2a313b",
"--bg-editor-selection": "#2d7772",
"--bg-editor-selection-inactive": "#255854",
"--fg-editor-selection": "inherit",
"--bg-text-search-result": "#114957",
"--bg-text-search-result-current": "#165e70",
"--bg-matched-brace": "#38424f",
+4 -3
View File
@@ -214,7 +214,7 @@ The full table is `UI_KEYS` in [`src/features/plugins/intellijTheme.ts`](../src/
|---|---|
| `--fg-editor`, `--bg-editor` | attribute `TEXT` (foreground, background) |
| `--caret`, `--bg-current-line` | colour `CARET_COLOR`, `CARET_ROW_COLOR` |
| `--bg-editor-selection` | colour `SELECTION_BACKGROUND` |
| `--bg-editor-selection`, `--fg-editor-selection` | colour `SELECTION_BACKGROUND`, `SELECTION_FOREGROUND` |
| `--fg-line-number`, `--fg-line-number-current` | colour `LINE_NUMBERS_COLOR`, `LINE_NUMBER_ON_CARET_ROW_COLOR` |
| `--bg-editor-gutter` | colour `EDITOR_GUTTER_BACKGROUND`, else the editor background (as the new UI paints it; `GUTTER_BACKGROUND` is the classic UI's) |
| `--border-indent-guide`, `--border-right-margin`, `--fg-whitespace` | colour `INDENT_GUIDE`, `RIGHT_MARGIN_COLOR`, `WHITESPACES` |
@@ -268,7 +268,7 @@ from `type`, then map:
| `--fg-editor` | `editor.foreground` |
| `--caret` | `editorCursor.foreground` |
| `--bg-current-line` | `editor.lineHighlightBackground` |
| `--bg-editor-selection`, `--bg-editor-selection-inactive` | `editor.selectionBackground`, `editor.inactiveSelectionBackground` |
| `--bg-editor-selection`, `--bg-editor-selection-inactive`, `--fg-editor-selection` | `editor.selectionBackground`, `editor.inactiveSelectionBackground`, `editor.selectionForeground` |
| `--fg-line-number`, `--fg-line-number-current` | `editorLineNumber.foreground`, `editorLineNumber.activeForeground` |
| `--border-indent-guide`, `--border-indent-guide-active` | `editorIndentGuide.background1`, `editorIndentGuide.activeBackground1` |
| `--fg-whitespace` | `editorWhitespace.foreground` |
@@ -329,7 +329,7 @@ a token.
<!-- BEGIN GENERATED TOKEN REFERENCE: pnpm gen:theme-docs -->
255 themeable tokens: 125 in the UI theme file, 130 in the editor colour scheme file.
256 themeable tokens: 125 in the UI theme file, 131 in the editor colour scheme file.
### Surfaces (UI theme)
@@ -499,6 +499,7 @@ a token.
| `--border-right-margin` | `#323438` | `#ebecf0` |
| `--bg-editor-selection` | `#214283` | `#a6d2ff` |
| `--bg-editor-selection-inactive` | `#1d3460` | `#c9e3ff` |
| `--fg-editor-selection` | `inherit` | `inherit` |
| `--bg-text-search-result` | `#114957` | `#fcd47e` |
| `--bg-text-search-result-current` | `#165e70` | `#f2bf57` |
| `--bg-matched-brace` | `#43454a` | `#93d9d9` |
+11
View File
@@ -159,6 +159,17 @@ describe("IntelliJ theme import", () => {
expect(s).toBeNull();
});
it("maps the selection foreground colour (#2446)", () => {
const s = convertIcls(`<scheme name="Sel" version="142" parent_scheme="Default"><colors>
<option name="SELECTION_BACKGROUND" value="214283" />
<option name="SELECTION_FOREGROUND" value="112233" />
</colors></scheme>`);
expect(s.tokens).toMatchObject({
"--bg-editor-selection": "#214283",
"--fg-editor-selection": "#112233",
});
});
it("only targets tokens that exist in tokens.css", () => {
const declared = new Set([...css.matchAll(/(--[a-z0-9-]+)\s*:/g)].map((m) => m[1]!));
// Read with a fallback by the editor (no tokens.css default), still themeable.
+2
View File
@@ -38,6 +38,8 @@ const plugin = ViewPlugin.fromClass(
const theme = EditorView.baseTheme({
".cm-openidea-selected": {
backgroundColor: "var(--editor-selection-inactive, var(--bg-editor-selection-inactive))",
// Unset (the default `inherit`): the declaration drops and the syntax colours show through.
color: "var(--fg-editor-selection)",
},
"&.cm-focused .cm-openidea-selected": {
backgroundColor: "var(--editor-selection, var(--bg-editor-selection))",
+1
View File
@@ -89,6 +89,7 @@ export const ICLS_COLORS: Record<string, string[]> = {
CARET_COLOR: ["--caret"],
CARET_ROW_COLOR: ["--bg-current-line"],
SELECTION_BACKGROUND: ["--bg-editor-selection"],
SELECTION_FOREGROUND: ["--fg-editor-selection"],
LINE_NUMBERS_COLOR: ["--fg-line-number"],
LINE_NUMBER_ON_CARET_ROW_COLOR: ["--fg-line-number-current"],
// The new UI paints the gutter with this, else with the editor's background; `GUTTER_BACKGROUND` is the classic UI's.
@@ -121,9 +121,11 @@ describe("theme tokens", () => {
/**
* Tokens the palette themes (Solarized, One) take from their light / dark base
* on purpose: colours the user assigns (project and data source colours), the
* platform's caption close and macOS window buttons, and the tint strength.
* platform's caption close and macOS window buttons, the tint strength, and
* the selection foreground, whose base default preserves syntax colours.
*/
const INHERITED_FROM_BASE = /^--(project-color-|db-color-|caption-close-|fg-caption-close-|tint-strength$)/;
const INHERITED_FROM_BASE =
/^--(project-color-|db-color-|caption-close-|fg-caption-close-|tint-strength$|fg-editor-selection$)/;
describe("palette themes", () => {
const ISLANDS = ["light", "dark", "dark-hc"];
+2
View File
@@ -339,6 +339,8 @@ export const viewerTheme = EditorView.theme(
"&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionBackground, ::selection, .cm-openidea-selected, &.cm-focused .cm-openidea-selected":
{
backgroundColor: "var(--bg-selected-strong)",
// Unset (the default `inherit`): the declaration drops and the syntax colours show through.
color: "var(--fg-editor-selection)",
},
".cm-activeLine": { backgroundColor: "var(--bg-current-line)" },
".cm-gutters": {
+24
View File
@@ -116,6 +116,30 @@ describe("color schemes", () => {
expect(pythonItalic).toContain("--syn-python-static-italic: italic !important");
});
it("leaves selected text in syntax colours until a selection foreground is set (#2446)", () => {
expect(ATTRIBUTES.find((a) => a.id === "selectionForeground")).toMatchObject({
name: "Selection foreground",
path: ["General", "Editor"],
fg: "--fg-editor-selection",
});
// The `inherit` default is not a colour: the settings page shows no foreground.
expect(resolvedAttribute("light", "selectionForeground")).toEqual({});
expect(resolvedAttribute("dark", "selectionForeground")).toEqual({});
const customs = {
"custom-sel": {
name: "Sel",
parent: "dark" as const,
attributes: { selectionForeground: { foreground: "#112233" } },
},
};
expect(resolvedAttribute("custom-sel", "selectionForeground", customs)).toMatchObject({
foreground: "#112233",
});
expect(schemeCss("dark", { dark: { selectionForeground: { foreground: "#112233" } } })).toContain(
"--fg-editor-selection: #112233 !important",
);
});
it("applies an explicit scheme over a plugin theme of the same base", () => {
const root = document.documentElement;
root.dataset.theme = "dark";
+6
View File
@@ -61,6 +61,12 @@ const BASE_ATTRIBUTES: readonly AttributeDef[] = [
{ id: "caret", name: "Caret", path: ["General", "Editor"], fg: "--caret" },
{ id: "caretRow", name: "Caret row", path: ["General", "Editor"], bg: "--bg-current-line" },
{ id: "selection", name: "Selection background", path: ["General", "Editor"], bg: "--bg-editor-selection" },
{
id: "selectionForeground",
name: "Selection foreground",
path: ["General", "Editor"],
fg: "--fg-editor-selection",
},
{
id: "selectionInactive",
name: "Selection background (inactive)",
+13
View File
@@ -34,6 +34,19 @@ describe(".icls schemes", () => {
expect(parseIcls(xml).scheme.attributes).toMatchObject(scheme.attributes);
});
it("round trips the selection foreground through SELECTION_FOREGROUND (#2446)", () => {
const { scheme } = parseIcls(`<scheme name="Sel" parent_scheme="Default"><colors>
<option name="SELECTION_BACKGROUND" value="214283"/>
<option name="SELECTION_FOREGROUND" value="ffffff"/>
</colors></scheme>`);
expect(scheme.attributes.selection).toEqual({ background: "#214283" });
expect(scheme.attributes.selectionForeground).toEqual({ foreground: "#ffffff" });
const { id, customs } = importScheme({}, scheme);
const xml = exportIcls(id, customs);
expect(xml).toContain('<option name="SELECTION_FOREGROUND" value="ffffff" />');
expect(parseIcls(xml).scheme.attributes.selectionForeground).toEqual({ foreground: "#ffffff" });
});
it("resolves explicit attribute inheritance independent of XML order and stops cycles", () => {
const { scheme } = parseIcls(`<scheme name="Inherited"><attributes>
<option name="PHP_PARAMETER" baseAttributes="PHP_VAR"/>
+1
View File
@@ -179,6 +179,7 @@ const COLOR_NAMES: Record<string, [string, "foreground" | "background"]> = {
CARET_COLOR: ["caret", "foreground"],
CARET_ROW_COLOR: ["caretRow", "background"],
SELECTION_BACKGROUND: ["selection", "background"],
SELECTION_FOREGROUND: ["selectionForeground", "foreground"],
INACTIVE_SELECTION_BACKGROUND: ["selectionInactive", "background"],
GUTTER_BACKGROUND: ["gutter", "background"],
LINE_NUMBERS_COLOR: ["lineNumber", "foreground"],
+2
View File
@@ -184,6 +184,7 @@ html {
--border-right-margin: #323438;
--bg-editor-selection: #214283;
--bg-editor-selection-inactive: #1d3460;
--fg-editor-selection: inherit;
--bg-text-search-result: #114957;
--bg-text-search-result-current: #165e70;
--bg-matched-brace: #43454a;
@@ -599,6 +600,7 @@ html {
--border-right-margin: #ebecf0;
--bg-editor-selection: #a6d2ff;
--bg-editor-selection-inactive: #c9e3ff;
--fg-editor-selection: inherit;
--bg-text-search-result: #fcd47e;
--bg-text-search-result-current: #f2bf57;
--bg-matched-brace: #93d9d9;