Порівняти коміти

...
Автор SHA1 Повідомлення Дата
l0ng-ai c5bccf020e fix(vcs): don't commit a file that was added to the index and then deleted
A file added with git add and then deleted from disk (AD) is no change
against HEAD, so the Commit view lists it nowhere. The commit plan skipped
it, but it stayed in the index copy the commit is made from, so committing
other files also committed the deleted file. Such staged entries are now
excluded like unchecked staged files.
2026-11-12 19:15:10 +08:00
l0ng-ai 55fcbf281a fix(editor): keep hover documentation inside its popup near the window edge (#2946)
Near the window edge CodeMirror caps the hover tooltip's height, but the documentation section kept its own 380px maximum and ran past the popup's border. The tooltip is now a flex column whose sections shrink and scroll within it.

Fixes #2910
2026-11-12 19:13:31 +08:00
Clarence f8aa003b47 feat(editor): render documentation comments (#2934)
Javadoc, KDoc, JSDoc, rustdoc and other /** and /// comments can be drawn in place as formatted documentation and switched back to source: globally (Settings › Editor › General › Appearance › Render documentation comments, off by default), per file (View › Active Editor › Render Documentation Comments, gutter menu, editor.toggleRenderedDocs) and per comment (gutter icon on hover, editor.toggleRenderedView at the caret). Comments are rescanned only once typing pauses and the Markdown renderer loads on first use.

Co-authored-by: Clarence404 <[email protected]>
2026-11-12 19:05:25 +08:00
Qingтаqtvbwfn e27b7f2502 feat(services): show a running-services badge on the window button (#2891)
The Services tool window's stripe button shows how many listed services are running: one per configuration (a run and its attached debugger count once), leaving out types not added, Docker kinds and hidden configurations; hidden at zero. The run provider keeps the count in a shared store, so the Services feature renders it without importing the run feature.

Co-authored-by: qtvbwfn <[email protected]>
2026-11-12 19:03:45 +08:00
langxpтаkradme bc00302ef8 fix(debugger): echo the Java launch command in the debug console (#2930)
Java Debug launches through java-debug in jdtls, which bypasses the Run process that echoes its command line. Debug now prints the equivalent java … -cp … Main … command after the build and classpath resolution, so Debug and Run output stay comparable.

Co-authored-by: kradme <[email protected]>
2026-11-12 19:03:38 +08:00
Mansur Akhmadiтаmansur-akhmadi 2e044c4a2b feat(vcs): show each repository's branch on its node in the Commit tool window (#2911)
With several Git repositories, each repository node in the Commit tool window now shows its current branch as a dimmed hint after the name (the short HEAD commit when detached), with the branch widget's branch / commit icon, from the heads the VCS store already loads.

Closes #2912

Co-authored-by: mansur-akhmadi <[email protected]>
2026-11-12 19:03:18 +08:00
l0ng-ai b1959e8fb5 fix(java): state the installed jdtls' JDK requirement in the language server JDK settings (#2876)
Settings said the language server JDK needs "jdtls 21+", but recent jdtls bundles an Eclipse platform that needs JDK 25, so a JDK 21 chosen there was rejected at launch. The requirement is now read from the installed jdtls (new jdtls_required_java command, the same bundle scan as the launch check): the hint states it, flags a too-old selection, the JDK picker marks too-old JDKs, and Download JDK preselects that version.
2026-11-12 19:03:00 +08:00
Qingтаqtvbwfn ce67c7e129 feat(run): colour console log lines by level (#2924)
Run and Debug consoles can colour whole lines by detected log level (ERROR, WARN, INFO, DEBUG, TRACE), reusing the log editor mode's level vocabulary; ANSI colours the program prints win. Settings › Build, Execution, Deployment › Console has the toggle and one colour per level (scheme tokens by default). Off by default, since IDEA's console doesn't colour lines by level.

Co-authored-by: qtvbwfn <[email protected]>
2026-11-12 19:01:01 +08:00
l0ng-ai 3cc94e340b fix(editor): move through the completion lookup with ⌃N / ⌃P on macOS (#2944)
On macOS ⌃N / ⌃P are line down / up in text, so with the lookup open they moved the caret instead of the selection. With a lookup open they now move its selection, as ↓ / ↑ do.

Fixes #2905
2026-11-12 18:58:15 +08:00
arookieofcтаa-rookie-of-C-language 51793e02c5 fix(debugger): use the same console font as the Run window (#2865)
The debug console used the UI mono font and size instead of the console font the Run window resolves from Tools › Terminal › Font (or Editor › Font). Both consoles now take family, size and row height from a shared useConsoleFont() hook, the debug console re-measures its rows when the size changes, and its evaluate input uses the same font.

Closes #2844

Co-authored-by: a-rookie-of-C-language <[email protected]>
2026-11-12 18:57:51 +08:00
l0ng-ai 1e0ce64b0c fix(plugins): don't block Find Usages on a failed plugin index batch (#2941)
A plugin indexer batch that failed left its paths dirty forever, so every later Find Usages and rename failed with "Plugin indexes are still updating". Failed paths are now retried before the next usages or refactoring; usages go ahead with a "may be incomplete" note while an index is not up to date, and refactoring refuses with a message that says the index failed.

Fixes #2913
2026-11-12 18:37:36 +08:00
76 змінених файлів з 3034 додано та 71 видалено

No files matched your search

+5
Переглянути файл
@@ -78,6 +78,10 @@ pub struct EditorAppearanceSettings {
/// Right margin and visual guides from Code Style.
pub hard_wrap_guides: bool,
pub intention_bulb: bool,
/// Draw doc comments (Javadoc, KDoc, JSDoc, rustdoc) as rendered documentation
/// instead of their source, as IDEA's option of the same name. View › Active
/// Editor › Render Documentation Comments overrides this for one editor.
pub render_doc_comments: bool,
/// Distraction Free Mode: width of the centred text column, in characters.
pub distraction_free_width: u32,
}
@@ -100,6 +104,7 @@ impl Default for EditorAppearanceSettings {
rainbow_indent_guides: false,
hard_wrap_guides: true,
intention_bulb: true,
render_doc_comments: false,
distraction_free_width: 80,
}
}
+30
Переглянути файл
@@ -192,6 +192,33 @@ pub struct IssueLink {
pub link: String,
}
/// Per-level colours for console log lines: a line whose level is detected
/// (`ERROR`, `WARN`, …) takes the level's colour; ANSI colours the program
/// prints itself win over it. Values are CSS colours, usually scheme tokens.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct LogLevelColors {
pub enabled: bool,
pub error: String,
pub warn: String,
pub info: String,
pub debug: String,
pub trace: String,
}
impl Default for LogLevelColors {
fn default() -> Self {
Self {
enabled: false,
error: "var(--ansi-red)".into(),
warn: "var(--ansi-yellow)".into(),
info: "var(--ansi-green)".into(),
debug: "var(--ansi-cyan)".into(),
trace: "var(--ansi-magenta)".into(),
}
}
}
/// Build, Execution, Deployment › Console.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
@@ -203,6 +230,8 @@ pub struct ConsoleSettings {
pub encoding: String,
/// Consecutive lines containing any of these are folded.
pub fold_patterns: Vec<String>,
/// Log lines take the colour of their level (Run/Debug consoles).
pub log_colors: LogLevelColors,
}
impl Default for ConsoleSettings {
@@ -219,6 +248,7 @@ impl Default for ConsoleSettings {
"at node:internal/".into(),
"<frozen importlib._bootstrap".into(),
],
log_colors: LogLevelColors::default(),
}
}
}
+18
Переглянути файл
@@ -161,6 +161,23 @@ pub async fn jdk_download(
Ok(sdk)
}
/// The Java version the jdtls this window launches needs; `None` while it isn't
/// installed or doesn't tell.
pub async fn jdtls_required_java(host: &Host, window: &str) -> IdeResult<Option<u32>> {
let settings = match host.project(window) {
Ok(project) => project.settings.effective(),
Err(_) => host.global_settings.effective(),
};
let Some(mut cfg) =
languages(host, window).into_iter().flat_map(|l| l.language_servers).find(|s| s.name == "jdtls")
else {
return Ok(None);
};
ide_sdk::apply::server_launch_overrides(&mut cfg, &settings);
ide_sdk::apply::server_protocol_overrides(&mut cfg, &settings);
tokio::task::spawn_blocking(move || ide_sdk::java::jdtls_required_java(&cfg)).await.map_err(join_err)
}
/// Commands of this module the remote server answers.
pub(crate) fn register(d: &mut Dispatcher) {
d.add("tools_list", |c| async move { tools_list(&c.host, &c.window).await });
@@ -173,6 +190,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
jdk_download_releases(&c.host, &c.window, c.arg("vendor")?, c.arg("request")?).await
});
d.add("jdk_download_location", |c| async move { jdk_download_location(&c.host, &c.window) });
d.add("jdtls_required_java", |c| async move { jdtls_required_java(&c.host, &c.window).await });
d.add("jdk_download", |c| async move {
jdk_download(
&c.host,
+1
Переглянути файл
@@ -254,6 +254,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "jdk_download_releases"
| "jdk_download_location"
| "jdk_download"
| "jdtls_required_java"
| "run_system_env"
| "run_gradle_jvm_args_script"
| "proxy_check_connection"
+1
Переглянути файл
@@ -53,6 +53,7 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project(
"jdk_download_releases",
"jdk_download_location",
"jdk_download",
"jdtls_required_java",
"scaffold_catalog",
"scaffold_options",
"scaffold_requirements",
+9 -3
Переглянути файл
@@ -282,10 +282,16 @@ pub fn jdtls_runtime(jdks: &[Jdk], min_java: u32) -> Option<&Jdk> {
/// for 21, but the Eclipse platform it ships moves on (1.63 needs 25), and on
/// an older JDK the server dies resolving its bundles.
pub fn jdtls_min_java(cfg: &LanguageServerConfig) -> u32 {
let required = crate::jdtls::script_for(cfg)
jdtls_required_java(cfg).unwrap_or(JDTLS_MIN_JAVA)
}
/// [`jdtls_min_java`] when the installed jdtls tells; `None` when none is
/// installed or its bundles can't be read.
pub fn jdtls_required_java(cfg: &LanguageServerConfig) -> Option<u32> {
crate::jdtls::script_for(cfg)
.and_then(|script| Some(script.parent()?.parent()?.to_path_buf()))
.and_then(|home| bundles_min_java(&home));
required.map_or(JDTLS_MIN_JAVA, |v| v.max(JDTLS_MIN_JAVA))
.and_then(|home| bundles_min_java(&home))
.map(|v| v.max(JDTLS_MIN_JAVA))
}
/// Per install: (plugins directory mtime, its requirement).
+16
Переглянути файл
@@ -565,6 +565,22 @@ mod tests {
);
}
#[test]
fn set_console_log_colors() {
let tmp = tempfile::tempdir().unwrap();
let cfg = tmp.path().join("cfg");
let proj = tmp.path().join("proj");
std::fs::create_dir_all(&cfg).unwrap();
let store = SettingsStore::load(&cfg, Some(&proj));
let s = store.set(SettingsScope::Global, "console.logColors.error", json!("#ff0000")).unwrap();
assert_eq!(s.console.log_colors.error, "#ff0000");
assert!(!s.console.log_colors.enabled, "off by default");
let s = store.set(SettingsScope::Global, "console.logColors.enabled", json!(true)).unwrap();
assert!(s.console.log_colors.enabled);
assert_eq!(s.console.log_colors.error, "#ff0000");
}
#[test]
fn set_preserves_comments_and_validates() {
let tmp = tempfile::tempdir().unwrap();
+1
Переглянути файл
@@ -156,6 +156,7 @@ Each launch opens a tab in the **Run** tool window (bottom). The toolbar has **R
- File paths and stack trace frames in the output are links.
- Repeated lines can be folded; configure the patterns in **Settings › Build, Execution, Deployment › Console**, along with the buffer size and the console encoding.
- Log lines can take the colour of their level (`ERROR`, `WARN`, `INFO`, `DEBUG`, `TRACE`); colours the program prints itself are kept. Turn it on and pick the colours in **Settings › Build, Execution, Deployment › Console**.
- For test runs the tab shows a test tree next to the output. See [Testing](/en/guide/testing).
**Settings › Build, Execution, Deployment › Run/Debug** controls whether the tool window opens when a process starts, whether rerun and closing a running tab ask first, and the time limit for a process to exit after Stop.
+1
Переглянути файл
@@ -152,6 +152,7 @@ Maven 模块在 **自动** 和 **构建(Make)** 时由 jdtls(ECJ)构建
- 输出里的文件路径和堆栈帧都可以点击跳转。
- 重复的行可以折叠。折叠规则、缓冲区大小和控制台编码在 **设置 › 构建、执行、部署 › 控制台** 中配置。
- 日志行可按级别着色(`ERROR`、`WARN`、`INFO`、`DEBUG`、`TRACE`);程序自行输出的颜色保持不变。默认关闭,可在 **设置 › 构建、执行、部署 › 控制台** 开启并修改颜色。
- 测试运行时,标签页会在输出旁显示测试树,参见[测试](/zh/guide/testing)。
**设置 › 构建、执行、部署 › 运行/调试** 控制进程启动时是否打开工具窗口、重新运行或关闭正在运行的标签页前是否确认,以及按停止后等待进程退出的时间。
+36
Переглянути файл
@@ -0,0 +1,36 @@
import { expect, test } from "@playwright/test";
/** Near the window edge CodeMirror caps a hover tooltip's height (#2910). */
test("hover sections scroll inside a height-capped tooltip", async ({ page }) => {
await page.goto("/");
const r = await page.evaluate(async () => {
const viewMod = await import("/node_modules/.vite/deps/@codemirror_view.js" as string);
const stateMod = await import("/node_modules/.vite/deps/@codemirror_state.js" as string);
const { lspTheme } = await import("/src/editor/lsp/theme.ts" as string);
const host = document.createElement("div");
host.style.cssText = "position:fixed;inset:0;z-index:99999;background:white";
document.body.append(host);
const view = new viewMod.EditorView({
state: stateMod.EditorState.create({ doc: "x", extensions: lspTheme }),
parent: host,
});
const tip = document.createElement("div");
tip.className = "cm-tooltip cm-tooltip-hover";
tip.style.cssText = "position:absolute;top:10px;left:10px;height:200px";
const sec = document.createElement("div");
sec.className = "cm-tooltip-section cm-lsp-hover";
sec.innerHTML = Array.from({ length: 40 }, (_, i) => `<p>line ${i}</p>`).join("");
tip.append(sec);
view.dom.append(tip);
const t = tip.getBoundingClientRect(),
s = sec.getBoundingClientRect();
return {
tip: Math.round(t.height),
secBottom: Math.round(s.bottom),
tipBottom: Math.round(t.bottom),
scrolls: sec.scrollHeight > sec.clientHeight,
};
});
expect(r.secBottom).toBeLessThanOrEqual(r.tipBottom);
expect(r.scrolls).toBe(true);
});
+1 -1
Переглянути файл
@@ -27,7 +27,7 @@ install-hint = "Install Microsoft java-debug (Settings › Languages & Framework
name = "jdtls"
command = "jdtls"
root-markers = ["pom.xml", "build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts", "build.xml", ".project"]
install-hint = "Install Eclipse JDT Language Server (brew install jdtls, or https://github.com/eclipse-jdtls/eclipse.jdt.ls); requires JDK 21+"
install-hint = "Install Eclipse JDT Language Server (brew install jdtls, or https://github.com/eclipse-jdtls/eclipse.jdt.ls); runs on a JDK new enough for the installed release, chosen or downloaded automatically"
[language-servers.initialization-options]
bundles = []
+1
Переглянути файл
@@ -455,6 +455,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
tools::jdk_download_vendors,
tools::jdk_download_releases,
tools::jdk_download_location,
tools::jdtls_required_java,
tools::jdk_download,
scaffold::scaffold_catalog,
scaffold::scaffold_options,
+7
Переглянути файл
@@ -66,6 +66,13 @@ pub fn jdk_download_location(state: State<'_, AppState>) -> IdeResult<ide_core::
ide_host::ops::tools::jdk_download_location(&state.host, "")
}
/// The Java version the window's jdtls needs; `None` while it isn't installed or doesn't tell.
#[tauri::command]
#[specta::specta]
pub async fn jdtls_required_java(window: Window, state: State<'_, AppState>) -> IdeResult<Option<u32>> {
ide_host::ops::tools::jdtls_required_java(&state.host, window.label()).await
}
/// Downloads the latest `vendor` JDK of Java `version` into `location` (default:
/// [`jdk_download_location`]), streaming progress through `on_event`; cancellable
/// with `cancel_request(request)`. The JDK is then detected like an installed one,
+8 -1
Переглянути файл
@@ -409,7 +409,14 @@ pub fn spec(os: Os) -> Vec<Entry> {
sub("statusBarWidgets", "Status Bar Widgets", vec![Entry::Dynamic(DYNAMIC_STATUS_BAR_WIDGETS)]),
],
),
sub("activeEditor", "Active Editor", vec![act("editor.toggleSoftWrap", "Soft-Wrap")]),
sub(
"activeEditor",
"Active Editor",
vec![
act("editor.toggleSoftWrap", "Soft-Wrap"),
act("editor.toggleRenderedDocs", "Render Documentation Comments"),
],
),
SEP,
act("code.quickDefinition", "Quick Definition"),
act("code.quickTypeDefinition", "Quick Type Definition"),
+24
Переглянути файл
@@ -931,6 +931,8 @@ export const commands = {
jdkDownloadReleases: (vendor: string, request: RequestId) => typedError<JdkReleases, IdeError>(__TAURI_INVOKE("jdk_download_releases", { vendor, request })),
/** Where Download JDK puts JDKs unless told otherwise. */
jdkDownloadLocation: () => typedError<IpcPath, IdeError>(__TAURI_INVOKE("jdk_download_location")),
/** The Java version the window's jdtls needs; `None` while it isn't installed or doesn't tell. */
jdtlsRequiredJava: () => typedError<number | null, IdeError>(__TAURI_INVOKE("jdtls_required_java")),
/**
* Downloads the latest `vendor` JDK of Java `version` into `location` (default:
* [`jdk_download_location`]), streaming progress through `on_event`; cancellable
@@ -3126,6 +3128,8 @@ export type ConsoleSettings = {
encoding?: string,
/** Consecutive lines containing any of these are folded. */
foldPatterns?: string[],
/** Log lines take the colour of their level (Run/Debug consoles). */
logColors?: LogLevelColors,
};
export type ContainerOp = "start" | "stop" | "restart" | "pause" | "unpause" | "kill";
@@ -4311,6 +4315,12 @@ export type EditorAppearanceSettings = {
/** Right margin and visual guides from Code Style. */
hardWrapGuides?: boolean,
intentionBulb?: boolean,
/**
* Draw doc comments (Javadoc, KDoc, JSDoc, rustdoc) as rendered documentation
* instead of their source, as IDEA's option of the same name. View › Active
* Editor › Render Documentation Comments overrides this for one editor.
*/
renderDocComments?: boolean,
/** Distraction Free Mode: width of the centred text column, in characters. */
distractionFreeWidth?: number,
};
@@ -6635,6 +6645,20 @@ export type LogDetails = {
export type LogLevel = "debug" | "info" | "warn" | "error";
/**
* Per-level colours for console log lines: a line whose level is detected
* (`ERROR`, `WARN`, …) takes the level's colour; ANSI colours the program
* prints itself win over it. Values are CSS colours, usually scheme tokens.
*/
export type LogLevelColors = {
enabled?: boolean,
error?: string,
warn?: string,
info?: string,
debug?: string,
trace?: string,
};
export type LogLocation = {
dir: IpcPath,
/** Current log file, if one was written yet. */
+14
Переглянути файл
@@ -49,6 +49,7 @@ import {
} from "./cm/folding";
import { reverseLines, sortLines } from "./cm/lines";
import { customRegions } from "./cm/regions";
import { toggleRenderedViewAtCaret } from "./cm/renderedDocs";
import { enclosingHeaders } from "./cm/stickyScroll";
import { moveElementLeft, moveElementRight } from "./cm/moveElement";
import { caretAnchor, openPopup, updatePopup, useLspPopup, type LspPopup } from "@/editor/lsp/popupStore";
@@ -748,6 +749,19 @@ export function registerEditorActions(): () => void {
else activeSlot()?.model.toggleSoftWrap();
},
},
{
id: "editor.toggleRenderedDocs",
title: "Render Documentation Comments",
category: "View",
keywords: ["render documentation comments", "javadoc", "rendered doc", "comments"],
when: "textEditorActive",
toggled: () => activeSlot()?.model.renderedDocsOn() ?? false,
run: () => activeSlot()?.model.toggleRenderedDocs(),
},
text("editor.toggleRenderedView", "Toggle Rendered View", (v) => toggleRenderedViewAtCaret(v), {
category: "View",
keywords: ["reader mode", "render documentation comment", "javadoc", "edit comment"],
}),
text("code.foldAll", "Collapse All", (v) => collapseAll(v), { category: "Folding", icon: "collapse-all" }),
text("code.unfoldAll", "Expand All", (v) => expandAll(v), { category: "Folding", icon: "expand-all" }),
text("code.collapseRecursively", "Collapse Recursively", (v) => collapseRecursively(v), {
+185
Переглянути файл
@@ -0,0 +1,185 @@
import { describe, expect, it } from "vitest";
import { extractInlineTags, hasContent, renderDoc, restoreInlineTags, splitBody, stripMarkers } from "./docRender";
describe("hasContent", () => {
it("agrees with renderDoc on which comments render to nothing", () => {
const cases: Array<[string, "block" | "line"]> = [
["/** */", "block"],
["/**\n *\n */", "block"],
["/** Books a room. */", "block"],
["/**\n * @param id the room\n */", "block"],
["/** @hide */", "block"],
["///", "line"],
["/// Frees a room.", "line"],
["//!", "line"],
];
for (const [text, style] of cases)
expect(hasContent(text, style), text).toBe(renderDoc(text, style, (md) => `<p>${md}</p>`) !== null);
});
});
/** Stands in for the Markdown renderer: wraps a paragraph, leaves the text alone. */
const markdown = (md: string) => `<p>${md}</p>`;
describe("stripMarkers", () => {
it("drops the markers of a block comment", () => {
const text = ["/**", " * One.", " * Two.", " */"].join("\n");
expect(stripMarkers(text, "block")).toBe("One.\nTwo.");
});
it("keeps the indent that follows the marker, so code blocks survive", () => {
const text = ["/**", " * Text:", " *", " * code();", " */"].join("\n");
expect(stripMarkers(text, "block")).toBe("Text:\n\n code();");
});
it("handles a one-line comment", () => {
expect(stripMarkers("/** Just this. */", "block")).toBe("Just this.");
});
it("does not eat a line that only closes the comment", () => {
expect(stripMarkers("/**\n * Text.\n */", "block")).toBe("Text.");
});
it("drops /// and //! of line comments", () => {
expect(stripMarkers("/// One.\n/// Two.", "line")).toBe("One.\nTwo.");
expect(stripMarkers("//! Crate docs.", "line")).toBe("Crate docs.");
});
});
describe("splitBody", () => {
it("separates the description from block tags", () => {
const { description, tags } = splitBody("Does a thing.\n\n@param id the id\n@return the thing");
expect(description).toBe("Does a thing.");
expect(tags).toEqual([
{ tag: "param", name: "id", type: null, text: "the id" },
{ tag: "return", name: null, type: null, text: "the thing" },
]);
});
it("lets a tag's text wrap onto the following lines", () => {
const { tags } = splitBody("@param id the id,\nwhich wraps");
expect(tags).toEqual([{ tag: "param", name: "id", type: null, text: "the id,\nwhich wraps" }]);
});
it("leaves an @ inside a sentence alone", () => {
const { description, tags } = splitBody("Mail to [email protected] for help.");
expect(description).toBe("Mail to [email protected] for help.");
expect(tags).toEqual([]);
});
it("keeps an unknown tag whole rather than splitting off a name", () => {
expect(splitBody("@apiNote Call it twice.").tags).toEqual([
{ tag: "apiNote", name: null, type: null, text: "Call it twice." },
]);
});
it("reads a line starting with @ inside a code block as code, not as a tag", () => {
const pre = splitBody("Use it:\n<pre>\n@Override\nvoid run() {}\n</pre>\nDone.");
expect(pre.tags).toEqual([]);
expect(pre.description).toBe("Use it:\n<pre>\n@Override\nvoid run() {}\n</pre>\nDone.");
const fenced = splitBody("Use it:\n```\n@Test\nfun works() {}\n```\n@param id the id");
expect(fenced.description).toContain("@Test");
expect(fenced.tags.map((t) => t.tag)).toEqual(["param"]);
});
it("splits off the type of a JSDoc or PHPDoc tag, so it is not taken for the name", () => {
expect(splitBody("@param {number} a - the first").tags).toEqual([
{ tag: "param", name: "a", type: "number", text: "the first" },
]);
expect(splitBody("@param {Map<string, {id: number}>} m the map").tags[0]).toMatchObject({
name: "m",
type: "Map<string, {id: number}>",
});
expect(splitBody("@param int $a the first").tags).toEqual([
{ tag: "param", name: "$a", type: "int", text: "the first" },
]);
expect(splitBody("@throws {RangeError} when out of range").tags).toEqual([
{ tag: "throws", name: "RangeError", type: null, text: "when out of range" },
]);
expect(splitBody("@returns {string} the name").tags).toEqual([
{ tag: "returns", name: null, type: "string", text: "the name" },
]);
});
});
describe("extractInlineTags", () => {
it("renders {@code} as escaped code", () => {
const { text, html } = extractInlineTags("Use {@code a < b}.");
expect(html).toEqual(["<code>a &#60; b</code>"]);
expect(restoreInlineTags(text, html)).toBe("Use <code>a &#60; b</code>.");
});
it("keeps a code span out of the Markdown, so a_b_c is not italicised", () => {
const { text } = extractInlineTags("{@code a_b_c}");
expect(text).not.toContain("a_b_c");
});
it("shows a link's label when it has one, the target otherwise", () => {
expect(extractInlineTags("{@link Foo#bar(int) the bar}").html[0]).toContain("the bar");
expect(extractInlineTags("{@link Foo#bar(int)}").html[0]).toContain("Foo#bar(int)");
});
it("counts nesting, so a brace inside {@code} does not end it", () => {
const { html } = extractInlineTags("{@code Map<K, {V}>} rest");
expect(html).toEqual(["<code>Map&#60;K, {V}&#62;</code>"]);
});
it("leaves an unclosed tag as written", () => {
const text = "{@code never closed";
expect(extractInlineTags(text)).toEqual({ text, html: [] });
});
it("drops {@inheritDoc}, which has nothing of its own to show", () => {
expect(extractInlineTags("{@inheritDoc}").html).toEqual([""]);
});
});
describe("renderDoc", () => {
it("renders the description and groups the tags into sections", () => {
const comment = [
"/**",
" * Books a room.",
" *",
" * @param id the room",
" * @param when the day",
" * @return the booking",
" * @throws Busy if it is taken",
" */",
].join("\n");
const doc = renderDoc(comment, "block", markdown);
expect(doc?.description).toBe("<p>Books a room.</p>");
expect(doc?.sections.map((s) => s.title)).toEqual(["Params", "Returns", "Throws"]);
expect(doc?.sections[0]?.rows.map((r) => r.name)).toEqual(["id", "when"]);
expect(doc?.sections[2]?.rows[0]?.name).toBe("Busy");
});
it("puts @return and @returns in one section", () => {
const doc = renderDoc("/**\n * @return a\n * @returns b\n */", "block", markdown);
expect(doc?.sections).toHaveLength(1);
expect(doc?.sections[0]?.rows).toHaveLength(2);
});
it("shows every tag: those it knows under their titles, any other under its own name", () => {
const doc = renderDoc(
"/**\n * Adds.\n * @example\n * add(1, 2)\n * @property id the id\n * @hide\n * @custom it\n */",
"block",
(md) => md,
);
expect(doc?.sections.map((s) => [s.title, s.translate])).toEqual([
["Properties", true],
["Examples", true],
["@hide", false],
["@custom", false],
]);
expect(doc?.sections[1]?.rows[0]?.html).toBe("add(1, 2)");
expect(doc?.sections[3]?.rows[0]?.html).toBe("it");
});
it("returns null for a comment with nothing to show", () => {
expect(renderDoc("/**\n *\n */", "block", markdown)).toBeNull();
});
it("renders a tagless comment", () => {
expect(renderDoc("/** Just prose. */", "block", markdown)?.description).toBe("<p>Just prose.</p>");
});
});
+332
Переглянути файл
@@ -0,0 +1,332 @@
/**
* Doc comment → HTML, for rendered doc comments (`renderedDocs.ts`) and anything else that
* wants a comment as prose.
*
* One path serves Javadoc, KDoc, JSDoc, PHPDoc and rustdoc: the body is handed to
* the Markdown renderer, which passes the raw HTML of a Javadoc body (`<p>`, `<b>`,
* `<code>`) straight through while rendering the Markdown of a KDoc or rustdoc one.
* Inline tags (`{@code}`, `{@link}`…) and block tags (`@param`, `@return`…) are
* ours to expand, and neither Markdown nor HTML knows them.
*
* Inline tags become placeholders before the Markdown runs and their HTML goes back
* in afterwards: their text is code, not Markdown, and `{@code a_b_c}` must not come
* out italicised. The HTML put back is built here from escaped text, so it is safe
* although it never passes through the sanitizer.
*/
/** A doc comment's text, by the markers that open it. */
export type DocStyle = "block" | "line";
export interface DocComment {
/** Document offset of the first character of the comment. */
from: number;
/** Document offset just past its last character. */
to: number;
style: DocStyle;
/** The comment verbatim, markers included. */
text: string;
}
/**
* Block tags given their own section, in the order IDEA lists them. Named tags start
* with what they document (`@param name`, `@throws Type`). Any other tag still shows,
* after these, under its own name.
*/
const BLOCK_SECTIONS: Array<{ tag: string; title: string; named: boolean }> = [
{ tag: "param", title: "Params", named: true },
{ tag: "tparam", title: "Type params", named: true },
{ tag: "template", title: "Type params", named: true },
{ tag: "property", title: "Properties", named: true },
{ tag: "receiver", title: "Receiver", named: false },
{ tag: "constructor", title: "Constructor", named: false },
{ tag: "return", title: "Returns", named: false },
{ tag: "returns", title: "Returns", named: false },
{ tag: "throws", title: "Throws", named: true },
{ tag: "exception", title: "Throws", named: true },
{ tag: "see", title: "See also", named: false },
{ tag: "since", title: "Since", named: false },
{ tag: "author", title: "Author", named: false },
{ tag: "version", title: "Version", named: false },
{ tag: "deprecated", title: "Deprecated", named: false },
{ tag: "apiNote", title: "API Note", named: false },
{ tag: "implSpec", title: "Implementation Requirements", named: false },
{ tag: "implNote", title: "Implementation Note", named: false },
{ tag: "example", title: "Examples", named: false },
{ tag: "sample", title: "Samples", named: false },
];
const SECTION_BY_TAG = new Map(BLOCK_SECTIONS.map((s) => [s.tag, s]));
/** `@tag` at the start of a stripped line opens a block tag; `@` inside a sentence does not. */
const BLOCK_TAG = /^@(\w+)[ \t]*([\s\S]*)$/;
export function escapeHtml(s: string): string {
return s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
}
/**
* The comment's content without its markers: the opening and closing ones, and the
* leading `*` of each line, or the `///` of each line. The indent that follows a
* marker is kept, so an indented code block inside the comment still is one.
*/
export function stripMarkers(text: string, style: DocStyle): string {
const lines = text.split("\n");
if (style === "line") {
return lines.map((l) => l.replace(/^[ \t]*\/\/[/!][ \t]?/, "")).join("\n");
}
const body = lines.map((line, i) => {
let l = line;
if (i === 0) l = l.replace(/^[ \t]*\/\*+[ \t]?/, "");
if (i === lines.length - 1) l = l.replace(/[ \t]*\*+\/[ \t]*$/, "");
if (i > 0) l = l.replace(/^[ \t]*\*(?!\/)[ \t]?/, "");
return l;
});
// A comment opening on its own line leaves a blank first line, and closing on its own a blank last one.
while (body.length && !body[0]!.trim()) body.shift();
while (body.length && !body.at(-1)!.trim()) body.pop();
return body.join("\n");
}
export interface BlockTag {
tag: string;
/** The first word for `@param name text` and `@throws Type text`; null otherwise. */
name: string | null;
/** The type a JSDoc (`@param {number} a`) or PHPDoc (`@param int $a`) tag gives; null otherwise. */
type: string | null;
text: string;
}
/** A leading `{type}` (JSDoc), braces balanced, split from the rest of the tag's text. */
function leadingType(text: string): { type: string; rest: string } | null {
if (!text.startsWith("{")) return null;
let depth = 0;
for (let i = 0; i < text.length; i++) {
if (text[i] === "{") depth++;
else if (text[i] === "}" && --depth === 0) return { type: text.slice(1, i).trim(), rest: text.slice(i + 1).trim() };
}
return null;
}
/** A tag's text after its name: JSDoc separates the two with ` - `, which is not part of it. */
const afterName = (text: string) => text.replace(/^-\s+/, "").trim();
/** Splits a named tag's text into what it documents, its type when given, and the rest. */
function namedTag(tag: string, text: string): Omit<BlockTag, "tag"> {
const typed = leadingType(text);
if (typed) {
// `@throws {Error} when…`: the type is what the tag names.
if (tag === "throws" || tag === "exception") {
return { name: typed.type, type: null, text: afterName(typed.rest) };
}
const m = /^(\S+)\s*([\s\S]*)$/.exec(typed.rest);
return { name: m?.[1] ?? null, type: typed.type, text: afterName(m?.[2] ?? "") };
}
// PHPDoc puts the type first and the variable second: `@param int $a`.
const php = /^([^\s$]\S*)\s+(\$\S+)\s*([\s\S]*)$/.exec(text);
if (php) return { name: php[2]!, type: php[1]!, text: afterName(php[3]!) };
const m = /^(\S+)[ \t]*([\s\S]*)$/.exec(text);
return { name: m?.[1] ?? null, type: null, text: afterName(m?.[2] ?? "") };
}
/** A code block's opening or closing line: a Markdown fence, or HTML `<pre>`. */
const FENCE = /^(```|~~~)/;
/** How the `<pre>` blocks a line opens and closes change the depth of code it leaves. */
function preDepthChange(line: string): number {
return (line.match(/<pre[\s>]/gi)?.length ?? 0) - (line.match(/<\/pre>/gi)?.length ?? 0);
}
export interface DocBody {
description: string;
tags: BlockTag[];
}
/**
* Splits the stripped body into its description and block tags. A tag's text runs
* to the next block tag, so a `@param` may wrap over several lines.
*/
export function splitBody(body: string): DocBody {
const lines = body.split("\n");
const description: string[] = [];
const tags: BlockTag[] = [];
let current: { tag: string; lines: string[] } | null = null;
const flush = () => {
if (!current) return;
const section = SECTION_BY_TAG.get(current.tag);
const text = current.lines.join("\n").trim();
if (section?.named) {
tags.push({ tag: current.tag, ...namedTag(current.tag, text) });
} else {
// `@returns {string} the name`: JSDoc types the value too.
const typed = leadingType(text);
tags.push({
tag: current.tag,
name: null,
type: typed?.type ?? null,
text: typed ? afterName(typed.rest) : text,
});
}
current = null;
};
// Inside a code block, a line starting with `@` is code (`@Override`), not a block tag.
let fenced = false;
let pre = 0;
for (const line of lines) {
const trimmed = line.trim();
const m = fenced || pre > 0 ? null : BLOCK_TAG.exec(trimmed);
if (FENCE.test(trimmed)) fenced = !fenced;
pre = Math.max(0, pre + preDepthChange(line));
if (m) {
flush();
current = { tag: m[1]!, lines: [m[2] ?? ""] };
} else if (current) {
current.lines.push(line);
} else {
description.push(line);
}
}
flush();
return { description: description.join("\n").trim(), tags };
}
/** The label an inline tag shows: `{@link Foo#bar(int) the label}` shows the label when it has one. */
function linkLabel(body: string): string {
const trimmed = body.trim();
const space = /\s/.exec(trimmed);
if (!space) return trimmed;
const target = trimmed.slice(0, space.index);
const label = trimmed.slice(space.index).trim();
return label || target;
}
/**
* Replaces each `{@tag …}` with a placeholder, collecting the HTML it stands for.
* Nesting is counted so `{@code Map<K, {@literal V}>}` ends at the right brace.
*/
export function extractInlineTags(text: string): { text: string; html: string[] } {
const html: string[] = [];
let out = "";
let i = 0;
while (i < text.length) {
const start = text.indexOf("{@", i);
if (start < 0) {
out += text.slice(i);
break;
}
const m = /^\{@(\w+)[ \t]*/.exec(text.slice(start));
if (!m) {
out += text.slice(i, start + 2);
i = start + 2;
continue;
}
let depth = 1;
let j = start + m[0].length;
for (; j < text.length && depth > 0; j++) {
if (text[j] === "{") depth++;
else if (text[j] === "}") depth--;
}
if (depth > 0) {
// Unclosed: leave it as written rather than swallowing the rest of the comment.
out += text.slice(i);
break;
}
out += text.slice(i, start);
out += placeholder(html.length);
html.push(inlineTagHtml(m[1]!, text.slice(start + m[0].length, j - 1)));
i = j;
}
return { text: out, html };
}
/**
* U+E000 is in the private use area: no comment contains it, and Markdown leaves it
* alone. The digits are delimited so `…0…` and `…01…` cannot be confused.
*/
function placeholder(index: number): string {
return `${index}`;
}
const PLACEHOLDER = /(\d+)/g;
function inlineTagHtml(tag: string, body: string): string {
switch (tag) {
case "code":
case "systemProperty":
return `<code>${escapeHtml(body)}</code>`;
case "literal":
return escapeHtml(body);
case "link":
case "linkplain":
return `<code class="cm-openidea-docLink">${escapeHtml(linkLabel(body))}</code>`;
case "value":
return `<code>${escapeHtml(linkLabel(body))}</code>`;
case "inheritDoc":
return "";
default:
return escapeHtml(body);
}
}
/** Puts the inline tags' HTML back where their placeholders are. */
export function restoreInlineTags(html: string, tags: string[]): string {
return html.replace(PLACEHOLDER, (all, index: string) => tags[Number(index)] ?? all);
}
export interface RenderedDoc {
/** The description, as HTML. */
description: string;
/**
* One entry per block-tag section: those of `BLOCK_SECTIONS` in its order, then any
* other tag in the order it comes, titled by the tag as written (`@hide`), which is
* code and is not translated.
*/
sections: Array<{
title: string;
translate: boolean;
rows: Array<{ name: string | null; type: string | null; html: string }>;
}>;
}
/**
* Whether `comment` has anything to render, without rendering it: `renderDoc` returns
* null exactly when it has neither text nor tags. Cheap enough to ask of every comment
* in a file, which rendering is not.
*/
export function hasContent(comment: string, style: DocStyle): boolean {
const { description, tags } = splitBody(stripMarkers(comment, style));
return description.trim() !== "" || tags.length > 0;
}
/**
* Renders `comment` with `markdown`, the Markdown-to-safe-HTML function (so this
* module stays free of the parser and its 40 KB). Returns null when the comment
* carries nothing to show.
*/
export function renderDoc(comment: string, style: DocStyle, markdown: (md: string) => string): RenderedDoc | null {
const { description, tags } = splitBody(stripMarkers(comment, style));
const render = (text: string) => {
if (!text.trim()) return "";
const extracted = extractInlineTags(text);
return restoreInlineTags(markdown(extracted.text), extracted.html);
};
const descriptionHtml = render(description);
const sections: RenderedDoc["sections"] = [];
const row = (t: BlockTag) => ({ name: t.name, type: t.type, html: render(t.text) });
for (const section of BLOCK_SECTIONS) {
const rows = tags.filter((t) => t.tag === section.tag).map(row);
if (!rows.length) continue;
// `@return` and `@returns` are the same section: extend the one already there.
const existing = sections.find((s) => s.translate && s.title === section.title);
if (existing) existing.rows.push(...rows);
else sections.push({ title: section.title, translate: true, rows });
}
for (const t of tags) {
if (SECTION_BY_TAG.has(t.tag)) continue;
const title = `@${t.tag}`;
const existing = sections.find((s) => !s.translate && s.title === title);
if (existing) existing.rows.push(row(t));
else sections.push({ title, translate: false, rows: [row(t)] });
}
if (!descriptionHtml && !sections.length) return null;
return { description: descriptionHtml, sections };
}
+2 -1
Переглянути файл
@@ -1,7 +1,8 @@
import { StreamLanguage, type StreamParser } from "@codemirror/language";
import { logWarning } from "./highlight";
const LEVELS =
/** Level vocabulary, shared with the console line colouring (`ui/logColors`). */
export 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`);
+4
Переглянути файл
@@ -50,6 +50,7 @@ import {
} from "./editing";
import { enterInLineComment, lineCommentStyle, type LineCommentStyle } from "./comments";
import { enterInDocComment } from "./docComment";
import { renderDocComments } from "./renderedDocs";
import { skipClosingQuote, smartAngleBrackets, smartBackspace, smartBackspaceApplies, tabOut } from "./smartKeys";
import { stringClipboard } from "./stringClipboard";
import { filterCopiedText } from "./clipboard";
@@ -485,6 +486,8 @@ export interface PrefsOptions {
softWrap: boolean | undefined;
/** Soft wraps toggled for the file, overriding `softWrap` and the file patterns. */
wrapOverride?: boolean | null;
/** Rendered doc comments toggled for the file, overriding `appearance.renderDocComments`. */
renderedDocsOverride?: boolean | null;
showWhitespace: boolean | undefined;
/** Hard wrap column for this file's language. */
hardWrapAt: number;
@@ -578,6 +581,7 @@ export function prefsExtension(o: PrefsOptions): Extension {
out.push(marginGuides([o.hardWrapAt, ...prefs.codeStyle.visualGuides]));
if (prefs.appearance.methodSeparators)
out.push(EditorView.editorAttributes.of({ class: "cm-openidea-methodSeparators" }));
if (o.renderedDocsOverride ?? prefs.appearance.renderDocComments) out.push(renderDocComments.of(true));
const hidden = GUTTER_ICON_GROUPS.filter(
(grp) => !prefs.showGutterIcons || prefs.gutterIconsDisabled.includes(grp.id),
);
+276
Переглянути файл
@@ -0,0 +1,276 @@
import { afterEach, expect, it, vi } from "vitest";
import { act } from "react";
import { fireEvent } from "@testing-library/react";
import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { java } from "@codemirror/lang-java";
import { useI18n } from "@/platform/i18n";
import { renderDocComments, renderedDocsExtension, renderedRanges, toggleRenderedViewAtCaret } from "./renderedDocs";
let view: EditorView | null = null;
const i18n = useI18n.getState();
afterEach(() => {
view?.destroy();
view = null;
useI18n.setState(i18n);
});
const DOC = ["/**", " * Books a room.", " * @param n nights", " */", "class Rooms {}"].join("\n");
function open(reader: boolean, doc = DOC) {
view = new EditorView({
state: EditorState.create({
doc,
extensions: [java(), renderedDocsExtension, renderDocComments.of(reader)],
}),
parent: document.body,
});
return view;
}
const toggles = (v: EditorView) => [...v.dom.querySelectorAll<HTMLElement>(".cm-openidea-docToggle")];
/** Moves the mouse over the line of `line` (1-based). jsdom lays nothing out, so the block under it is given. */
async function hover(v: EditorView, line: number) {
vi.spyOn(v, "lineBlockAtHeight").mockReturnValue(v.lineBlockAt(v.state.doc.line(line).from));
await act(async () => fireEvent.mouseMove(v.dom, { clientY: v.documentTop }));
}
const pressed = (v: EditorView) => toggles(v).map((t) => t.getAttribute("aria-pressed"));
/** Whether the gutter gives the toggles a column: its spacer has their width, else none. */
const hasColumn = (v: EditorView) =>
[...v.dom.querySelectorAll<HTMLElement>(".cm-openidea-docToggles .cm-gutterElement span")].some(
(el) => el.style.width === "14px",
);
/** Opens the file rendering its comments, then brings this one's source back with its pencil. */
async function openFlippedToSource() {
const v = open(true);
await hover(v, 1);
await act(async () => fireEvent.mouseDown(toggles(v)[0]!, { button: 0 }));
expect(renderedRanges(v.state)).toHaveLength(0);
return v;
}
it("shows the pencil beside a rendered comment, and clicking it brings back the source", async () => {
const v = open(true);
expect(renderedRanges(v.state)).toHaveLength(1);
await hover(v, 1);
const [pencil] = toggles(v);
// A rendered comment is a block widget: the gutter must still give it its toggle.
expect(pencil?.getAttribute("aria-pressed")).toBe("true");
await act(async () => fireEvent.mouseDown(pencil!, { button: 0 }));
expect(renderedRanges(v.state)).toHaveLength(0);
expect(toggles(v)[0]?.getAttribute("aria-pressed")).toBe("false");
});
it("shows the lines icon beside a source comment, and clicking it renders the comment", async () => {
const v = await openFlippedToSource();
await hover(v, 2);
const [lines] = toggles(v);
expect(lines?.getAttribute("aria-pressed")).toBe("false");
await act(async () => fireEvent.mouseDown(lines!, { button: 0 }));
expect(renderedRanges(v.state)).toHaveLength(1);
expect(toggles(v)[0]?.getAttribute("aria-pressed")).toBe("true");
});
it("shows a source comment's icon only while the mouse is over it, as IDEA does", async () => {
const v = await openFlippedToSource();
await act(async () => fireEvent.mouseLeave(v.dom));
expect(toggles(v)).toHaveLength(0);
// The column stays, so the text does not shift as the icon comes and goes.
expect(hasColumn(v)).toBe(true);
await hover(v, 3);
expect(pressed(v)).toEqual(["false"]);
await hover(v, 5);
expect(toggles(v)).toHaveLength(0);
await hover(v, 1);
await act(async () => fireEvent.mouseLeave(v.dom));
expect(toggles(v)).toHaveLength(0);
});
it("shows a rendered comment's pencil only while the mouse is over it", async () => {
const v = open(true);
expect(toggles(v)).toHaveLength(0);
await hover(v, 1);
expect(pressed(v)).toEqual(["true"]);
await hover(v, 5);
expect(toggles(v)).toHaveLength(0);
});
it("leaves the icons to the mouse, not the caret", async () => {
const v = await openFlippedToSource();
await act(async () => fireEvent.mouseLeave(v.dom));
await act(async () => v.dispatch({ selection: { anchor: v.state.doc.line(2).from + 4 } }));
expect(toggles(v)).toHaveLength(0);
});
it("while off, shows the lines icon over a comment, and clicking it renders that one, as IDEA does", async () => {
const v = open(false);
expect(hasColumn(v)).toBe(true);
expect(toggles(v)).toHaveLength(0);
await hover(v, 2);
expect(pressed(v)).toEqual(["false"]);
await act(async () => fireEvent.mouseDown(toggles(v)[0]!, { button: 0 }));
expect(renderedRanges(v.state)).toHaveLength(1);
await hover(v, 1);
await act(async () => fireEvent.mouseDown(toggles(v)[0]!, { button: 0 }));
expect(renderedRanges(v.state)).toHaveLength(0);
expect(hasColumn(v)).toBe(true);
});
it("while off, gives a file without doc comments no column, and finds a new one once typing pauses", async () => {
vi.useFakeTimers();
try {
const v = open(false, "class Rooms {}");
expect(hasColumn(v)).toBe(false);
await hover(v, 1);
expect(toggles(v)).toHaveLength(0);
await act(async () => v.dispatch({ changes: { from: 0, insert: "/** Books a room. */\n" } }));
// Not scanned on the keystroke: only once typing pauses.
expect(hasColumn(v)).toBe(false);
await act(async () => void vi.advanceTimersByTime(300));
expect(hasColumn(v)).toBe(true);
await hover(v, 1);
expect(pressed(v)).toEqual(["false"]);
} finally {
vi.useRealTimers();
}
});
it("while off, Toggle Rendered View renders one comment, and its pencil is the way back", async () => {
const v = open(false);
await act(async () => v.dispatch({ selection: { anchor: v.state.doc.line(2).from } }));
await act(async () => void toggleRenderedViewAtCaret(v));
expect(renderedRanges(v.state)).toHaveLength(1);
expect(hasColumn(v)).toBe(true);
await hover(v, 1);
await act(async () => fireEvent.mouseDown(toggles(v)[0]!, { button: 0 }));
expect(renderedRanges(v.state)).toHaveLength(0);
// The file still has its comment, so its column stays for the icon.
expect(hasColumn(v)).toBe(true);
});
it("renders an edited comment anew, and an unchanged one as before", async () => {
const v = open(true);
const text = () => v.dom.querySelector(".cm-openidea-renderedDoc")?.textContent ?? "";
expect(text()).toContain("Books a room.");
const at = v.state.doc.toString().indexOf("room");
await act(async () => v.dispatch({ changes: { from: at, to: at + 4, insert: "suite" } }));
expect(text()).toContain("Books a suite.");
await act(async () => v.dispatch({ changes: { from: at, to: at + 5, insert: "room" } }));
expect(text()).toContain("Books a room.");
});
it("makes the toggle a labelled button with IDEA's icons", async () => {
const v = open(true);
await hover(v, 1);
const [pencil] = toggles(v);
expect(pencil?.tagName).toBe("BUTTON");
expect(pencil?.getAttribute("aria-label")).toBe("Toggle Rendered View");
expect(pencil?.querySelector("svg[data-icon='gutter-javadoc-edit']")).not.toBeNull();
await act(async () => fireEvent.mouseDown(pencil!, { button: 0 }));
await hover(v, 2);
expect(toggles(v)[0]?.querySelector("svg[data-icon='gutter-javadoc-read']")).not.toBeNull();
});
it("shows the source of a rendered comment the caret is put inside, as by Find", async () => {
const v = open(true);
expect(renderedRanges(v.state)).toHaveLength(1);
// Its edges are where arrow keys stop on their way past: still rendered there.
await act(async () => v.dispatch({ selection: { anchor: 0 } }));
await act(async () => v.dispatch({ selection: { anchor: v.state.doc.line(4).to } }));
expect(renderedRanges(v.state)).toHaveLength(1);
const at = v.state.doc.toString().indexOf("room");
await act(async () => v.dispatch({ selection: { anchor: at, head: at + 4 } }));
expect(renderedRanges(v.state)).toHaveLength(0);
});
it("indents by the code's character width, so a font size change keeps it in line", () => {
const v = open(true, ["class A {", "\t/**", "\t * Books.", "\t */", "\tvoid book() {}", "}"].join("\n"));
const body = v.dom.querySelector<HTMLElement>(".cm-openidea-renderedDoc")!;
// A tab is a tab size of columns, not one character.
expect(body.style.marginLeft).toContain(`${v.state.tabSize} * var(--openidea-doc-char`);
});
it("forgets a flip whose comment is deleted, so the feature turns off again", async () => {
const v = open(
false,
["/** Books a room. */", "class Rooms {}", "/** Lists the rooms. */", "class List {}"].join("\n"),
);
await act(async () => v.dispatch({ selection: { anchor: 0 } }));
await act(async () => void toggleRenderedViewAtCaret(v));
expect(renderedRanges(v.state)).toHaveLength(1);
// Delete the rendered comment, its line and all: the other comment stays as source.
await act(async () => v.dispatch({ changes: { from: 0, to: v.state.doc.line(2).from } }));
expect(renderedRanges(v.state)).toHaveLength(0);
// A flip left behind would keep the feature on: the remaining comment, now where the
// deleted one began, would render after its next edit.
await act(async () => v.dispatch({ changes: { from: v.state.doc.length, insert: " " } }));
expect(renderedRanges(v.state)).toHaveLength(0);
});
it("does not flip a comment with nothing to render, nor give it an icon", async () => {
const v = open(false, ["/** */", "class Rooms {}"].join("\n"));
expect(toggleRenderedViewAtCaret(v)).toBe(false);
expect(hasColumn(v)).toBe(false);
await hover(v, 1);
expect(toggles(v)).toHaveLength(0);
});
it("draws the section titles and tooltips again in a new UI language", async () => {
const v = open(true);
const title = () => v.dom.querySelector(".cm-openidea-renderedDocSections dt")?.textContent;
expect(title()).toBe("Params:");
await act(async () =>
useI18n.setState({ locale: "zh", texts: { Params: "参数", "Toggle Rendered View": "切换渲染视图" } }),
);
expect(title()).toBe("参数:");
await hover(v, 1);
expect(toggles(v)[0]?.getAttribute("aria-label")).toBe("切换渲染视图");
});
/** The declarations the editor's theme gives `selector` (ending the rule's selector). */
function themeRule(selector: string): CSSStyleDeclaration | null {
for (const sheet of document.styleSheets)
for (const rule of sheet.cssRules)
if (rule instanceof CSSStyleRule && rule.selectorText.split(",").some((s) => s.trim().endsWith(selector)))
return rule.style;
return null;
}
it("gives the rendered comment no vertical margin, which would push the lines below off their numbers", () => {
open(true);
// The editor measures a block widget by its border box: a margin is height it never sees.
const rule = themeRule(".cm-openidea-renderedDoc")!;
expect(rule.marginTop || "0").toMatch(/^0(px)?$/);
expect(rule.marginBottom || "0").toMatch(/^0(px)?$/);
});
it("reflows the rendered text, so the source's line breaks do not carry over", () => {
open(true);
// The content is `white-space: pre`, which the widget inherits unless it says otherwise;
// jsdom does not compute inheritance, so the rule itself is what is checked.
expect(themeRule(".cm-openidea-renderedDoc")?.whiteSpace).toBe("normal");
// Code blocks keep theirs.
expect(themeRule(".cm-openidea-renderedDoc pre")?.whiteSpace).toBe("pre");
});
it("gives a {@link} the link colour, over the code colour", () => {
open(true);
expect(themeRule(".cm-openidea-renderedDoc code.cm-openidea-docLink")?.color).toBe("var(--accent)");
});
it("wraps the rendered text at the visible width, not the longest line's", () => {
const v = open(true);
const body = v.dom.querySelector<HTMLElement>(".cm-openidea-renderedDoc")!;
expect(body.style.maxWidth).toContain("var(--openidea-doc-width");
});
it("leaves the comment as it is when its rendered text is clicked", async () => {
const v = open(true);
const body = v.dom.querySelector<HTMLElement>(".cm-openidea-renderedDoc")!;
await act(async () => fireEvent.mouseDown(body, { button: 0 }));
expect(renderedRanges(v.state)).toHaveLength(1);
});
+221
Переглянути файл
@@ -0,0 +1,221 @@
import { describe, expect, it } from "vitest";
import { Compartment, EditorSelection, EditorState } from "@codemirror/state";
import { java } from "@codemirror/lang-java";
import { javascript } from "@codemirror/lang-javascript";
import { ensureSyntaxTree } from "@codemirror/language";
import { findLanguageDescription, loadSupport } from "./languages";
import {
commentAt,
docComments,
renderCacheSize,
renderDocComments,
renderedDocsExtension,
renderedRanges,
toggleRenderedView,
} from "./renderedDocs";
const defaultMode = new Compartment();
function state(doc: string, { reader = true, language = java(), at = 0 } = {}) {
return EditorState.create({
doc,
selection: EditorSelection.cursor(at),
extensions: [language, renderedDocsExtension, defaultMode.of(renderDocComments.of(reader))],
});
}
/** The replaced ranges as 1-based `[firstLine, lastLine]` pairs. */
function replaced(s: EditorState): Array<[number, number]> {
return renderedRanges(s).map(([from, to]) => [s.doc.lineAt(from).number, s.doc.lineAt(to).number]);
}
const JAVA = [
"package p;", // 1
"", // 2
"/**", // 3
" * Books a room.", // 4
" *", // 5
" * @param id the room", // 6
" */", // 7
"public class Rooms {", // 8
" /** Frees a room. */", // 9
" public void free() {}", // 10
"}", // 11
].join("\n");
const BOOK = JAVA.indexOf("/**");
const FREE = JAVA.indexOf("/** Frees");
describe("docComments", () => {
it("finds each Javadoc block and its exact range", () => {
const s = state(JAVA);
const found = docComments(s);
expect(found.map((c) => [s.doc.lineAt(c.from).number, s.doc.lineAt(c.to).number])).toEqual([
[3, 7],
[9, 9],
]);
expect(found[0]!.style).toBe("block");
});
it("ignores an ordinary block comment", () => {
expect(docComments(state("/* not docs */\nclass A {}"))).toHaveLength(0);
});
it("ignores an empty block comment that only looks like one", () => {
expect(docComments(state("/**/\nclass A {}"))).toHaveLength(0);
});
it("ignores a doc comment sharing its line with code", () => {
expect(docComments(state("int a; /** docs */\n"))).toHaveLength(0);
});
it("joins a run of /// lines into one comment", () => {
const found = docComments(state("/// One.\n/// Two.\nfunction f() {}", { language: javascript() }));
expect(found).toHaveLength(1);
expect(found[0]!.style).toBe("line");
expect(found[0]!.text).toBe("/// One.\n/// Two.");
});
it("keeps two /// runs apart when a blank line separates them", () => {
expect(docComments(state("/// One.\n\n/// Two.\nfunction f() {}", { language: javascript() }))).toHaveLength(2);
});
it("ignores a plain // comment", () => {
expect(docComments(state("// just a note\nfunction f() {}", { language: javascript() }))).toHaveLength(0);
});
});
describe("the file's default", () => {
it("renders every doc comment when on", () => {
expect(replaced(state(JAVA))).toEqual([
[3, 7],
[9, 9],
]);
});
it("renders none when off", () => {
expect(replaced(state(JAVA, { reader: false }))).toEqual([]);
});
it("does not depend on the caret: one inside a comment leaves it rendered", () => {
expect(replaced(state(JAVA, { at: JAVA.indexOf("Books") }))).toEqual([
[3, 7],
[9, 9],
]);
});
it("does not change as the caret moves, so nothing flickers", () => {
const s = state(JAVA);
const moved = s.update({ selection: EditorSelection.cursor(JAVA.indexOf("public class")) }).state;
expect(replaced(moved)).toEqual(replaced(s));
});
it("leaves an empty doc comment as source, having nothing to show", () => {
expect(replaced(state("/**\n *\n */\nclass A {}"))).toEqual([]);
});
it("renders no comment until the editor draws it, so opening a large file stays cheap", () => {
const doc = Array.from({ length: 40 }, (_, i) => `/** Lazy comment ${i} of a large file. */\nclass L${i} {}`).join(
"\n",
);
const before = renderCacheSize();
const s = state(doc);
// Every comment is decided rendered: its source is replaced ...
expect(replaced(s)).toHaveLength(40);
// ... but none was rendered yet: no editor has drawn them.
expect(renderCacheSize()).toBe(before);
});
});
describe("per-comment toggle", () => {
it("switches one comment to source and leaves the others rendered", () => {
const s = state(JAVA).update({ effects: toggleRenderedView.of(BOOK) }).state;
expect(replaced(s)).toEqual([[9, 9]]);
});
it("renders one comment while the file's default is source", () => {
const s = state(JAVA, { reader: false }).update({ effects: toggleRenderedView.of(FREE) }).state;
expect(replaced(s)).toEqual([[9, 9]]);
});
it("toggling twice restores the default", () => {
const s = state(JAVA)
.update({ effects: toggleRenderedView.of(BOOK) })
.state.update({ effects: toggleRenderedView.of(BOOK) }).state;
expect(replaced(s)).toEqual([
[3, 7],
[9, 9],
]);
});
it("follows its comment through an edit above it", () => {
const s = state(JAVA)
.update({ effects: toggleRenderedView.of(BOOK) })
.state.update({ changes: { from: 0, insert: "// header\n" } }).state;
expect(replaced(s)).toEqual([[10, 10]]);
});
it("is cleared when the whole file switches, so every comment ends up the same", () => {
const flipped = state(JAVA).update({ effects: toggleRenderedView.of(BOOK) }).state;
const off = flipped.update({ effects: defaultMode.reconfigure(renderDocComments.of(false)) }).state;
expect(replaced(off)).toEqual([]);
const on = off.update({ effects: defaultMode.reconfigure(renderDocComments.of(true)) }).state;
expect(replaced(on)).toEqual([
[3, 7],
[9, 9],
]);
});
});
describe("commentAt", () => {
it("finds the comment around the caret", () => {
const s = state(JAVA);
expect(commentAt(s, JAVA.indexOf("Books"))?.from).toBe(BOOK);
});
it("finds the comment right above the caret's line", () => {
const s = state(JAVA);
expect(commentAt(s, JAVA.indexOf("public class"))?.from).toBe(BOOK);
expect(commentAt(s, JAVA.indexOf("public void"))?.from).toBe(FREE);
});
it("finds nothing on a line no comment documents", () => {
expect(commentAt(state(JAVA), 0)).toBeNull();
});
});
describe("languages", () => {
const BLOCK = "/**\n * Hello.\n * @param a the a\n */\n";
const LINES = "/// Hello.\n/// World.\n";
// Lezer grammars give a comment one node; the line-based modes (Kotlin, Scala, Groovy,
// Dart, C#, Swift) give each of its lines one. Both must come out as one comment.
it.each([
["Java", "A.java", BLOCK + "class A {}\n", [1, 4]],
["Kotlin", "A.kt", BLOCK + "class A\n", [1, 4]],
["Scala", "A.scala", BLOCK + "class A\n", [1, 4]],
["Groovy", "A.groovy", BLOCK + "class A {}\n", [1, 4]],
["Dart", "a.dart", BLOCK + "class A {}\n", [1, 4]],
["C#", "A.cs", LINES + "class A {}\n", [1, 2]],
["JavaScript", "a.js", BLOCK + "function a() {}\n", [1, 4]],
["TypeScript", "a.ts", BLOCK + "function a() {}\n", [1, 4]],
["PHP", "a.php", "<?php\n" + BLOCK + "function a() {}\n", [2, 5]],
["Rust", "a.rs", LINES + "fn a() {}\n", [1, 2]],
["C++", "a.cpp", BLOCK + "void a();\n", [1, 4]],
["Swift", "a.swift", LINES + "func a() {}\n", [1, 2]],
] as const)("finds a whole doc comment in %s", async (name, path, doc, [first, last]) => {
const support = await loadSupport(findLanguageDescription(name, `/p/${path}`)!);
const s = EditorState.create({ doc, extensions: support! });
ensureSyntaxTree(s, s.doc.length, 5000);
expect(docComments(s).map((c) => [s.doc.lineAt(c.from).number, s.doc.lineAt(c.to).number])).toEqual([
[first, last],
]);
});
it("drops a line-based comment that is never closed", async () => {
const support = await loadSupport(findLanguageDescription("Kotlin", "/p/A.kt")!);
const s = EditorState.create({ doc: "/**\n * Hello.\nclass A\n", extensions: support! });
ensureSyntaxTree(s, s.doc.length, 5000);
expect(docComments(s)).toEqual([]);
});
});
+850
Переглянути файл
@@ -0,0 +1,850 @@
/**
* Rendered doc comments: drawn as the documentation they are, in place of their
* source, as IDEA's "Render documentation comments".
*
* A rendered comment's lines are replaced by one block widget holding its HTML
* (`docRender.ts`). Which comments are rendered is decided in two layers, as in IDEA:
*
* - The file's default: Settings › Editor › General › Appearance › Render
* documentation comments, or what View › Active Editor › Render Documentation
* Comments made of it for this file (the document model keeps that override, as it
* does the soft-wrap one). It arrives as the `renderDocComments` facet.
* - Each comment's own gutter icon (and Toggle Rendered View at the caret), which
* flips that one comment away from the default. Changing the default clears them,
* so switching the whole file always leaves every comment in the same mode.
*
* Clicking the rendered text leaves the comment as it is: the text can be selected and
* copied like any prose, and the gutter icon is the way to the source. The caret can
* only get into a rendered comment some other way (Find, Go to Line, undo), and then,
* as in IDEA, the comment shows its source: else its text would be edited unseen.
*
* As in IDEA, a comment's icon shows while the mouse is over the comment, its source
* lines or its rendered block, or over the icon's own place in the gutter. So a file of
* comments is not a column of icons.
*
* Off, which is the default, the icon still shows over a comment, so one comment can be
* read rendered, as in IDEA. That costs typing nothing: while the file renders no
* comments and none is flipped, its comments are found only once typing pauses
* (`passiveScanner`), the Markdown parser is not loaded, and the gutter has a column
* only if the file has doc comments.
*/
import {
EditorState,
Facet,
countColumn,
MapMode,
RangeSet,
StateEffect,
StateField,
type Extension,
type Transaction,
} from "@codemirror/state";
import {
Decoration,
EditorView,
GutterMarker,
ViewPlugin,
WidgetType,
gutter,
type DecorationSet,
type ViewUpdate,
} from "@codemirror/view";
import { syntaxTree } from "@codemirror/language";
import { gutterIconMarkup } from "@/ui/icons";
import { emptyGutterSpacer } from "@/editor/api";
import { tx, useI18n } from "@/platform/i18n";
import { loadMarkdown, markupToHtml } from "@/editor/lsp/markdown";
import { hasContent, renderDoc, type DocComment, type DocStyle, type RenderedDoc } from "./docRender";
import { LINE_INSET } from "./lineInset";
/** The file's default: render its doc comments or not. */
export const renderDocComments = Facet.define<boolean, boolean>({
combine: (values) => values[0] ?? false,
});
/** Flips the comment starting at this position away from the file's default. */
export const toggleRenderedView = StateEffect.define<number>();
/** The mouse is over the comment starting here, or (null) over none. */
const hoverDoc = StateEffect.define<number | null>();
/** Announces that the Markdown parser has loaded and the comments can be rendered again. */
const markdownLoaded = StateEffect.define<null>();
/** Announces a new UI language: titles and tooltips are drawn again in it. */
const relabel = StateEffect.define<null>();
// --- finding the comments -----------------------------------------------------
/** A doc comment opens with `/**`, except when those are an empty block comment's four characters. */
function blockDocAt(text: string): boolean {
return text.startsWith("/**") && !text.startsWith("/**/");
}
/** rustdoc and C# `///`, and the `//!` that documents the enclosing item. */
function lineDocAt(text: string): boolean {
return /^\/\/[/!](?!\/)/.test(text);
}
/** Comment nodes, whatever the grammar calls them: `BlockComment`, `LineComment`, `docComment`, `comment`. */
const COMMENT_NODE = /comment/i;
/** Whether only whitespace lies between two document positions. */
function adjoins(state: EditorState, from: number, to: number): boolean {
return !state.doc.sliceString(from, to).trim();
}
/**
* The doc comments of `state`, in document order. Only comments that have their lines
* to themselves are listed: one sharing a line with code cannot be replaced by a block.
*
* A comment is assembled from the comment nodes rather than taken as one: a Lezer
* grammar (Java, JavaScript, PHP, Rust…) gives a block comment one node, but the
* line-based modes (Kotlin, Scala, Groovy, Dart, C#, Swift) give each of its lines a
* node of its own. So a `/**` node opens a comment that takes in the nodes after it
* until one closes it, and consecutive `///` lines join into one, as they are one doc
* block in every language that has them.
*/
export function docComments(state: EditorState): DocComment[] {
const out: DocComment[] = [];
/** The block comment being assembled, until a node ends it with the closing marker. */
let open: DocComment | null = null;
const finish = (comment: DocComment) => {
const first = state.doc.lineAt(comment.from);
const last = state.doc.lineAt(comment.to);
if (first.text.slice(0, comment.from - first.from).trim()) return;
if (last.text.slice(comment.to - last.from).trim()) return;
comment.text = state.doc.sliceString(comment.from, comment.to);
out.push(comment);
};
syntaxTree(state).iterate({
enter(node) {
if (!COMMENT_NODE.test(node.name)) return;
const text = state.doc.sliceString(node.from, node.to);
if (open) {
if (adjoins(state, open.to, node.from)) {
open.to = node.to;
if (text.trimEnd().endsWith("*/")) {
finish(open);
open = null;
}
return false;
}
// Something other than comment came first: it was never closed, so it is not one.
open = null;
}
if (blockDocAt(text)) {
const comment: DocComment = { from: node.from, to: node.to, style: "block", text };
if (text.length > 3 && text.trimEnd().endsWith("*/")) finish(comment);
else open = comment;
return false;
}
if (!lineDocAt(text)) return false;
const previous = out.at(-1);
if (
previous?.style === "line" &&
state.doc.lineAt(previous.to).number === state.doc.lineAt(node.from).number - 1 &&
adjoins(state, previous.to, node.from)
) {
// The next line of the same run: extend it rather than starting a comment.
previous.to = node.to;
previous.text = state.doc.sliceString(previous.from, node.to);
return false;
}
finish({ from: node.from, to: node.to, style: "line", text });
return false;
},
});
return out;
}
/**
* The comment Toggle Rendered View acts on from `pos`: the one around it, else the one
* right above its line (the documentation of the declaration the caret is on).
*/
export function commentAt(state: EditorState, pos: number): DocComment | null {
const line = state.doc.lineAt(pos).number;
for (const comment of docComments(state)) {
if (pos >= comment.from && pos <= comment.to) return comment;
if (state.doc.lineAt(comment.to).number === line - 1) return comment;
}
return null;
}
// --- per-comment flips ---------------------------------------------------------
/**
* Start positions of the comments flipped away from the file's default, mapped through
* edits so a flip follows its comment. A position that no longer starts a comment is
* simply never matched.
*/
const flipped = StateField.define<readonly number[]>({
create: () => [],
update(value, tr) {
if (tr.startState.facet(renderDocComments) !== tr.state.facet(renderDocComments)) return [];
// A flip whose comment lost its opening character is gone with it; one left behind
// would keep the feature active (scanning, its column) with nothing to show.
let next = tr.docChanged
? value.flatMap((pos) => {
const mapped = tr.changes.mapPos(pos, 1, MapMode.TrackAfter);
return mapped === null ? [] : [mapped];
})
: value;
for (const e of tr.effects) {
if (!e.is(toggleRenderedView)) continue;
next = next.includes(e.value) ? next.filter((pos) => pos !== e.value) : [...next, e.value];
}
return next;
},
});
/** Whether the file renders its comments, or has one flipped: else there is nothing to find or draw. */
function active(state: EditorState): boolean {
return state.facet(renderDocComments) || state.field(flipped).length > 0;
}
/** Whether the comment starting at `from` is rendered now. */
function isRendered(state: EditorState, from: number): boolean {
return state.facet(renderDocComments) !== state.field(flipped).includes(from);
}
// --- the widget ---------------------------------------------------------------
/** A section's title as shown: translated, unless it is a tag as written (`@hide`). */
const sectionTitle = (section: RenderedDoc["sections"][number]) =>
section.translate ? tx(section.title) : section.title;
/** Bumped when the UI language changes: widgets made before draw their titles in the old one. */
let labelEpoch = 0;
/** Whether the Markdown parser has loaded: widgets made before drew their text as plain text. */
let markdownIsLoaded = false;
/** Height of one rendered line, for the estimate of a widget not drawn yet. */
const ESTIMATED_LINE_HEIGHT = 20;
/**
* A rendered comment. It keeps the comment's source and renders it only when the editor
* draws it, which is when it scrolls into view: opening a file with a thousand comments
* renders the dozen on screen. Until then its height is estimated from the source.
*/
class DocWidget extends WidgetType {
/** Everything the DOM depends on, made once: the editor compares widgets on every update. */
readonly key: string;
constructor(
readonly style: DocStyle,
readonly text: string,
/** The comment's indent, in columns. */
readonly indent: number,
) {
super();
this.key = `${indent}\u0000${labelEpoch}\u0000${markdownIsLoaded ? 1 : 0}\u0000${style}\u0000${text}`;
}
override eq(other: DocWidget): boolean {
return this.key === other.key;
}
/** The source's lines less its opening and closing ones: about what the rendered text takes. */
override get estimatedHeight(): number {
let lines = 1;
for (let i = this.text.indexOf("\n"); i >= 0; i = this.text.indexOf("\n", i + 1)) lines++;
return Math.max(1, this.style === "block" ? lines - 2 : lines) * ESTIMATED_LINE_HEIGHT;
}
override toDOM(): HTMLElement {
const doc = renderCached(this.style, this.text) ?? { description: "", sections: [] };
const titles = doc.sections.map(sectionTitle);
const dom = document.createElement("div");
dom.className = "cm-openidea-renderedDoc";
// In the code's character width, which `docMetrics` keeps current: the DOM stays
// right through a font size change or zoom, which does not draw the widget again.
const left = `(${this.indent} * var(--openidea-doc-char, 1ch) + ${LINE_INSET}px)`;
dom.style.marginLeft = `calc${left}`;
// The content is as wide as its longest line; the comment wraps at the visible width instead.
dom.style.maxWidth = `calc(var(--openidea-doc-width, 100%) - ${left} - ${DOC_RIGHT_GAP}px)`;
dom.setAttribute("role", "note");
if (doc.description) {
const body = document.createElement("div");
body.className = "cm-openidea-renderedDocBody";
body.innerHTML = doc.description;
dom.appendChild(body);
}
if (doc.sections.length) {
const list = document.createElement("dl");
list.className = "cm-openidea-renderedDocSections";
doc.sections.forEach((section, s) => {
section.rows.forEach((row, i) => {
const title = document.createElement("dt");
// The section's title heads its first row only, as IDEA lists `Params:`.
if (i === 0) title.textContent = `${titles[s]}:`;
list.appendChild(title);
const value = document.createElement("dd");
if (row.name !== null) {
const name = document.createElement("code");
name.textContent = row.name;
value.appendChild(name);
}
if (row.type !== null) {
if (row.name !== null) value.appendChild(document.createTextNode(" "));
const type = document.createElement("code");
type.className = "cm-openidea-docType";
type.textContent = row.type;
value.appendChild(type);
}
if ((row.name !== null || row.type !== null) && row.html) value.appendChild(document.createTextNode(" – "));
const text = document.createElement("span");
text.className = "cm-openidea-renderedDocText";
text.innerHTML = row.html;
value.appendChild(text);
list.appendChild(value);
});
});
dom.appendChild(list);
}
return dom;
}
/**
* The editor leaves every event inside the widget to the browser: its text selects
* and copies like any prose, and a click does not move the caret or switch the mode.
*/
override ignoreEvent(): boolean {
return true;
}
}
// --- decorations and gutter markers -------------------------------------------
/**
* The comment's mode switch, with IDEA's icons: lines while it is source (to render
* it), a pencil while rendered (to edit it). The gutter takes it with the mouse, so the
* button is not in the tab order.
*/
class ToggleMarker extends GutterMarker {
constructor(
readonly rendered: boolean,
readonly label: string,
) {
super();
}
override eq(other: GutterMarker): boolean {
return other instanceof ToggleMarker && other.rendered === this.rendered && other.label === this.label;
}
override toDOM(): Node {
const button = document.createElement("button");
button.type = "button";
button.tabIndex = -1;
button.className = "cm-openidea-docToggle";
button.title = this.label;
button.setAttribute("aria-label", this.label);
button.setAttribute("aria-pressed", String(this.rendered));
button.innerHTML = gutterIconMarkup(this.rendered ? "gutter-javadoc-edit" : "gutter-javadoc-read") ?? "";
return button;
}
}
/** The toggle in the UI language of the moment: a new language makes a new marker, which the gutter draws again. */
function toggleMarker(rendered: boolean): ToggleMarker {
return new ToggleMarker(rendered, tx("Toggle Rendered View"));
}
/** Reserves the toggles' width. Not a `ToggleMarker`: the spacer is in the DOM, hidden, and must not read as a button. */
class ToggleSpacer extends GutterMarker {
override toDOM(): Node {
const el = document.createElement("span");
el.style.display = "inline-block";
el.style.width = "14px";
return el;
}
}
const toggleSpacer = new ToggleSpacer();
/** A doc comment, with where its first line starts and its last line ends, and whether it is rendered now. */
type PlacedComment = DocComment & { line: number; end: number; rendered: boolean };
interface DocsView {
decorations: DecorationSet;
/** Every doc comment, rendered or not. */
comments: ReadonlyArray<PlacedComment>;
}
const INACTIVE: DocsView = { decorations: Decoration.none, comments: [] };
/**
* Rendered comments by their source, shared by every editor, least recently used first.
* A widget drawn again (scrolled back into view, or after an edit elsewhere) finds its
* comment here instead of rendering it again. Past `RENDER_CACHE_SIZE` the least recently
* used is dropped, so a large file does not empty the cache for the next one. Emptied
* when the Markdown parser loads: what was rendered before it is plain text.
*/
const renderCache = new Map<string, RenderedDoc | null>();
const RENDER_CACHE_SIZE = 2000;
function renderCached(style: DocStyle, text: string): RenderedDoc | null {
const key = style + "\u0000" + text;
let rendered = renderCache.get(key);
if (rendered === undefined) {
rendered = renderDoc(text, style, (md) => markupToHtml({ kind: "markdown", value: md }));
if (renderCache.size >= RENDER_CACHE_SIZE) renderCache.delete(renderCache.keys().next().value!);
} else {
// Move it to the most recently used end.
renderCache.delete(key);
}
renderCache.set(key, rendered);
return rendered;
}
/**
* Whether a comment has anything to render, by its source. Each edit rebuilds the view
* and asks it of every comment in the file; this keeps that to a lookup for all but the
* comment being edited.
*/
const contentCache = new Map<string, boolean>();
function hasContentCached(style: DocStyle, text: string): boolean {
const key = style + "\u0000" + text;
let content = contentCache.get(key);
if (content === undefined) {
content = renderCache.has(key) ? renderCache.get(key) !== null : hasContent(text, style);
if (contentCache.size >= RENDER_CACHE_SIZE * 5) contentCache.delete(contentCache.keys().next().value!);
contentCache.set(key, content);
}
return content;
}
/** Exported for tests: the number of comments rendered and cached. */
export function renderCacheSize(): number {
return renderCache.size;
}
function build(state: EditorState): DocsView {
if (!active(state)) return INACTIVE;
const decorations = [];
const comments: PlacedComment[] = [];
for (const comment of docComments(state)) {
const first = state.doc.lineAt(comment.from);
const last = state.doc.lineAt(comment.to);
// A comment with nothing to render keeps its source, and its icon says so. Telling
// which those are does not render anything: the widget renders when it is drawn.
const rendered = isRendered(state, comment.from) && hasContentCached(comment.style, comment.text);
comments.push({ ...comment, line: first.from, end: last.to, rendered });
if (!rendered) continue;
const indent = countColumn(first.text.slice(0, first.text.length - first.text.trimStart().length), state.tabSize);
decorations.push(
Decoration.replace({ widget: new DocWidget(comment.style, comment.text, indent), block: true }).range(
first.from,
last.to,
),
);
}
return { decorations: Decoration.set(decorations), comments };
}
/** Start of the comment the mouse is over, or null. Cleared by any edit, which moves the text under it. */
const hovered = StateField.define<number | null>({
create: () => null,
update(value, tr) {
for (const e of tr.effects) if (e.is(hoverDoc)) value = e.value;
return tr.docChanged && !tr.effects.some((e) => e.is(hoverDoc)) ? null : value;
},
});
/**
* The comments of a file that renders none, as source, with where their lines start and
* end. Those that would render to nothing are left out: they get no icon, and a file of
* only those no column.
*/
function placeComments(state: EditorState): PlacedComment[] {
return docComments(state)
.filter((c) => hasContentCached(c.style, c.text))
.map((c) => ({
...c,
line: state.doc.lineAt(c.from).from,
end: state.doc.lineAt(c.to).to,
rendered: false,
}));
}
/** Replaces the comments found while the file renders none. */
const setPassive = StateEffect.define<readonly PlacedComment[]>();
/**
* The doc comments of a file that renders none, for their icons. Rescanning the file on
* each keystroke would make typing in a large file several times slower for a feature
* that is off, so `passiveScanner` rescans once typing pauses; meanwhile the comments
* found are mapped through the edits. Empty while the file renders comments: `docsField`
* has them then.
*/
const passiveDocs = StateField.define<readonly PlacedComment[]>({
create: (state) => (active(state) ? [] : placeComments(state)),
update(value, tr) {
if (active(tr.state)) return value.length ? [] : value;
// Switched off: the comments rendered a moment ago are known only to the last scan.
if (active(tr.startState)) return placeComments(tr.state);
for (const e of tr.effects) if (e.is(setPassive)) return e.value;
if (!tr.docChanged) return value;
const map = (pos: number, assoc: number) => tr.changes.mapPos(pos, assoc);
return value.map((c) => ({
...c,
from: map(c.from, 1),
to: map(c.to, -1),
line: map(c.line, 1),
end: map(c.end, -1),
}));
},
});
/** Scans a file that renders no comments once the edits and the parser have paused. */
const passiveScanner = ViewPlugin.fromClass(
class {
timer: ReturnType<typeof setTimeout> | null = null;
constructor(readonly view: EditorView) {}
update(update: ViewUpdate) {
if (active(update.state)) return this.cancel();
if (update.docChanged || syntaxTree(update.startState) !== syntaxTree(update.state)) {
this.cancel();
this.timer = setTimeout(() => {
this.timer = null;
if (!active(this.view.state)) this.view.dispatch({ effects: setPassive.of(placeComments(this.view.state)) });
}, PASSIVE_SCAN_DELAY);
}
}
cancel() {
if (this.timer !== null) clearTimeout(this.timer);
this.timer = null;
}
destroy() {
this.cancel();
}
},
);
/** How long edits and parsing must pause before a file that renders no comments is scanned. */
const PASSIVE_SCAN_DELAY = 300;
/** Every doc comment of the file, as last found. */
function placedComments(state: EditorState): ReadonlyArray<PlacedComment> {
return active(state) ? state.field(docsField).comments : state.field(passiveDocs);
}
/**
* The source-mode toggle, beside the source comment the mouse is over, if it is over one
* that has something to render: one that would render to nothing stays source anyway.
*/
function sourceToggle(state: EditorState): RangeSet<GutterMarker> {
const at = state.field(hovered);
const comment = at === null ? null : placedComments(state).find((c) => c.from === at && !c.rendered);
if (!comment || (!active(state) && !hasContentCached(comment.style, comment.text))) return RangeSet.empty;
return RangeSet.of([toggleMarker(false).range(comment.line)]);
}
/** The comment whose lines, source or rendered, make up part of the line block `[from, to]`. */
function commentOverlapping(state: EditorState, from: number, to: number): number | null {
return placedComments(state).find((c) => from <= c.to && to >= c.line)?.from ?? null;
}
/**
* Follows the mouse over the editor, text and gutters alike, by the line block under it,
* so a comment's icon shows wherever on its lines the mouse is.
*/
const hoverTracker = ViewPlugin.fromClass(
class {
constructor(readonly view: EditorView) {
view.dom.addEventListener("mousemove", this.move);
view.dom.addEventListener("mouseleave", this.leave);
}
move = (event: MouseEvent) => {
const { view } = this;
if (!placedComments(view.state).length) return;
const block = view.lineBlockAtHeight(event.clientY - view.documentTop);
const inside = event.clientY - view.documentTop <= block.bottom && event.clientY >= view.documentTop;
this.set(inside ? commentOverlapping(view.state, block.from, block.to) : null);
};
leave = () => this.set(null);
set(at: number | null) {
if (this.view.state.field(hovered) !== at) this.view.dispatch({ effects: hoverDoc.of(at) });
}
destroy() {
this.view.dom.removeEventListener("mousemove", this.move);
this.view.dom.removeEventListener("mouseleave", this.leave);
}
},
);
/** The comment ranges rendered in place of their source, as `[from, to]` offsets. Exported for tests. */
export function renderedRanges(state: EditorState): Array<[number, number]> {
const out: Array<[number, number]> = [];
state.field(docsField).decorations.between(0, state.doc.length, (from, to) => void out.push([from, to]));
return out;
}
const docsField = StateField.define<DocsView>({
create: build,
update(value, tr) {
if (tr.effects.some((e) => e.is(markdownLoaded))) renderCache.clear();
const rebuild =
tr.docChanged ||
tr.reconfigured ||
tr.effects.some((e) => e.is(toggleRenderedView) || e.is(markdownLoaded) || e.is(relabel)) ||
// The parser finishing more of the file can bring comments it had not reached.
syntaxTree(tr.startState) !== syntaxTree(tr.state);
return rebuild ? build(tr.state) : value;
},
provide: (f) => EditorView.decorations.from(f, (v) => v.decorations),
});
const toggleGutter = gutter({
class: "cm-openidea-docToggles",
// Comments left as source: their first line is a text line, which reads these markers.
// Asked again on every update, so the icon follows the mouse.
markers: (view) => sourceToggle(view.state),
// Rendered comments: a block widget is not a text line, and the gutter never looks at
// `markers` for one, only at this.
widgetMarker: (view, widget, block) =>
widget instanceof DocWidget && view.state.field(hovered) === commentOverlapping(view.state, block.from, block.to)
? toggleMarker(true)
: null,
// The widget markers are asked for only when the gutter redraws, which it must when the
// mouse moves on, or the language changes.
lineMarkerChange: (update) =>
update.startState.field(hovered) !== update.state.field(hovered) ||
update.transactions.some((tr) => tr.effects.some((e) => e.is(relabel))),
// A file without doc comments gets no column at all; one with them keeps its column
// while the icon comes and goes, so the text does not shift.
initialSpacer: (view) => (placedComments(view.state).length ? toggleSpacer : emptyGutterSpacer),
updateSpacer: (_spacer, update) => (placedComments(update.state).length ? toggleSpacer : emptyGutterSpacer),
domEventHandlers: {
mousedown(view, block, event) {
const target = event.target instanceof Element ? event.target.closest(".cm-openidea-docToggle") : null;
if ((event as MouseEvent).button !== 0 || !target) return false;
// A file that renders none may not have been scanned since its last edit: the flip
// must land on where its comment starts now.
const comment = active(view.state)
? commentOverlapping(view.state, block.from, block.to)
: (placeComments(view.state).find((c) => block.from <= c.to && block.to >= c.line)?.from ?? null);
if (comment === null) return false;
event.preventDefault();
view.dispatch({ effects: toggleRenderedView.of(comment) });
return true;
},
},
});
/**
* Until the Markdown parser has loaded, a comment renders as plain text. It loads the
* first time the editor renders anything, so an editor that never does never loads it,
* and each that does waits for it once and then rebuilds.
*/
const markdownReady = ViewPlugin.fromClass(
class {
alive = true;
started = false;
constructor(readonly view: EditorView) {
this.start();
}
update(update: ViewUpdate) {
if (!this.started && (update.docChanged || update.transactions.length)) this.start();
}
start() {
if (!active(this.view.state)) return;
this.started = true;
void loadMarkdown()
.then(() => {
markdownIsLoaded = true;
if (this.alive) this.view.dispatch({ effects: markdownLoaded.of(null) });
})
.catch(() => {});
}
destroy() {
this.alive = false;
}
},
);
/**
* A selection that ends inside a rendered comment shows that comment's source. A click
* cannot put it there (the widget keeps its events); Find, Go to Line or undo can, and
* the text they land on must be visible to be edited. The edges are outside: arrow keys
* stop there on their way past.
*/
const showSourceAtCaret = EditorState.transactionExtender.of((tr: Transaction) => {
if (!tr.selection) return null;
const docs = tr.startState.field(docsField, false);
if (!docs?.comments.length) return null;
const map = (pos: number, assoc: number) => (tr.docChanged ? tr.changes.mapPos(pos, assoc) : pos);
const effects = [];
for (const c of docs.comments) {
if (!c.rendered) continue;
const from = map(c.line, 1);
const to = map(c.end, -1);
if (tr.selection.ranges.some((r) => r.head > from && r.head < to))
effects.push(toggleRenderedView.of(map(c.from, 1)));
}
return effects.length ? { effects } : null;
});
/** Draws titles and tooltips again when the UI language changes. */
const languageWatcher = ViewPlugin.fromClass(
class {
readonly unsubscribe: () => void;
constructor(view: EditorView) {
this.unsubscribe = useI18n.subscribe((next, previous) => {
if (next.locale !== previous.locale || next.texts !== previous.texts) {
labelEpoch++;
view.dispatch({ effects: relabel.of(null) });
}
});
}
destroy() {
this.unsubscribe();
}
},
);
/** Toggle Rendered View at the caret: the comment around it or right above its line. */
export function toggleRenderedViewAtCaret(view: EditorView): boolean {
const comment = commentAt(view.state, view.state.selection.main.head);
if (!comment) return false;
// One with nothing to render would stay source, and its flip would linger to no end.
if (!isRendered(view.state, comment.from) && !hasContentCached(comment.style, comment.text)) return false;
view.dispatch({ effects: toggleRenderedView.of(comment.from) });
return true;
}
/**
* Colours come from the colour scheme's doc comment attributes, as IDEA's rendered view
* takes them: the text is the comment's colour (`--syn-doc-comment`) and code and names
* are a tag value's (`--syn-doc-comment-tag-value`). So the rendered comment reads like
* the source one, and follows the theme and any Color Scheme edit.
*/
/** Space kept right of a rendered comment, before the visible edge. */
const DOC_RIGHT_GAP = 24;
/**
* Publishes on the content the visible width of the text area, `--openidea-doc-width`,
* and the code's character width, `--openidea-doc-char`. A block widget is as wide as
* the content, which without soft wraps is as wide as the longest line, so a rendered
* comment needs the first to wrap where the reader can see, as IDEA's does; it indents
* by the second, which a font size change or zoom changes. Horizontal scrolling leaves
* both unchanged.
*/
const docMetrics = ViewPlugin.fromClass(
class {
constructor(readonly view: EditorView) {
this.measure();
}
update(update: ViewUpdate) {
if (update.geometryChanged) this.measure();
}
measure() {
this.view.requestMeasure({
key: this,
read: (view) => ({
width: view.scrollDOM.clientWidth - view.contentDOM.offsetLeft,
char: view.defaultCharacterWidth,
}),
write: ({ width, char }, view) => {
const style = view.contentDOM.style;
const widthValue = `${Math.max(0, Math.floor(width))}px`;
if (style.getPropertyValue("--openidea-doc-width") !== widthValue)
style.setProperty("--openidea-doc-width", widthValue);
const charValue = `${char}px`;
if (style.getPropertyValue("--openidea-doc-char") !== charValue)
style.setProperty("--openidea-doc-char", charValue);
},
});
}
},
);
const docsTheme = EditorView.theme({
".cm-openidea-renderedDoc": {
// No vertical margin: the editor measures a block widget by its border box, so a
// margin would push every line below it away from its line number.
margin: "0",
// The content keeps its whitespace (`pre`); prose reflows instead, so the comment's
// own line breaks do not carry over and its paragraphs wrap at the visible width.
whiteSpace: "normal",
overflowWrap: "break-word",
padding: "4px 0 6px 12px",
borderLeft: "2px solid var(--border)",
color: "var(--syn-doc-comment)",
fontFamily: "var(--font-ui)",
fontSize: "var(--fs-ui)",
lineHeight: "1.55",
cursor: "auto",
WebkitUserSelect: "text",
userSelect: "text",
},
".cm-openidea-renderedDoc p": { margin: "0 0 6px" },
".cm-openidea-renderedDoc p:last-child": { marginBottom: "0" },
".cm-openidea-renderedDoc code": {
fontFamily: "var(--font-mono)",
fontSize: "0.92em",
color: "var(--syn-doc-comment-tag-value)",
padding: "0 4px",
borderRadius: "var(--radius-small)",
background: "var(--bg-elev)",
border: "1px solid var(--border)",
},
".cm-openidea-renderedDoc pre": {
margin: "0 0 6px",
whiteSpace: "pre",
padding: "6px 8px",
overflowX: "auto",
borderRadius: "var(--radius-small)",
background: "var(--bg-elev)",
},
".cm-openidea-renderedDoc pre code": { padding: "0", background: "none", border: "none" },
".cm-openidea-renderedDoc ul, .cm-openidea-renderedDoc ol": { margin: "0 0 6px", paddingLeft: "20px" },
// As specific as the code rule it must beat, or a link takes the plain code colour.
".cm-openidea-renderedDoc code.cm-openidea-docLink": { color: "var(--accent)" },
".cm-openidea-renderedDocSections": {
display: "grid",
gridTemplateColumns: "max-content 1fr",
gap: "2px 8px",
margin: "8px 0 0",
},
".cm-openidea-renderedDoc code.cm-openidea-docType": { color: "var(--syn-doc-comment)" },
".cm-openidea-renderedDocSections dt": { color: "var(--syn-doc-comment)" },
".cm-openidea-renderedDocSections dd": { margin: "0" },
".cm-openidea-renderedDocText p": { display: "inline", margin: "0" },
".cm-openidea-docToggles .cm-gutterElement": {
display: "flex",
alignItems: "flex-start",
justifyContent: "center",
padding: "0 2px",
},
".cm-openidea-docToggle": {
display: "inline-flex",
alignItems: "center",
height: "var(--openidea-editor-line-height, var(--lh-code))",
padding: "0",
border: "none",
background: "none",
color: "var(--fg-muted)",
cursor: "pointer",
},
".cm-openidea-docToggle:hover": { color: "var(--fg-strong)" },
});
/** Installed always; a file without doc comments gets no column and no decorations. */
export const renderedDocsExtension: Extension = [
flipped,
docsField,
passiveDocs,
passiveScanner,
hovered,
hoverTracker,
showSourceAtCaret,
toggleGutter,
markdownReady,
languageWatcher,
docMetrics,
docsTheme,
];
+7
Переглянути файл
@@ -42,6 +42,7 @@ import { rainbowBrackets } from "./rainbowBrackets";
import { stickyMaxLines, stickyScroll } from "./stickyScroll";
import { findExtension } from "./find";
import { codeLensExtension } from "./codeLens";
import { renderedDocsExtension } from "./renderedDocs";
import { caretExtension, fontZoom, methodLinesField, prefsExtension, softWrapActive } from "./prefs";
import { commentTokens } from "./languages";
import { multiCaretMouse } from "./multiCaret";
@@ -275,12 +276,14 @@ export function prefsFor(o: {
info: LanguageInfo | undefined;
readOnly: boolean;
wrapOverride?: boolean | null;
renderedDocsOverride?: boolean | null;
}): Extension {
return prefsExtension({
prefs: prefsOf(o.settings),
path: o.path,
softWrap: o.settings?.softWrap,
wrapOverride: o.wrapOverride,
renderedDocsOverride: o.renderedDocsOverride,
showWhitespace: o.settings?.showWhitespace,
hardWrapAt: hardWrapAt(o.settings, o.info),
readOnly: o.readOnly,
@@ -314,6 +317,8 @@ export interface SetupOptions {
formatIndent?: EffectiveIndent | null;
/** Soft wraps toggled for this file (View › Active Editor › Soft-Wrap). */
wrapOverride?: boolean | null;
/** Rendered doc comments toggled for this file (View › Active Editor › Render Documentation Comments). */
renderedDocsOverride?: boolean | null;
/** Extra extensions owned by the slot (update listeners, focus handlers). */
extra?: Extension;
/** Standalone value editors supply their own extensions without file-backed contributions. */
@@ -384,6 +389,7 @@ export function baseExtensions(o: SetupOptions): Extension {
overviewLane,
cornerInset,
codeLensExtension,
renderedDocsExtension,
EditorView.contentAttributes.of({
spellcheck: "false",
autocorrect: "off",
@@ -406,6 +412,7 @@ export function baseExtensions(o: SetupOptions): Extension {
info: o.info,
readOnly: o.ctx.readOnly,
wrapOverride: o.wrapOverride,
renderedDocsOverride: o.renderedDocsOverride,
}),
),
compartments.contributions.of(o.contributions ?? contributionExtensions(o.ctx)),
+1
Переглянути файл
@@ -44,6 +44,7 @@ export function gutterMenuItems(slot: EditorSlot, line: number): MenuItem[] {
...menuGroupItems("editorGutter", { paths: [model.path], text: slot.currentState().doc.toString(), line }),
{ label: "Soft-Wrap", checked: model.softWrapOn(), run: () => model.toggleSoftWrap() },
{ label: "Configure Soft Wraps…", run: openSettings("editor.general") },
{ label: "Render Documentation Comments", checked: model.renderedDocsOn(), run: () => model.toggleRenderedDocs() },
{ kind: "separator" },
{ label: "Appearance", submenu: appearanceItems() },
{ label: "Configure Gutter Icons…", run: openSettings("editor.general.gutterIcons") },
+14
Переглянути файл
@@ -469,6 +469,18 @@ export class DocModel {
this.applySettings();
}
/** Rendered doc comments toggled for this file (wins over settings until the file is closed). */
renderedDocsOverride: boolean | null = null;
renderedDocsOn(): boolean {
return this.renderedDocsOverride ?? prefsOf(this.settings()).appearance.renderDocComments;
}
toggleRenderedDocs() {
this.renderedDocsOverride = !this.renderedDocsOn();
this.applySettings();
}
setIndent(indent: { tabSize: number; insertSpaces: boolean }) {
this.indentOverride = indent;
this.applySettings();
@@ -497,6 +509,7 @@ export class DocModel {
info: this.info,
readOnly: this.meta.readOnly,
wrapOverride: this.softWrapOverride,
renderedDocsOverride: this.renderedDocsOverride,
}),
),
];
@@ -532,6 +545,7 @@ export class DocModel {
info: this.info,
readOnly,
wrapOverride: this.softWrapOverride,
renderedDocsOverride: this.renderedDocsOverride,
}),
),
]);
+1
Переглянути файл
@@ -117,6 +117,7 @@ export class EditorSlot {
indent: model.effectiveIndent(),
formatIndent: model.formatIndent(),
wrapOverride: model.softWrapOverride,
renderedDocsOverride: model.renderedDocsOverride,
extra: this.slotExtensions(),
}),
],
+10
Переглянути файл
@@ -13,6 +13,7 @@ import {
completeFromList,
completionStatus,
currentCompletions,
moveCompletionSelection,
pickedCompletion,
selectedCompletion,
snippet,
@@ -630,6 +631,12 @@ const lookupKeys = Prec.highest(
}),
);
function moveLookup(view: EditorView, forward: boolean): boolean {
if (completionStatus(view.state) !== "active") return false;
focusedLookups.add(view);
return moveCompletionSelection(forward)(view);
}
/** IDEA inserts a sole candidate right away on explicit invocation (Code Completion › Automatically insert single suggestions). */
const AUTO_INSERT_DELAY = 80; // past CodeMirror's interactionDelay
@@ -815,6 +822,9 @@ export function completionExtension() {
lookupTracker,
Prec.highest(
keymap.of([
// macOS: ⌃N / ⌃P are line down / up in text, so they move through the lookup as ↓ / ↑ do.
{ mac: "Ctrl-n", run: (view) => moveLookup(view, true) },
{ mac: "Ctrl-p", run: (view) => moveLookup(view, false) },
{
key: "Tab",
run: (view) => {
+2 -2
Переглянути файл
@@ -56,7 +56,7 @@ async function applyEdit(view: EditorView, edit: WorkspaceEdit) {
async function prepareIndexes(view: EditorView, path: string): Promise<boolean> {
const before = view.state.doc;
try {
await synchronizeDocumentIndexes(path);
await synchronizeDocumentIndexes(path, "refactor");
return view.state.doc === before;
} catch (e) {
reportLspError(view, e, true);
@@ -269,7 +269,7 @@ async function finishRename(view: EditorView, doc: EditorDocContext, pos: Pos, o
let renamed;
const before = view.state.doc;
try {
await synchronizeDocumentIndexes(doc.path);
await synchronizeDocumentIndexes(doc.path, "refactor");
if (renameRequest !== request || view.state.doc !== before) return;
renamed = await lspRequest(() => commands.lspRename(request, doc.doc, pos, newName));
if (renameRequest !== request) return;
+4 -1
Переглянути файл
@@ -192,7 +192,10 @@ export const lspTheme = EditorView.theme({
".cm-lsp-doc-signature": { marginTop: "0", color: "var(--fg-emphasis)" },
// --- hover / quick documentation / signature / hints ---------------------
".cm-tooltip.cm-tooltip-hover": { ...tooltipSurface },
// Near the window edge CodeMirror caps the tooltip's height to the space left: its sections then
// shrink and scroll inside the popup instead of running past its border.
".cm-tooltip.cm-tooltip-hover": { ...tooltipSurface, display: "flex", flexDirection: "column" },
".cm-tooltip-hover > .cm-tooltip-section": { minHeight: 0, flexShrink: 1 },
".cm-lsp-hover": { maxWidth: "580px", maxHeight: "380px", overflow: "auto", padding: "10px 14px" },
".cm-lsp-hover-diagnostics + .cm-lsp-markdown": {
borderTop: "1px solid var(--border-popup)",
+18 -1
Переглянути файл
@@ -160,7 +160,7 @@ describe("findUsages", () => {
const pending = findUsages();
const at = (line: number) => ({ path: "/p/b.rs", range: { start: { line, col: 4 }, end: { line, col: 8 } } });
try {
await vi.waitFor(() => expect(sync).toHaveBeenCalledWith("/p/a.rs"));
await vi.waitFor(() => expect(sync).toHaveBeenCalledWith("/p/a.rs", "usages"));
expect(lspReferences).not.toHaveBeenCalled();
expect(channels).toHaveLength(allocated);
release();
@@ -181,6 +181,23 @@ describe("findUsages", () => {
});
});
describe("findUsages with a contributed index that is not up to date", () => {
it("still searches, and says plugin usages may be missing", async () => {
const off = registerDocumentIndexSync(async () => false);
const at = { path: "/p/b.rs", range: { start: { line: 1, col: 4 }, end: { line: 1, col: 8 } } };
referenceResponse.mockResolvedValueOnce({ locations: [at], incomplete: null });
try {
await findUsages();
const search = activeUsageSearch(useUsages.getState())!;
expect(search.groups).toHaveLength(1);
expect(search.note).toBe("May be incomplete: plugin indexes are still updating");
} finally {
off();
resetUsages();
}
});
});
describe("usage tabs", () => {
const search = (id: number) => ({
id,
+3 -1
Переглянути файл
@@ -6,6 +6,7 @@ import { clearLineTokens } from "@/editor/lineTokens";
import { showPendingHint } from "@/editor/lsp/hint";
import { incompleteNote } from "@/editor/lsp/lsp";
import { commands, IpcError, isCancelled, nextRequestId } from "@/platform/ipc";
import { tx } from "@/platform/i18n";
import { shortcutLabel } from "@/platform/keymap";
import { synchronizeDocumentIndexes, toolWindowService } from "@/workbench/contributions";
import {
@@ -230,7 +231,7 @@ export async function fetchUsages(
let note: string | null = null;
let librariesSkipped = false;
await caretSynced(c);
await synchronizeDocumentIndexes(c.path);
const indexed = await synchronizeDocumentIndexes(c.path, "usages");
if (c.view && c.view.state.doc !== before) throw new IpcError({ kind: "cancelled", message: "", data: null });
let settled = false;
const channel = new Channel<References>();
@@ -268,6 +269,7 @@ export async function fetchUsages(
locs = fb.locs;
note = fb.note;
}
if (!indexed) note ??= tx("May be incomplete: plugin indexes are still updating");
const files = await readManyFileLines(pathsToRead(locs, classified));
return { groups: groupUsages(locs, files, classified), note, librariesSkipped };
}
+12
Переглянути файл
@@ -95,6 +95,18 @@ describe("Java debugging (java-debug through jdtls)", () => {
});
});
it("echoes the equivalent Java command in the Debug console", async () => {
const report = vi.fn();
await javaLaunchConfig(app, "/p", {
request: 1,
signal: new AbortController().signal,
report,
});
expect(report).toHaveBeenCalledWith(
'/jdk21/bin/java -Xmx1g -cp /p/target/classes:/m2/a.jar a.Main --port "8081 x"\n',
);
});
it("debugs Spring Boot with coloured console output, as Run does", async () => {
const boot = { ...app, kind: "spring-boot" };
expect(JSON.parse((await javaLaunchConfig(boot, "/p")).extraJson!).vmArgs).toBe(
+3
Переглянути файл
@@ -139,6 +139,9 @@ export async function javaLaunchConfig(
const classPaths = parsed.explicitClasspath
? parsed.explicitClasspath.split(info.pathListSeparator)
: info.classPaths;
// java-debug starts the JVM itself, bypassing the Run process that echoes its
// command line. Echo the equivalent launch here so Debug and Run stay comparable.
preparation?.report?.(`${joinJavaArgs([resolved.command, ...resolved.args])}\n`);
if (kotlin) {
return {
name: rc.name,
+15 -4
Переглянути файл
@@ -15,7 +15,9 @@ import { EmptyState, IconButton, showContextMenu, T, type MenuItem } from "@/ui"
import { formatBinding, hasPrimaryModifier, hostBinding, registerKeyClaim } from "@/platform/keymap";
import { tx } from "@/platform/i18n";
import { useWorkflowSettings } from "@/platform/workflowSettings";
import { useConsoleFont } from "@/platform/consoleFont";
import { spanStyle } from "@/ui/ansi";
import { logLineColor } from "@/ui/logColors";
import { ConsoleFindBar } from "@/ui/ConsoleFindBar";
import { DEFAULT_FIND_OPTIONS, findInLines, type FindOptions, type FindResult } from "@/ui/consoleFind";
import {
@@ -117,8 +119,13 @@ export function DebugConsole({ session, interactive = true }: { session: DebugSe
const [follow, setFollow] = useState(true);
const stick = useRef(true);
stick.current = follow;
// The debug console uses the same font as the Run console.
const consoleFont = useConsoleFont();
const { rowHeight } = consoleFont;
const fontStyle = { fontFamily: consoleFont.family, fontSize: `${consoleFont.size}px` };
// Settings › Console › Use soft wraps is the default; the toggle overrides it for this console.
const defaultWrap = useWorkflowSettings((s) => s.console.softWrap);
const logColors = useWorkflowSettings((s) => s.console.logColors);
const [wrapOverride, setWrapOverride] = useState<boolean | null>(null);
const softWrap = wrapOverride ?? defaultWrap;
const programmatic = useRef(false);
@@ -140,13 +147,13 @@ export function DebugConsole({ session, interactive = true }: { session: DebugSe
const virtualizer = useVirtualizer({
count: lines?.length ?? 0,
getScrollElement: () => scroller.current,
estimateSize: () => 18,
estimateSize: () => rowHeight,
overscan: 30,
getItemKey: (i) => lines?.[i]?.id ?? i,
});
// Wrapping changes every row's height; the end is pinned again below.
useLayoutEffect(() => virtualizer.measure?.(), [softWrap, virtualizer]);
useLayoutEffect(() => virtualizer.measure?.(), [softWrap, rowHeight, virtualizer]);
useLayoutEffect(() => {
const el = scroller.current;
@@ -429,6 +436,7 @@ export function DebugConsole({ session, interactive = true }: { session: DebugSe
<div
ref={scroller}
className={clsx(styles.consoleOutput, !softWrap && styles.noWrap)}
style={{ ...fontStyle, lineHeight: `${rowHeight}px` }}
onScroll={(e) => {
if (programmatic.current) return;
const el = e.currentTarget;
@@ -448,13 +456,15 @@ export function DebugConsole({ session, interactive = true }: { session: DebugSe
{virtualizer.getVirtualItems().map((item) => {
const l = lines?.[item.index];
if (!l) return null;
// The level colour is the inherited fallback; spans with their own ANSI colour win.
const color = logLineColor(l.text, l.category, logColors);
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)` }}
style={{ transform: `translateY(${item.start}px)`, color }}
>
<DebugLine line={l} marks={marksByLine.get(item.index)} length={l.text.length} cwd={cwd} />
</span>
@@ -465,12 +475,13 @@ export function DebugConsole({ session, interactive = true }: { session: DebugSe
<div className={styles.repl}>
{interactive && (
<>
<span className={styles.prompt} aria-hidden>
<span className={styles.prompt} style={fontStyle} aria-hidden>
›
</span>
<input
ref={inputRef}
className={styles.evalInput}
style={fontStyle}
onFocus={() => setConsoleHistoryBrowser(browseHistory)}
onBlur={() => {
setConsoleHistoryBrowser(null);
+31 -5
Переглянути файл
@@ -135,7 +135,7 @@ it("flushes pending changes before usages or rename without waiting for the debo
await vi.advanceTimersByTimeAsync(250);
mocks.index.mockClear();
mocks.events.get("fsChanged")?.({ created: [], modified: ["/project/A.xml"], removed: [] });
await synchronizeDocumentIndexes("/project/A.xml");
await synchronizeDocumentIndexes("/project/A.xml", "refactor");
expect(mocks.index).toHaveBeenCalledExactlyOnceWith(["/project/A.xml"], expect.any(Number));
});
@@ -153,7 +153,7 @@ it("waits for an active batch, then indexes changes queued while it was running"
await vi.advanceTimersByTimeAsync(40);
mocks.events.get("fsChanged")?.({ created: [], modified: ["/project/A.java"], removed: [] });
let settled = false;
const waiting = synchronizeDocumentIndexes("/project/A.xml").then(() => {
const waiting = synchronizeDocumentIndexes("/project/A.xml", "refactor").then(() => {
settled = true;
});
await Promise.resolve();
@@ -168,7 +168,33 @@ it("refuses refactoring if a queued index batch failed", async () => {
await vi.advanceTimersByTimeAsync(250);
mocks.index.mockResolvedValueOnce({ status: "error", error: { kind: "unavailable", message: "indexer failed" } });
mocks.events.get("fsChanged")?.({ created: [], modified: ["/project/A.xml"], removed: [] });
await expect(synchronizeDocumentIndexes("/project/A.xml")).rejects.toThrow("Plugin indexes are still updating");
await expect(synchronizeDocumentIndexes("/project/A.xml", "refactor")).rejects.toThrow(
"Plugin indexes could not be updated",
);
});
it("lets usages go ahead, marked incomplete, while a failed batch keeps failing", async () => {
off = startPluginNavigation();
await vi.advanceTimersByTimeAsync(250);
const failure = { status: "error", error: { kind: "unavailable", message: "indexer failed" } };
mocks.index.mockResolvedValue(failure);
mocks.events.get("fsChanged")?.({ created: [], modified: ["/project/A.xml"], removed: [] });
await vi.advanceTimersByTimeAsync(40);
// Every later search used to fail with "still updating" although nothing was running.
await expect(synchronizeDocumentIndexes("/project/A.java", "usages")).resolves.toBe(false);
await expect(synchronizeDocumentIndexes("/project/A.java", "usages")).resolves.toBe(false);
});
it("retries a failed batch before the next usages or refactoring", async () => {
off = startPluginNavigation();
await vi.advanceTimersByTimeAsync(250);
mocks.index.mockResolvedValueOnce({ status: "error", error: { kind: "unavailable", message: "indexer failed" } });
mocks.events.get("fsChanged")?.({ created: [], modified: ["/project/A.xml"], removed: [] });
await vi.advanceTimersByTimeAsync(40);
mocks.index.mockClear();
await expect(synchronizeDocumentIndexes("/project/A.java", "refactor")).resolves.toBe(true);
expect(mocks.index).toHaveBeenCalledWith(["/project/A.xml"], expect.any(Number));
await expect(synchronizeDocumentIndexes("/project/A.java", "usages")).resolves.toBe(true);
});
it.each(["/project/notes.txt", "/external/Other.xml", "jdt://contents/Other.xml"])(
@@ -179,13 +205,13 @@ it.each(["/project/notes.txt", "/external/Other.xml", "jdt://contents/Other.xml"
mocks.index.mockImplementationOnce(() => new Promise(() => {}));
mocks.events.get("fsChanged")?.({ created: [], modified: ["/project/A.xml"], removed: [] });
await vi.advanceTimersByTimeAsync(40);
await synchronizeDocumentIndexes(path);
await synchronizeDocumentIndexes(path, "refactor");
},
);
it("keeps unrelated language-server queries available when plugin discovery fails", async () => {
mocks.files.mockRejectedValue(new Error("Plugin file inventory is unavailable"));
off = startPluginNavigation();
await expect(synchronizeDocumentIndexes("/project/notes.txt")).resolves.toBeUndefined();
await expect(synchronizeDocumentIndexes("/project/notes.txt", "refactor")).resolves.toBe(true);
expect(mocks.index).not.toHaveBeenCalled();
});
+31 -6
Переглянути файл
@@ -22,9 +22,12 @@ export function startPluginNavigation(): () => void {
let timer: ReturnType<typeof setTimeout> | undefined;
let pending = new Set<string>();
const dirty = new Set<string>();
/** Paths whose last index batch failed: retried on the next usages or refactoring. */
const failed = new Set<string>();
const views = new Map<EditorView, string>();
let discovered = false;
let discovering = false;
let discoveryFailed = false;
const known = new Set<string>();
let signature = "";
let reportedError = false;
@@ -79,13 +82,22 @@ export function startPluginNavigation(): () => void {
await call(commands.pluginNavigationIndex(paths, id));
if (at === generation) {
paths.forEach((p) => {
failed.delete(p);
if (!pending.has(p)) dirty.delete(p);
});
notify();
}
} catch (error) {
if (at === generation && isCancelled(error)) paths.forEach((p) => pending.add(p));
else if (at === generation) report(error);
else if (at === generation) {
// Not "still updating": the batch is done, its paths just have no fresh index.
paths.forEach((p) => {
if (pending.has(p)) return;
dirty.delete(p);
failed.add(p);
});
report(error);
}
} finally {
request = null;
running = false;
@@ -120,12 +132,14 @@ export function startPluginNavigation(): () => void {
if (at !== generation) return;
fileExtensions = new Set(inventory.extensions);
discovered = !inventory.indexing;
discoveryFailed = false;
const fresh = inventory.paths.filter((p) => !known.has(p));
inventory.paths.forEach((p) => known.add(p));
queue(fresh);
queue([...views.values()], true);
} catch (error) {
discovered = false;
discoveryFailed = true;
report(error);
} finally {
if (at === generation) discovering = false;
@@ -139,9 +153,11 @@ export function startPluginNavigation(): () => void {
clearTimeout(timer);
pending.clear();
dirty.clear();
failed.clear();
known.clear();
discovered = false;
discovering = false;
discoveryFailed = false;
discovery = null;
reportedError = false;
notify();
@@ -151,32 +167,41 @@ export function startPluginNavigation(): () => void {
.catch(() => {});
}
const offs = [
registerDocumentIndexSync(async (path) => {
registerDocumentIndexSync(async (path, purpose) => {
if (!ready()) return;
const root = useProject.getState().project!.root;
if (!path.startsWith(root.endsWith("/") ? root : `${root}/`)) return;
const at = generation;
await discover();
if (!eligible(path)) return;
if (failed.size) queue([...failed]);
while (ready() && at === generation && (pending.size || flushing)) {
clearTimeout(timer);
await flush();
}
if (at !== generation || !discovered || dirty.size) {
throw new Error(tx`Plugin indexes are still updating; try again shortly`);
const complete = at === generation && discovered && !dirty.size && !failed.size;
if (complete) return true;
if (purpose === "usages") return false;
if (at === generation && (failed.size || discoveryFailed)) {
throw new Error(tx`Plugin indexes could not be updated; see the plugin notification`);
}
throw new Error(tx`Plugin indexes are still updating; try again shortly`);
}),
pluginsBackend().onEvent((event) => {
if (event.type === "documentIndexesChanged") reset();
}),
registerNavigationProvider(async (view, offset) => {
const doc = docOf(view);
if (!ready() || !doc || doc.virtual || !eligible(doc.path) || dirty.has(doc.path)) return null;
if (!ready() || !doc || doc.virtual || !eligible(doc.path) || dirty.has(doc.path) || failed.has(doc.path))
return null;
const before = view.state.doc;
await whenSynced(view);
const result = await call(commands.pluginNavigationGoto(doc.doc, offsetToPos(before, offset)));
if (!result || view.state.doc !== before) return null;
return { ...rangeToOffsets(before, result.range), targets: result.targets.filter((t) => !dirty.has(t.path)) };
return {
...rangeToOffsets(before, result.range),
targets: result.targets.filter((t) => !dirty.has(t.path) && !failed.has(t.path)),
};
}),
registerEditorContribution({
id: "plugins.navigation",
+8 -7
Переглянути файл
@@ -14,10 +14,10 @@ import {
import clsx from "clsx";
import { useVirtualizer } from "@tanstack/react-virtual";
import { useWorkflowSettings } from "@/platform/workflowSettings";
import { useProject } from "@/platform/project";
import { resolveMonoFont } from "@/platform/fonts";
import { useConsoleFont } from "@/platform/consoleFont";
import { Icon, showContextMenu } from "@/ui";
import { spanStyle } from "@/ui/ansi";
import { logLineColor } from "@/ui/logColors";
import type { ConsoleLine, ConsoleModel } from "./console";
import { findOutputLinks, type OutputLink } from "./links";
import { openTarget, openUrl } from "./navigate";
@@ -183,12 +183,11 @@ export function ConsoleView({
const innerRef = useRef<HTMLDivElement>(null);
const lines = model.lines;
const patterns = useWorkflowSettings((s) => s.console.foldPatterns);
const logColors = useWorkflowSettings((s) => s.console.logColors);
// The Run console uses Tools › Terminal › Font (which can follow Editor › Font),
// so the font and fallback configured there apply to process output too.
const terminalFont = useWorkflowSettings((s) => s.terminal);
const editorFont = useProject((s) => s.settings?.editor);
const consoleFont = resolveMonoFont(terminalFont, editorFont);
const rowHeight = Math.max(16, Math.round(consoleFont.size * 1.5));
const consoleFont = useConsoleFont();
const { rowHeight } = consoleFont;
/** A row's box: at its virtual offset, and never shorter than one line — a blank line has no text. */
const rowStyle = (start: number) => ({
transform: `translateY(${start}px)`,
@@ -595,13 +594,15 @@ export function ConsoleView({
}
const line = lines[row.index];
if (!line) return null;
// The level colour is the inherited fallback; spans with their own ANSI colour win.
const color = logLineColor(line.text, line.kind, logColors);
return (
<div
key={item.key}
data-index={item.index}
ref={softWrap ? virtualizer.measureElement : undefined}
className={clsx(styles.line, styles[line.kind])}
style={rowStyle(item.start)}
style={{ ...rowStyle(item.start), color }}
>
<Line line={line} marks={marksByLine.get(row.index)} length={line.text.length} cwd={cwd} />
{row.fold && foldButton(row.fold, true)}
+64
Переглянути файл
@@ -0,0 +1,64 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { useProject } from "@/platform/project";
import { ConsoleModel } from "./console";
import { ConsoleView } from "./ConsoleView";
vi.mock("@tanstack/react-virtual", () => ({
useVirtualizer: ({ count }: { count: number }) => ({
getTotalSize: () => count * 20,
getVirtualItems: () => Array.from({ length: count }, (_, index) => ({ index, start: index * 20, key: index })),
scrollToIndex: () => {},
measureElement: () => {},
measure: () => {},
}),
}));
function setup(
chunks: { kind: "stdout" | "stderr" | "system"; text: string }[],
logColors: object = { enabled: true },
) {
const model = new ConsoleModel();
for (const c of chunks) model.append(c);
useProject.setState({ settings: { console: { logColors } } as never });
render(<ConsoleView model={model} version={1} softWrap={false} follow={false} onFollowChange={() => {}} cwd="/" />);
}
const rowOf = (text: string) =>
Array.from(screen.getByRole("log").querySelectorAll<HTMLElement>("[data-index]")).find((d) =>
d.textContent?.includes(text),
)!;
describe("console log colours", () => {
it("colours a level line with the level's colour", () => {
setup([{ kind: "stdout", text: "2026-11-05 10:42:39.123 INFO 4242 --- [nio-8080-exec-1] c.a.S : done\n" }]);
expect(rowOf("INFO").style.color).toBe("var(--ansi-green)");
});
it("keeps ANSI colours printed by the program", () => {
setup([{ kind: "stdout", text: "[ERROR] \x1b[34mblue part\x1b[0m rest\n" }]);
const row = rowOf("ERROR");
expect(row.style.color).toBe("var(--ansi-red)");
const ansi = Array.from(row.querySelectorAll("span")).find((s) => s.textContent === "blue part")!;
expect(ansi.style.color).toBe("var(--ansi-blue)");
});
it("leaves plain and system lines uncoloured", () => {
setup([
{ kind: "stdout", text: "plain line\n" },
{ kind: "system", text: "INFO Process finished with exit code 0\n" },
]);
expect(rowOf("plain line").style.color).toBe("");
expect(rowOf("Process finished").style.color).toBe("");
});
it("is off by default", () => {
setup([{ kind: "stdout", text: "INFO plain\n" }], {});
expect(rowOf("INFO").style.color).toBe("");
});
it("does no colouring when disabled", () => {
setup([{ kind: "stdout", text: "INFO plain\n" }], { enabled: false });
expect(rowOf("INFO").style.color).toBe("");
});
});
+7 -1
Переглянути файл
@@ -44,7 +44,12 @@ import { invalidateGoModules } from "./goModules";
import { invalidatePythonPackages } from "./pythonPackages";
import { RunWidget } from "./RunWidget";
import { runConfigsRunAnything } from "./runAnything";
import { cleanupRemovedProject, registerRunServiceActions, runServiceProvider } from "./services";
import {
cleanupRemovedProject,
registerRunServiceActions,
runServiceProvider,
watchServiceRunningCount,
} from "./services";
import { liveServiceSession, serviceSessions } from "./serviceSessions";
import { ServiceImportHost } from "./serviceImport";
import { cmakeBuildId } from "./cmake";
@@ -396,6 +401,7 @@ export function activate(): () => void {
registerOverlay({ id: "run.serviceImport", component: ServiceImportHost }),
registerServiceProvider(runServiceProvider),
registerRunServiceActions(),
watchServiceRunningCount(),
registerRunningProcessesProvider({
id: "run",
running: () =>
+66
Переглянути файл
@@ -21,6 +21,7 @@ import {
readHidden,
readTypes,
runServiceItems,
runningServiceCount,
sessionDetail,
sessionStatus,
} from "./services";
@@ -375,6 +376,71 @@ describe("hidden configurations", () => {
});
});
describe("running service count (the Services badge)", () => {
const dev = config("dev", "npm");
const app = config("app", "spring-boot");
beforeEach(() => {
localStorage.clear();
useProject.setState({ project: { root: "/badge" } as never });
useRun.setState({ configs: [], sessions: [] });
useDebugRunSessions.setState({ serviceSessions: [] });
addType("npm");
});
afterEach(() => useProject.setState({ project: null }));
it("counts one per listed running configuration", () => {
expect(runningServiceCount()).toBeNull();
useRun.setState({ configs: [dev], sessions: [session("s1", dev, "running", 1)] });
expect(runningServiceCount()).toBe(1);
// A second parallel instance of the same configuration is still one service.
useRun.setState((s) => ({ sessions: [...s.sessions, session("s2", dev, "running", 2)] }));
expect(runningServiceCount()).toBe(1);
useRun.setState({ sessions: [session("s1", dev, "finished", 1)] });
expect(runningServiceCount()).toBeNull();
});
it("leaves out types not added and Docker kinds, and counts a live debugger", () => {
const compose = config("compose", "docker-compose");
useRun.setState({
configs: [dev, app, compose],
sessions: [
session("s1", dev, "running", 1),
session("s2", app, "running", 2),
session("s3", compose, "running", 3),
],
});
expect(runningServiceCount()).toBe(1);
const debugged: DebugServiceSession = {
id: 1,
config: app,
status: "running",
processId: null,
startedAt: 4,
stop: async () => {},
rerun: null,
show: () => {},
};
// `app` is not added yet: its live debugger does not count.
useDebugRunSessions.setState({ serviceSessions: [debugged] });
expect(runningServiceCount()).toBe(1);
addType("spring-boot");
expect(runningServiceCount()).toBe(2);
});
it("does not count a configuration removed from the window while it runs", () => {
useRun.setState({ configs: [dev], sessions: [session("s1", dev, "running", 1)] });
const node = { key: "run:config:dev", label: "dev", icon: "" };
for (const entry of runServiceProvider.menu!(node)) {
if (entry.kind === "separator" || entry.kind === "header" || entry.kind === "action") continue;
if (entry.label === "Remove from Services") {
entry.run!();
break;
}
}
expect(runningServiceCount()).toBeNull();
});
});
describe("add service categories", () => {
it("categorizes every built-in kind; update the table when adding kinds", () => {
const expected: Record<string, string> = {
+44
Переглянути файл
@@ -20,6 +20,7 @@ import {
notifications,
useRunConfigTypes,
useDebugRunSessions,
useServicesRunningCount,
type DebugServiceSession,
type ServiceItemAction,
type ServiceProviderContribution,
@@ -241,6 +242,49 @@ async function pickForKind(kind: string) {
saveHidden({ ...currentHidden(), [kind]: remaining });
}
// ---------------------------------------------------------------------------
// Running count (the Services tool window's badge)
/**
* How many services the window lists are running: one per configuration (a run
* and its attached debugger are one service), hidden configurations excluded.
* Docker kinds are left out: their containers show under the Docker provider.
*/
export function runningServiceCount(): number | null {
const root = useProject.getState().project?.root ?? null;
const kinds = addedTypes(root).filter((kind) => !DOCKER_PROVIDER_KINDS.has(kind));
if (!kinds.length) return null;
const hidden = currentHidden();
const executions = serviceSessions(useRun.getState().sessions, useDebugRunSessions.getState().serviceSessions);
const running = new Set<string>();
for (const s of executions) {
if (!liveServiceSession(s) || !kinds.includes(s.config.kind)) continue;
if ((hidden[s.config.kind] ?? []).includes(s.config.id)) continue;
running.add(s.config.id);
}
return running.size || null;
}
/** Keeps the Services window's badge current (`useServicesRunningCount`); returns the unsubscribe. */
export function watchServiceRunningCount(): () => void {
const update = () => useServicesRunningCount.setState({ count: runningServiceCount() });
update();
const disposers = [
useRun.subscribe((s, prev) => {
if (s.sessions !== prev.sessions) update();
}),
useDebugRunSessions.subscribe((s, prev) => {
if (s.serviceSessions !== prev.serviceSessions) update();
}),
useAddedTypes.subscribe(update),
useHiddenConfigs.subscribe(update),
useProject.subscribe((s, prev) => {
if (s.project?.root !== prev.project?.root) update();
}),
];
return () => disposers.forEach((dispose) => dispose());
}
// ---------------------------------------------------------------------------
// Tree
+5
Переглянути файл
@@ -19,6 +19,7 @@ import {
registerServiceView,
registerToolWindow,
showServiceView,
useServicesRunningCount,
} from "@/workbench/contributions";
import { useProject } from "@/platform/project";
import { addFileIconProvider } from "@/ui";
@@ -58,6 +59,9 @@ import type { PruneTarget } from "@/platform/ipc";
const PROJECT = "projectOpen";
/** The window badge: how many of the listed services are running (the run provider tracks them). */
const useServicesBadge = () => useServicesRunningCount((s) => s.count);
/** The connection of the selection, else the first one. */
const currentConnection = () => selectedTarget()?.conn ?? connections()[0]?.id ?? null;
@@ -71,6 +75,7 @@ export function activate(): () => void {
anchor: "bottom",
order: 8,
stripe: "onUse",
useBadge: useServicesBadge,
component: ServicesHost,
headerContent: ServicesTabs,
headerActions: ServicesHeaderActions,
+72
Переглянути файл
@@ -0,0 +1,72 @@
import { describe, expect, it, vi } from "vitest";
import { fireEvent, render, renderHook, screen, waitFor } from "@testing-library/react";
import type { Sdk } from "@/platform/ipc";
const ok = <T,>(data: T) => ({ status: "ok" as const, data });
const jdk = (version: string): Sdk => ({
path: `/jdks/jdk-${version}`,
name: `JDK ${version}`,
version,
source: "system",
home: `/jdks/jdk-${version}`,
extra: null,
});
vi.mock("@/platform/ipc", async (importOriginal) => {
const real = await importOriginal<typeof import("@/platform/ipc")>();
return {
...real,
isTauri: () => true,
commands: {
...real.commands,
sdkDetect: async () => ok([jdk("21.0.3"), jdk("25.0.1")]),
jdtlsRequiredJava: async () => ok(25),
},
};
});
const { SdkField, javaMajor, useJdtlsJdk } = await import("../languages/components");
describe("language server JDK", () => {
it("reads the feature release of JDK versions", () => {
expect(javaMajor("21.0.3")).toBe(21);
expect(javaMajor("25")).toBe(25);
expect(javaMajor("1.8.0_392")).toBe(8);
expect(javaMajor(null)).toBeNull();
});
it("states what the installed jdtls needs and whether the chosen JDK meets it", async () => {
const { result, rerender } = renderHook(({ value }) => useJdtlsJdk("Runs jdtls.", value), {
initialProps: { value: "/jdks/jdk-21.0.3" },
});
await waitFor(() =>
expect(result.current.hint).toBe(
"Runs jdtls. The installed jdtls needs JDK 25 or newer; the selected JDK 21 is too old for it.",
),
);
expect(result.current.minJava).toBe(25);
rerender({ value: "/jdks/jdk-25.0.1" });
expect(result.current.hint).toBe("Runs jdtls. The installed jdtls needs JDK 25 or newer.");
rerender({ value: "" });
expect(result.current.hint).toBe("Runs jdtls. The installed jdtls needs JDK 25 or newer.");
});
it("marks JDKs older than jdtls needs", async () => {
render(
<SdkField
kind="jdk"
value="/jdks/jdk-21.0.3"
onChange={() => {}}
browseTitle="Select"
aria-label="Language server JDK"
minJava={25}
/>,
);
const combo = screen.getByRole("button", { name: "Language server JDK" });
await waitFor(() => expect(combo).toHaveTextContent("JDK 21.0.3"));
fireEvent.click(combo);
const options = await screen.findAllByRole("option");
expect(options[0]).toHaveTextContent("too old, needs JDK 25+ · /jdks/jdk-21.0.3");
expect(options[1]).toHaveTextContent("/jdks/jdk-25.0.1");
expect(options[1]).not.toHaveTextContent("too old");
});
});
+9 -3
Переглянути файл
@@ -13,7 +13,7 @@ import { JAVA_LANGUAGE_LEVELS, PHP_LANGUAGE_LEVELS } from "@/platform/languageSe
import { GlobListEditor, Row, Section, Select, Toggle } from "../components/Form";
import { choosePath } from "./api";
import { usePageContext, useSetting } from "../components/context";
import { SdkField, tildify, useDetected } from "./components";
import { SdkField, tildify, useDetected, useJdtlsJdk } from "./components";
import styles from "./Sdk.module.css";
function PathInput({
@@ -391,6 +391,11 @@ export function JavaPage() {
const sharedDisabled = scope === "project" && workspace;
const [vmArgs, setVmArgs] = useSetting<string>("languages.java.jdtlsVmArgs");
const [languageLevel, setLanguageLevel] = useSetting<string>("languages.java.languageLevel");
const [jdtlsJdk] = useSetting<string>("languages.java.jdtlsJdk");
const jdtls = useJdtlsJdk(
"Runs jdtls only; the project keeps its Project JDK. When empty, a suitable installed JDK is used first, otherwise Eclipse Temurin is downloaded automatically into a separate language-server folder. The jdtls executable itself is set in Language Servers.",
jdtlsJdk ?? "",
);
const id = useId();
return (
<div className={styles.page}>
@@ -404,7 +409,7 @@ export function JavaPage() {
directory
browseTitle="Select JDK Home Directory"
aria-label="Project JDK"
hint="Compiles and runs the project: the default jdtls runtime, Run/Debug, and JAVA_HOME for Maven and Gradle."
hint="Compiles and runs the project: Run/Debug and JAVA_HOME for Maven and Gradle; it also runs jdtls when new enough for it."
/>
<Row
label="Language level"
@@ -439,7 +444,8 @@ export function JavaPage() {
directory
browseTitle="Select JDK Home Directory"
aria-label="Language server JDK"
hint="Runs jdtls only; the project keeps its Project JDK. When empty, a suitable installed JDK is used first, otherwise Eclipse Temurin is downloaded automatically into a separate language-server folder. The jdtls executable itself is set in Language Servers."
hint={jdtls.hint}
minJava={jdtls.minJava}
/>
<Row
label="JVM options"
+7 -5
Переглянути файл
@@ -62,7 +62,7 @@ import { initProjectRoots, useDetectedRoots } from "@/platform/projectRoots";
import { Row, Section } from "../components/Form";
import { choosePath, detectSdks, useSdks, type SdkKind } from "./api";
import { DownloadJdkDialog } from "./DownloadJdkDialog";
import { SdkField, tildify, useDetected, useProjectSetting } from "./components";
import { SdkField, tildify, useDetected, useJdtlsJdk, useProjectSetting } from "./components";
import styles from "./Sdk.module.css";
import { tx } from "@/platform/i18n";
@@ -304,6 +304,10 @@ function ProjectSection() {
const [languageLevel, setLanguageLevel] = useProjectSetting<string>("languages.java.languageLevel");
const [compilerOutput, setCompilerOutput] = useProjectSetting<string>("projectStructure.compilerOutput");
const [jdtlsJdk, setJdtlsJdk] = useProjectSetting<string>("languages.java.jdtlsJdk");
const jdtls = useJdtlsJdk(
"JDK used to run the Eclipse JDT Language Server, independent of the target Project SDK.",
jdtlsJdk ?? "",
);
const outId = useId();
return (
@@ -369,10 +373,7 @@ function ProjectSection() {
</Row>
</Section>
<Section title="Language Server">
<Row
label="JDK"
hint="JDK used to run the Eclipse JDT Language Server (jdtls 21+), independent of the target Project SDK."
>
<Row label="JDK" hint={jdtls.hint}>
<SdkField
kind="jdk"
value={jdtlsJdk ?? ""}
@@ -382,6 +383,7 @@ function ProjectSection() {
directory
browseTitle="Select JDK Home Directory"
aria-label="Language server JDK"
minJava={jdtls.minJava}
/>
</Row>
</Section>
+53 -2
Переглянути файл
@@ -2,6 +2,8 @@
import { useEffect, useMemo, useState } from "react";
import { IconButton, Select, Spinner, type SelectOption } from "@/ui";
import { useProject } from "@/platform/project";
import { call, commands, isTauri } from "@/platform/ipc";
import { tx } from "@/platform/i18n";
import { useSetting } from "../components/context";
import { choosePath, detectSdks, useSdks, type Sdk, type SdkKind } from "./api";
import { DownloadJdkDialog } from "./DownloadJdkDialog";
@@ -47,6 +49,8 @@ export interface SdkFieldProps {
"aria-label": string;
/** Extra fixed choices before the detected ones (e.g. `workspace`). */
extra?: SelectOption[];
/** Oldest usable Java version: older JDKs are marked, and Download JDK preselects it. */
minJava?: number | null;
}
/** The "Download JDK…" entry of JDK combos (not a value). */
@@ -62,6 +66,7 @@ export function SdkField({
directory,
browseTitle,
extra,
minJava,
...aria
}: SdkFieldProps) {
const { list, loading, refresh } = useDetected(kind);
@@ -69,11 +74,16 @@ export function SdkField({
const out: SelectOption[] = [];
if (emptyLabel !== undefined) out.push({ value: "", label: emptyLabel, detail: emptyDetail });
out.push(...(extra ?? []));
for (const sdk of list) out.push({ value: sdk.path, label: sdk.name, detail: tildify(sdk.path) });
for (const sdk of list) {
const major = minJava ? javaMajor(sdk.version) : null;
const tooOld = major !== null && !!minJava && major < minJava;
const detail = tooOld ? tx`too old, needs JDK ${minJava}+ · ${tildify(sdk.path)}` : tildify(sdk.path);
out.push({ value: sdk.path, label: sdk.name, detail });
}
if (value && !out.some((o) => o.value === value)) out.push({ value, label: tildify(value), detail: "custom" });
if (kind === "jdk") out.push({ value: DOWNLOAD_JDK, label: "Download JDK…", icon: "plus" });
return out;
}, [list, value, emptyLabel, emptyDetail, extra, kind]);
}, [list, value, emptyLabel, emptyDetail, extra, kind, minJava]);
const [downloading, setDownloading] = useState(false);
const browse = async () => {
const picked = await choosePath({ directory: !!directory, title: browseTitle, defaultPath: value || undefined });
@@ -97,6 +107,7 @@ export function SdkField({
<IconButton icon="folder-open" size="small" title="Browse…" onClick={() => void browse()} />
{downloading && (
<DownloadJdkDialog
initialVersion={minJava ?? undefined}
onClose={() => setDownloading(false)}
onDownloaded={(sdk) => {
setDownloading(false);
@@ -107,3 +118,43 @@ export function SdkField({
</span>
);
}
/** Feature release of a JDK version string: `21.0.3` → 21, `1.8.0_392` → 8. */
export function javaMajor(version: string | null | undefined): number | null {
const m = /^(\d+)(?:\.(\d+))?/.exec(version ?? "");
if (!m) return null;
const major = Number(m[1]) === 1 && m[2] ? Number(m[2]) : Number(m[1]);
return Number.isFinite(major) ? major : null;
}
/** The Java version the installed jdtls needs; `null` until known (or not installed). */
function useJdtlsRequiredJava(): number | null {
const [required, setRequired] = useState<number | null>(null);
useEffect(() => {
if (!isTauri()) return;
let live = true;
call(commands.jdtlsRequiredJava())
.then((r) => live && setRequired(r))
.catch(() => {});
return () => {
live = false;
};
}, []);
return required;
}
/**
* The language server JDK row's hint, stating the installed jdtls' Java requirement
* and whether the chosen JDK (`value`) meets it, and that requirement for [`SdkField`]'s `minJava`.
*/
export function useJdtlsJdk(hint: string, value: string): { hint: string; minJava: number | null } {
const required = useJdtlsRequiredJava();
const { list } = useDetected("jdk");
if (!required) return { hint, minJava: null };
const major = javaMajor(list.find((sdk) => sdk.path === value)?.version);
const tooOld = major !== null && major < required;
const need = tooOld
? tx`The installed jdtls needs JDK ${required} or newer; the selected JDK ${major} is too old for it.`
: tx`The installed jdtls needs JDK ${required} or newer.`;
return { hint: `${tx(hint)} ${need}`, minJava: required };
}
+5
Переглянути файл
@@ -281,6 +281,11 @@ const APPEARANCE = editorPage({
children: [{ t: "check", key: `${P}.appearance.rainbowIndentGuides`, label: "Rainbow indent guides" }],
},
{ t: "check", key: `${P}.appearance.intentionBulb`, label: "Show intention bulb" },
{
t: "check",
key: `${P}.appearance.renderDocComments`,
label: "Render documentation comments",
},
{
t: "number",
key: `${P}.appearance.distractionFreeWidth`,
+56 -2
Переглянути файл
@@ -6,7 +6,7 @@
import { CMakeProfiles } from "./cmake";
import { WORKFLOW_LIMITS } from "@/platform/workflowSettings";
import type { SelectOption } from "@/ui";
import { GlobListEditor, NumberInput, Row } from "../components/Form";
import { ColorSwatches, FormGroup, GlobListEditor, NumberInput, Row, Toggle } from "../components/Form";
import { GradlePage, MavenPage } from "../languages/LanguagePages";
import { useSetting } from "../components/context";
import type { PageSpec } from "./spec";
@@ -66,6 +66,54 @@ function FoldPatterns() {
);
}
/** The five console log levels, in severity order. */
const LOG_LEVELS = [
{ level: "error", label: "Error" },
{ level: "warn", label: "Warning" },
{ level: "info", label: "Info" },
{ level: "debug", label: "Debug" },
{ level: "trace", label: "Trace" },
] as const;
/** The level colours as presets: any level can take another level's colour. */
const LOG_COLOR_PRESETS = [
{ value: "var(--ansi-red)", label: "Red" },
{ value: "var(--ansi-yellow)", label: "Yellow" },
{ value: "var(--ansi-green)", label: "Green" },
{ value: "var(--ansi-cyan)", label: "Cyan" },
{ value: "var(--ansi-magenta)", label: "Magenta" },
];
function LogLevelColors() {
const [enabled, setEnabled] = useSetting<boolean>("console.logColors.enabled");
return (
<>
<Toggle
label="Colour log lines by level"
checked={enabled}
onChange={setEnabled}
hint="ERROR, WARN, INFO, DEBUG and TRACE lines take the colour of their level; colours the program prints itself are kept."
/>
{enabled && (
<FormGroup indented>
{LOG_LEVELS.map(({ level, label }) => (
<LogLevelRow key={level} level={level} label={label} />
))}
</FormGroup>
)}
</>
);
}
function LogLevelRow({ level, label }: { level: (typeof LOG_LEVELS)[number]["level"]; label: string }) {
const [value, setValue] = useSetting<string>(`console.logColors.${level}`);
return (
<Row label={label}>
<ColorSwatches value={value} presets={LOG_COLOR_PRESETS} onChange={setValue} aria-label={label} />
</Row>
);
}
function TooltipDelayRow() {
const [enabled] = useSetting<boolean>("debugger.valueTooltip");
const [delay, setDelay] = useSetting<number>("debugger.valueTooltipDelay");
@@ -173,7 +221,7 @@ export const EXECUTION_PAGES: PageSpec[] = [
group: BUILD_GROUP,
order: 360,
projectScope: true,
keywords: ["run output", "encoding", "fold", "buffer"],
keywords: ["run output", "encoding", "fold", "buffer", "colour"],
sections: [
{
options: [
@@ -186,6 +234,12 @@ export const EXECUTION_PAGES: PageSpec[] = [
options: CONSOLE_ENCODINGS,
hint: "Encoding of the output of processes started from Run configurations.",
},
{
kind: "custom",
label: "Colour log lines by level",
keywords: ["rainbow", "severity", "log level", "ANSI"],
component: LogLevelColors,
},
],
},
{ options: [{ kind: "custom", label: "Fold console lines that contain", component: FoldPatterns }] },
+4
Переглянути файл
@@ -79,6 +79,10 @@
text-overflow: ellipsis;
color: var(--fg-subtle);
}
.detailIcon {
flex: none;
--icon-color: var(--fg-subtle);
}
.partial {
flex: none;
padding: 0 4px;
+4 -2
Переглянути файл
@@ -568,10 +568,11 @@ export function ChangesTree({ mode }: { mode: "commit" | "local" | "dialog" }) {
const viewRef = useRef<HTMLButtonElement>(null);
const repos = useVcs((s) => s.roots);
const heads = useVcs((s) => s.heads);
const projectRoots = useEffectiveRoots();
const roots = useMemo(
() => buildChangeTree(status, root, groupByDir, lists, repos, groupByStaging),
[status, root, groupByDir, lists, repos, groupByStaging],
() => buildChangeTree(status, root, groupByDir, lists, repos, groupByStaging, heads),
[status, root, groupByDir, lists, repos, groupByStaging, heads],
);
const [byKey, parents] = useMemo(() => {
const m = new Map<string, ChangeNode>();
@@ -882,6 +883,7 @@ export function ChangesTree({ mode }: { mode: "commit" | "local" | "dialog" }) {
<T>partial</T>
</span>
)}
{n.detail && n.detailIcon && <Icon name={n.detailIcon} size={14} className={styles.detailIcon} />}
{n.detail && <span className={styles.detail}>{n.detail}</span>}
</span>
);
+82
Переглянути файл
@@ -151,6 +151,75 @@ describe("several repositories", () => {
expect(commitRepos([], repos, "/r/libs/core")).toEqual(["/r/libs/core"]);
});
it("shows each repository's branch, or its HEAD commit when detached, beside its name", () => {
const repoDetails = (tree: ChangeNode[]) =>
tree.map((g) => [g.label, ...(g.children ?? []).map((n) => `${n.label} ${n.detail ?? "-"}`)]);
const heads = {
"/r": { head: "feature/ABC-123", headCommit: "0123456789abcdef0123456789abcdef01234567" },
"/r/libs/core": { head: null, headCommit: "89abcdef0123456789abcdef0123456789abcdef" },
};
expect(repoDetails(buildChangeTree(nested, "/r", false, undefined, repos, false, heads))).toEqual([
["Merge Conflicts", "r feature/ABC-123"],
["Changes", "r feature/ABC-123", "libs/core 89abcdef"],
["Unversioned Files", "r feature/ABC-123", "libs/core 89abcdef"],
]);
const staging = buildChangeTree(nested, "/r", false, undefined, repos, true, heads);
expect(repoDetails(staging)[2]).toEqual(["Unstaged Changes", "r feature/ABC-123", "libs/core 89abcdef"]);
const lists = { lists: [{ id: "d", name: "Changes", comment: "" }], active: "d", files: {} } as never;
expect(repoDetails(buildChangeTree(nested, "/r", false, lists, repos, false, heads))[1]).toEqual([
"Changes",
"r feature/ABC-123",
"libs/core 89abcdef",
]);
// Not loaded yet, or HEAD unreadable: no hint.
const partial = { "/r/libs/core": { head: null, headCommit: null } };
expect(repoDetails(buildChangeTree(nested, "/r", false, undefined, repos, false, partial))[1]).toEqual([
"Changes",
"r -",
"libs/core -",
]);
// A repository without commits is on its unborn branch.
const unborn = { "/r/libs/core": { head: "main", headCommit: null } };
expect(repoDetails(buildChangeTree(nested, "/r", false, undefined, repos, false, unborn))[1]).toEqual([
"Changes",
"r -",
"libs/core main",
]);
expect(repoDetails(buildChangeTree(nested, "/r", false, undefined, repos))[1]).toEqual([
"Changes",
"r -",
"libs/core -",
]);
// One repository: no repository level, files keep their own details.
const single = buildChangeTree(status, "/r", false, undefined, ["/r"], false, { "/r": heads["/r"] });
expect(single).toEqual(buildChangeTree(status, "/r", false));
});
it("marks the hint with a branch icon, or a commit icon when detached, on repository nodes only", () => {
const heads = {
"/r": { head: "main", headCommit: "0123456789" },
"/r/libs/core": { head: null, headCommit: "89abcdef01" },
};
const icons = (nodes: ChangeNode[]): string[] =>
nodes.flatMap((n) => [`${n.label}:${n.detailIcon ?? "-"}`, ...icons(n.children ?? [])]);
const changes = buildChangeTree(nested, "/r", true, undefined, repos, false, heads)[1]!;
expect(icons([changes])).toEqual([
"Changes:-",
"r:branch",
"src:-",
"main/kotlin:-",
"App.kt:-",
"Partial.kt:-",
"test:-",
"AppTest.kt:-",
"README.md:-",
"libs/core:commit",
"Lib.kt:-",
]);
const unloaded = buildChangeTree(nested, "/r", true, undefined, repos)[1]!;
expect(unloaded.children!.map((n) => n.detailIcon)).toEqual([undefined, undefined]);
});
it("routes a nested repository's own entry (submodule) to the enclosing repository", () => {
expect(rootOf("/r/libs/core/Lib.kt", repos)).toBe("/r/libs/core");
expect(rootOf("/r/libs/core", repos)).toBe("/r");
@@ -214,6 +283,19 @@ describe("commit inclusion", () => {
]);
});
it("leaves out a file added to the index and then deleted, which no group lists", () => {
const files = [fs("src/A.java", null, "modified"), fs("src/Gone.java", "added", "deleted")];
expect(
buildChangeTree(files, "/r", false)
.flatMap((g) => g.children ?? [])
.map((n) => n.label),
).toEqual(["A.java"]);
const plan = planCommit(files, new Map());
expect(plan.stage).toEqual(["/r/src/A.java"]);
expect(plan.exclude).toEqual(["/r/src/Gone.java"]);
expect(plan.included.map((f) => f.relPath)).toEqual(["src/A.java"]);
});
it("aggregates checkbox state for groups", () => {
const checks = new Map<string, CheckState>();
expect(aggregateCheck([status[0]!, status[3]!], checks)).toMatchObject({ checked: true, indeterminate: false });
+76
Переглянути файл
@@ -0,0 +1,76 @@
import { act, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/platform/ipc", async (importOriginal) => {
const real = await importOriginal<typeof import("@/platform/ipc")>();
return { ...real, call: vi.fn() };
});
vi.mock("../DiffEditor", () => ({ DiffEditor: () => <div /> }));
import { ChangesTree } from "../CommitView";
import { useVcs } from "../store";
import { useProject } from "@/platform/project";
const change = (path: string) => ({
path,
relPath: path.slice("/p/".length),
staged: null,
unstaged: "modified" as const,
renamedFrom: null,
});
/** The tree row (`role=treeitem`) showing `name`. */
const row = (name: string) => screen.getByText(name).closest('[role="treeitem"]') as HTMLElement;
describe("repository nodes in the changes tree", () => {
beforeEach(() => {
localStorage.clear();
useProject.setState({
project: { name: "p", root: "/p", trusted: true, isGit: true, settings: {} } as never,
settings: {} as never,
});
useVcs.setState({
root: "/p",
repo: "/p",
roots: ["/p", "/p/orders"],
status: [change("/p/App.kt"), change("/p/orders/Order.kt")],
heads: {},
});
});
it("shows the branch each repository is on and follows branch switches", () => {
render(<ChangesTree mode="commit" />);
expect(row("orders").textContent).toBe("orders");
act(() =>
useVcs.setState({
heads: {
"/p": { head: "main", headCommit: "1111111111" },
"/p/orders": { head: "feature/ABC-123", headCommit: "2222222222" },
},
}),
);
expect(row("orders").textContent).toBe("ordersfeature/ABC-123");
expect(row("p").textContent).toBe("pmain");
act(() =>
useVcs.setState({
heads: { ...useVcs.getState().heads, "/p/orders": { head: null, headCommit: "abcdef0123456789" } },
}),
);
expect(row("orders").textContent).toBe("ordersabcdef01");
});
it("puts a decorative icon before the branch, on repository rows only", () => {
useVcs.setState({
status: [change("/p/App.kt"), change("/p/orders/src/Order.kt")],
heads: { "/p/orders": { head: "feature/ABC-123", headCommit: "2222222222" } },
});
render(<ChangesTree mode="commit" />);
const hintIcons = (name: string) => row(name).querySelectorAll('svg[class*="detailIcon"]');
expect(hintIcons("orders")).toHaveLength(1);
expect(hintIcons("orders")[0]!.getAttribute("aria-hidden")).toBe("true");
// No head info for the project's repository yet: no icon.
expect(hintIcons("p")).toHaveLength(0);
expect(hintIcons("Order.kt")).toHaveLength(0);
});
});
+26 -7
Переглянути файл
@@ -2,6 +2,7 @@
import type { ChangelistState, FileStatus } from "@/bindings";
import { baseName, dirName, effectiveKind, relTo, repoLabel, rootOf, stripSlash, type EffectiveKind } from "./model";
import { tx } from "@/platform/i18n";
import type { RepoHead } from "./store";
export type GroupId = "conflicts" | "changes" | "unversioned" | "staged" | "unstaged";
@@ -38,8 +39,10 @@ export interface ChangeNode {
kind?: EffectiveKind;
/** Absolute path (files and directories). */
path?: string;
/** Detail text: the relative directory (flat mode) or the old name. */
/** Detail text: the relative directory (flat mode), the old name or a repository's branch. */
detail?: string;
/** Icon before the detail: a repository's branch, or its HEAD commit when detached. */
detailIcon?: "branch" | "commit";
/** Change list id (change list groups and their descendants). */
changelist?: string;
/** The active change list's group. */
@@ -180,6 +183,13 @@ function childNodes(files: readonly FileStatus[], root: string, group: GroupId,
.map((f) => fileNode(f, root, group, true, scope));
}
/** The branch a repository is on, or its HEAD commit when detached (IDEA's repository node hint). */
function headHint(head: RepoHead | undefined): Pick<ChangeNode, "detail" | "detailIcon"> {
if (head?.head) return { detail: head.head, detailIcon: "branch" };
if (head?.headCommit) return { detail: head.headCommit.slice(0, 8), detailIcon: "commit" };
return {};
}
/** With several repositories, a node per repository (IDEA's "Group by Repository") above the files. */
function repoNodes(
files: readonly FileStatus[],
@@ -188,6 +198,7 @@ function repoNodes(
groupByDir: boolean,
scope: string,
repos: readonly string[],
heads: Readonly<Record<string, RepoHead>>,
): ChangeNode[] {
if (repos.length < 2) return childNodes(files, root, group, groupByDir, scope);
const byRepo = new Map<string, FileStatus[]>();
@@ -208,6 +219,7 @@ function repoNodes(
children: childNodes(own, repo, group, groupByDir, `${scope}@${repo}`),
files: own,
path: repo,
...headHint(heads[repo]),
}));
}
@@ -223,8 +235,9 @@ function tagList(nodes: ChangeNode[], id: string): ChangeNode[] {
* Group roots (Merge Conflicts / Changes / Unversioned Files) with flat or directory
* children. With change lists, "Changes" is split into one group per list
* (`ChangelistState.files` keys are project-relative paths). With several
* repositories (`repos`), each group's files are grouped by repository first. With
* `byStaging`, "Changes" becomes "Staged Changes" and "Unstaged Changes" instead.
* repositories (`repos`), each group's files are grouped by repository first, with
* each repository's branch (`heads`) beside its name. With `byStaging`, "Changes"
* becomes "Staged Changes" and "Unstaged Changes" instead.
*/
export function buildChangeTree(
status: readonly FileStatus[],
@@ -233,6 +246,7 @@ export function buildChangeTree(
lists?: ChangelistState,
repos: readonly string[] = [],
byStaging = false,
heads: Readonly<Record<string, RepoHead>> = {},
): ChangeNode[] {
const byGroup = new Map<GroupId, FileStatus[]>();
for (const fs of status) {
@@ -253,7 +267,7 @@ export function buildChangeTree(
];
for (const [side, own] of sides) {
if (!own.length) continue;
const children = repoNodes(own, root, side, groupByDir, side, repos);
const children = repoNodes(own, root, side, groupByDir, side, repos, heads);
roots.push({
key: `group:${side}`,
type: "group",
@@ -281,7 +295,7 @@ export function buildChangeTree(
type: "group",
label: l.name,
group: g,
children: tagList(repoNodes(own, root, g, groupByDir, scope, repos), l.id),
children: tagList(repoNodes(own, root, g, groupByDir, scope, repos, heads), l.id),
files: own,
changelist: l.id,
active: l.id === lists.active,
@@ -291,7 +305,7 @@ export function buildChangeTree(
continue;
}
if (!files.length) continue;
const children = repoNodes(files, root, g, groupByDir, g, repos);
const children = repoNodes(files, root, g, groupByDir, g, repos, heads);
roots.push({ key: `group:${g}`, type: "group", label: GROUP_LABEL[g], group: g, children, files });
}
return roots;
@@ -376,7 +390,12 @@ export function renamedFromPath(fs: FileStatus): string | null {
export function planCommit(status: readonly FileStatus[], checks: ReadonlyMap<string, CheckState>): CommitPlan {
const plan: CommitPlan = { stage: [], exclude: [], included: [], partial: [] };
for (const fs of status) {
if (!groupOf(fs)) continue;
if (!groupOf(fs)) {
// Listed nowhere, yet staged: added to the index, then deleted from disk. It is no
// change against HEAD, so the commit must not pick up the staged addition.
if (fs.staged && fs.staged !== "conflicted" && fs.unstaged !== "ignored") plan.exclude.push(stripSlash(fs.path));
continue;
}
const c = checkOf(fs, checks);
const path = stripSlash(fs.path);
if (c === "none") {
+1
Переглянути файл
@@ -71,6 +71,7 @@ export const CLIENT_SETTINGS_KEYS = [
"terminal.columnWidth",
"console.softWrap",
"console.foldPatterns",
"console.logColors",
"debugger.showOnBreakpoint",
"debugger.focusAppOnBreakpoint",
"debugger.hideOnTermination",
+14
Переглянути файл
@@ -0,0 +1,14 @@
import { useProject } from "./project";
import { resolveMonoFont } from "./fonts";
import { useWorkflowSettings } from "./workflowSettings";
/**
* The font of the Run and Debug consoles: Tools › Terminal › Font (which can follow
* Editor › Font), with the row height both consoles lay their lines out with.
*/
export function useConsoleFont(): { family: string; size: number; rowHeight: number } {
const terminalFont = useWorkflowSettings((s) => s.terminal);
const editorFont = useProject((s) => s.settings?.editor);
const { family, size } = resolveMonoFont(terminalFont, editorFont);
return { family, size, rowHeight: Math.max(16, Math.round(size * 1.5)) };
}
+1
Переглянути файл
@@ -113,6 +113,7 @@ export const EDITOR_EXTRA_DEFAULTS = {
rainbowIndentGuides: false,
hardWrapGuides: true,
intentionBulb: true,
renderDocComments: false,
distractionFreeWidth: 80,
},
breadcrumbs: { show: true, placement: "bottom", disabledLanguages: [] as string[] },
+1
Переглянути файл
@@ -109,6 +109,7 @@
"common.copy": "Copy",
"menu.activeEditor": "Active Editor",
"menu.editor.toggleSoftWrap": "Soft-Wrap",
"menu.editor.toggleRenderedDocs": "Render Documentation Comments",
"menu.app": "OpenIDEA",
"menu.app.about": "About OpenIDEA",
"menu.app.checkForUpdates": "Check for Updates…",
+16 -2
Переглянути файл
@@ -441,7 +441,6 @@
"Compiler output": "Compiler output",
"Default directory where compiled classes are placed when not managed by Maven or Gradle.": "Default directory where compiled classes are placed when not managed by Maven or Gradle.",
"Select Compiler Output Directory": "Select Compiler Output Directory",
"JDK used to run the Eclipse JDT Language Server (jdtls 21+), independent of the target Project SDK.": "JDK used to run the Eclipse JDT Language Server (jdtls 21+), independent of the target Project SDK.",
"Select Module Directory": "Select Module Directory",
"Module settings": "Module settings",
"Module SDK & Compiler Output": "Module SDK & Compiler Output",
@@ -571,6 +570,13 @@
"Console output file": "Console output file",
"Show console when a message is printed to standard output stream": "Show console when a message is printed to standard output stream",
"Show console when a message is printed to standard error stream": "Show console when a message is printed to standard error stream",
"Colour log lines by level": "Colour log lines by level",
"ERROR, WARN, INFO, DEBUG and TRACE lines take the colour of their level; colours the program prints itself are kept.": "ERROR, WARN, INFO, DEBUG and TRACE lines take the colour of their level; colours the program prints itself are kept.",
"Error": "Error",
"Warning": "Warning",
"Info": "Info",
"Debug": "Debug",
"Trace": "Trace",
"Startup runners": "Startup runners",
"Use default": "Use default",
"Pass environment variables": "Pass environment variables",
@@ -1069,6 +1075,7 @@
"Color picker": "Color picker",
"HEX": "HEX",
"Red": "Red",
"Yellow": "Yellow",
"Green": "Green",
"Blue": "Blue",
"Alpha percent": "Alpha percent",
@@ -1341,8 +1348,15 @@
"OpenIDEA Keymap": "OpenIDEA Keymap",
"Keymap exported": "Keymap exported",
"Cannot export keymap": "Cannot export keymap",
"JDK used to run the Eclipse JDT Language Server, independent of the target Project SDK.": "JDK used to run the Eclipse JDT Language Server, independent of the target Project SDK.",
"Compiles and runs the project: Run/Debug and JAVA_HOME for Maven and Gradle; it also runs jdtls when new enough for it.": "Compiles and runs the project: Run/Debug and JAVA_HOME for Maven and Gradle; it also runs jdtls when new enough for it.",
"The installed jdtls needs JDK {version} or newer.": "The installed jdtls needs JDK {version} or newer.",
"The installed jdtls needs JDK {version} or newer; the selected JDK {selected} is too old for it.": "The installed jdtls needs JDK {version} or newer; the selected JDK {selected} is too old for it.",
"too old, needs JDK {version}+ · {path}": "too old, needs JDK {version}+ · {path}",
"Close Agent Task": "Close Agent Task",
"Close and New Agent Task": "Close and New Agent Task",
"Closes the agent task tab in the editor; the task keeps running in the Agents tool window.": "Closes the agent task tab in the editor; the task keeps running in the Agents tool window.",
"Closes the agent task tab in the editor, then opens the New Task page.": "Closes the agent task tab in the editor, then opens the New Task page."
"Closes the agent task tab in the editor, then opens the New Task page.": "Closes the agent task tab in the editor, then opens the New Task page.",
"May be incomplete: plugin indexes are still updating": "May be incomplete: plugin indexes are still updating",
"Plugin indexes could not be updated; see the plugin notification": "Plugin indexes could not be updated; see the plugin notification"
}
-1
Переглянути файл
@@ -502,7 +502,6 @@
"Compare with Current": "Comparar con la versión actual",
"Compare with Local": "Comparar con la versión local",
"Compilation failed: fix the errors and reload again.": "Falló la compilación: corrige los errores y vuelve a recargar.",
"Compiles and runs the project: the default jdtls runtime, Run/Debug, and JAVA_HOME for Maven and Gradle.": "Compila y ejecuta el proyecto: el entorno de ejecución predeterminado de jdtls, Ejecutar/Depurar y JAVA_HOME para Maven y Gradle.",
"Compiles the module of the current file": "Compila el módulo del archivo actual",
"Compiles the project's sources and tests with its build system": "Compila el código fuente y las pruebas del proyecto con su sistema de compilación",
"Completions": "Sugerencias de completado",
-1
Переглянути файл
@@ -450,7 +450,6 @@
"Compare with Current": "Comparer avec la version actuelle",
"Compare with Local": "Comparer avec la version locale",
"Compilation failed: fix the errors and reload again.": "Échec de la compilation : corrigez les erreurs et rechargez à nouveau.",
"Compiles and runs the project: the default jdtls runtime, Run/Debug, and JAVA_HOME for Maven and Gradle.": "Compile et exécute le projet : environnement jdtls par défaut, Exécuter/Déboguer et JAVA_HOME pour Maven et Gradle.",
"Compiles the module of the current file": "Compile le module du fichier actuel",
"Compiles the project's sources and tests with its build system": "Compile les sources et les tests du projet avec son système de build",
"Completions": "Complétions",
-1
Переглянути файл
@@ -539,7 +539,6 @@
"Compare with Current": "현재 브랜치와 비교",
"Compare with Local": "로컬과 비교",
"Compilation failed: fix the errors and reload again.": "컴파일 실패: 오류를 수정한 후 다시 로드하세요.",
"Compiles and runs the project: the default jdtls runtime, Run/Debug, and JAVA_HOME for Maven and Gradle.": "프로젝트를 컴파일하고 실행합니다: 기본 jdtls 런타임, 실행/디버그, Maven 및 Gradle의 JAVA_HOME.",
"Compiles the module of the current file": "현재 파일의 모듈을 컴파일합니다",
"Compiles the project's sources and tests with its build system": "프로젝트의 빌드 시스템으로 소스와 테스트를 컴파일합니다",
"Completions": "완성",
-1
Переглянути файл
@@ -494,7 +494,6 @@
"Compare with Current": "Porównaj z bieżącym",
"Compare with Local": "Porównaj z lokalnym",
"Compilation failed: fix the errors and reload again.": "Kompilacja nie powiodła się: napraw błędy i przeładuj ponownie.",
"Compiles and runs the project: the default jdtls runtime, Run/Debug, and JAVA_HOME for Maven and Gradle.": "Kompiluje i uruchamia projekt: domyślne środowisko uruchomieniowe jdtls, Uruchom/Debuguj oraz JAVA_HOME dla Maven i Gradle.",
"Compiles the module of the current file": "Kompiluje moduł bieżącego pliku",
"Compiles the project's sources and tests with its build system": "Kompiluje źródła i testy projektu za pomocą jego systemu budowania",
"Completions": "Uzupełnienia kodu",
-1
Переглянути файл
@@ -494,7 +494,6 @@
"Compare with Current": "Comparar com a versão atual",
"Compare with Local": "Comparar com a versão local",
"Compilation failed: fix the errors and reload again.": "A compilação falhou: corrija os erros e recarregue novamente.",
"Compiles and runs the project: the default jdtls runtime, Run/Debug, and JAVA_HOME for Maven and Gradle.": "Compila e executa o projeto: o runtime jdtls padrão, Executar/Depurar e JAVA_HOME para Maven e Gradle.",
"Compiles the module of the current file": "Compila o módulo do arquivo atual",
"Compiles the project's sources and tests with its build system": "Compila os fontes e testes do projeto com seu sistema de compilação",
"Completions": "Preenchimentos de código",
+27 -3
Переглянути файл
@@ -881,7 +881,6 @@
"Compare with Current": "与当前比较",
"Compare with Local": "与本地比较",
"Compilation failed: fix the errors and reload again.": "编译失败:请修复错误后重新加载。",
"Compiles and runs the project: the default jdtls runtime, Run/Debug, and JAVA_HOME for Maven and Gradle.": "用于编译和运行项目:jdtls 的默认运行时、运行/调试,以及 Maven 和 Gradle 的 JAVA_HOME。",
"Compiles the module of the current file": "编译当前文件所在的模块",
"Compiles the project's sources and tests with its build system": "使用项目的构建系统编译其源代码和测试",
"Completions": "补全",
@@ -2022,7 +2021,6 @@
"Compiler output": "编译器输出",
"Default directory where compiled classes are placed when not managed by Maven or Gradle.": "不由 Maven 或 Gradle 管理时,存放编译后类文件的默认目录。",
"Select Compiler Output Directory": "选择编译器输出目录",
"JDK used to run the Eclipse JDT Language Server (jdtls 21+), independent of the target Project SDK.": "用于运行 Eclipse JDT 语言服务器(jdtls 21+)的 JDK,独立于目标项目 SDK。",
"Select Module Directory": "选择模块目录",
"Module settings": "模块设置",
"Module SDK & Compiler Output": "模块 SDK 与编译器输出",
@@ -6977,6 +6975,10 @@
"Console output file": "控制台输出文件",
"Show console when a message is printed to standard output stream": "当消息输出到标准输出流时显示控制台",
"Show console when a message is printed to standard error stream": "当消息输出到标准错误流时显示控制台",
"Colour log lines by level": "按日志级别为控制台行着色",
"ERROR, WARN, INFO, DEBUG and TRACE lines take the colour of their level; colours the program prints itself are kept.": "ERROR、WARN、INFO、DEBUG、TRACE 行采用其级别的颜色;程序自行输出的颜色保持不变。",
"Info": "信息",
"Trace": "跟踪",
"Startup runners": "启动运行器",
"Use default": "使用默认值",
"Pass environment variables": "传递环境变量",
@@ -7731,5 +7733,27 @@
"Download Remote Files Into Folder": "将远程文件下载到文件夹",
"Download Remote Host files into a local folder (arg: { server, paths, target })": "将远程主机中的文件下载到本地文件夹(参数:{ server, paths, target })",
"Delegate IDE build/run actions to Maven": "将 IDE 构建/运行操作委托给 Maven",
"Run and Debug build Maven modules with Maven (the project's javac) instead of the Java language server. Editor problems still come from the language server.": "运行和调试时用 Maven(项目的 javac)构建 Maven 模块,而不是用 Java 语言服务器。编辑器中的问题仍来自语言服务器。"
"Run and Debug build Maven modules with Maven (the project's javac) instead of the Java language server. Editor problems still come from the language server.": "运行和调试时用 Maven(项目的 javac)构建 Maven 模块,而不是用 Java 语言服务器。编辑器中的问题仍来自语言服务器。",
"Render Documentation Comments": "渲染文档注释",
"Render documentation comments": "渲染文档注释",
"Toggle Rendered View": "切换渲染视图",
"Type params": "类型参数",
"Returns": "返回",
"Throws": "抛出",
"See also": "另请参阅",
"Since": "始于",
"Deprecated": "已弃用",
"Receiver": "接收者",
"Constructor": "构造函数",
"API Note": "API 说明",
"Implementation Requirements": "实现要求",
"Implementation Note": "实现说明",
"Samples": "示例代码",
"JDK used to run the Eclipse JDT Language Server, independent of the target Project SDK.": "用于运行 Eclipse JDT 语言服务器的 JDK,独立于目标项目 SDK。",
"Compiles and runs the project: Run/Debug and JAVA_HOME for Maven and Gradle; it also runs jdtls when new enough for it.": "用于编译和运行项目:运行/调试,以及 Maven 和 Gradle 的 JAVA_HOME;版本足够新时也用来运行 jdtls。",
"The installed jdtls needs JDK {version} or newer.": "当前安装的 jdtls 需要 JDK {version} 或更高版本。",
"The installed jdtls needs JDK {version} or newer; the selected JDK {selected} is too old for it.": "当前安装的 jdtls 需要 JDK {version} 或更高版本,所选的 JDK {selected} 版本过旧。",
"too old, needs JDK {version}+ · {path}": "版本过旧,需要 JDK {version}+ · {path}",
"May be incomplete: plugin indexes are still updating": "可能不完整:插件索引仍在更新",
"Plugin indexes could not be updated; see the plugin notification": "插件索引更新失败,详见插件通知"
}
+1
Переглянути файл
@@ -109,6 +109,7 @@
"common.copy": "复制",
"menu.activeEditor": "活动编辑器",
"menu.editor.toggleSoftWrap": "软换行",
"menu.editor.toggleRenderedDocs": "渲染文档注释",
"menu.app": "OpenIDEA",
"menu.app.about": "关于 OpenIDEA",
"menu.app.checkForUpdates": "检查更新…",
+13 -1
Переглянути файл
@@ -28,7 +28,11 @@ describe("validateWorkflow", () => {
confirmation: { onPatchCreated: "always" },
issueNavigation: [{ pattern: "" }, { pattern: "#(\\d+)", link: "x/$1" }],
},
console: { bufferKb: 1e12, foldPatterns: ["a", 3, ""] },
console: {
bufferKb: 1e12,
foldPatterns: ["a", 3, ""],
logColors: { enabled: false, error: "#ff0000", warn: 123 },
},
terminal: { cursorShape: "bar", env: { A: "1", B: 2 }, tabName: " " },
externalTools: [{ name: "Lint", id: "x" }, { name: "" }, { name: "Again", id: "x" }],
});
@@ -42,6 +46,14 @@ describe("validateWorkflow", () => {
expect(v.vcs.shelf.removeApplied).toBe(false);
expect(v.console.bufferKb).toBe(1_048_576);
expect(v.console.foldPatterns).toEqual(["a"]);
expect(v.console.logColors).toEqual({
enabled: false,
error: "#ff0000",
warn: "var(--ansi-yellow)",
info: "var(--ansi-green)",
debug: "var(--ansi-cyan)",
trace: "var(--ansi-magenta)",
});
expect(v.terminal).toMatchObject({ cursorShape: "bar", env: { A: "1" }, tabName: "Local" });
expect(v.externalTools.map((t) => [t.id, t.name])).toEqual([
["x", "Lint"],
+29
Переглянути файл
@@ -74,12 +74,23 @@ export interface VcsSettingsValid {
issueNavigation: IssueLinkValid[];
}
/** Mirrors `ide-core::workflow_settings::LogLevelColors` defaults. */
export interface LogLevelColorsValid {
enabled: boolean;
error: string;
warn: string;
info: string;
debug: string;
trace: string;
}
export interface ConsoleSettingsValid {
softWrap: boolean;
overrideBuffer: boolean;
bufferKb: number;
encoding: string;
foldPatterns: string[];
logColors: LogLevelColorsValid;
}
export interface DebuggerSettingsValid {
@@ -254,6 +265,14 @@ export const DEFAULT_WORKFLOW: WorkflowSettings = {
"at node:internal/",
"<frozen importlib._bootstrap",
],
logColors: {
enabled: false,
error: "var(--ansi-red)",
warn: "var(--ansi-yellow)",
info: "var(--ansi-green)",
debug: "var(--ansi-cyan)",
trace: "var(--ansi-magenta)",
},
},
debugger: {
showOnBreakpoint: true,
@@ -389,12 +408,22 @@ export function validateVcs(raw: unknown): VcsSettingsValid {
export function validateConsole(raw: unknown): ConsoleSettingsValid {
const d = DEFAULT_WORKFLOW.console;
const v = obj(raw);
const lc = obj(v.logColors);
const dl = d.logColors;
return {
softWrap: bool(v.softWrap, d.softWrap),
overrideBuffer: bool(v.overrideBuffer, d.overrideBuffer),
bufferKb: num(v.bufferKb, d.bufferKb, WORKFLOW_LIMITS.bufferKb, true),
encoding: nonEmpty(v.encoding, d.encoding),
foldPatterns: strings(v.foldPatterns, d.foldPatterns),
logColors: {
enabled: bool(lc.enabled, dl.enabled),
error: nonEmpty(lc.error, dl.error),
warn: nonEmpty(lc.warn, dl.warn),
info: nonEmpty(lc.info, dl.info),
debug: nonEmpty(lc.debug, dl.debug),
trace: nonEmpty(lc.trace, dl.trace),
},
};
}
+4
Переглянути файл
@@ -0,0 +1,4 @@
<!-- Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.5973 6.65471L12.6882 4.56049C13.1053 4.15406 13.1003 3.49602 12.6948 3.08627L11.0267 1.3136L11.0224 1.30932C10.6123 0.900035 9.94199 0.893268 9.53311 1.31079L7.3867 3.44406M10.5973 6.65471L7.3867 3.44406M10.5973 6.65471L4.74041 12.5H1.50036L1.5 9.32001L7.3867 3.44406" stroke="#6C707E" stroke-miterlimit="10"/>
</svg>

Після

Ширина:  |  Висота:  |  Розмір: 554 B

+4
Переглянути файл
@@ -0,0 +1,4 @@
<!-- Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.5973 6.65471L12.6882 4.56049C13.1053 4.15406 13.1003 3.49602 12.6948 3.08627L11.0267 1.3136L11.0224 1.30932C10.6123 0.900035 9.94199 0.893268 9.53311 1.31079L7.3867 3.44406M10.5973 6.65471L7.3867 3.44406M10.5973 6.65471L4.74041 12.5H1.50036L1.5 9.32001L7.3867 3.44406" stroke="#CED0D6" stroke-miterlimit="10"/>
</svg>

Після

Ширина:  |  Висота:  |  Розмір: 554 B

+7
Переглянути файл
@@ -0,0 +1,7 @@
<!-- Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 2.5C1 2.22386 1.22386 2 1.5 2C1.77614 2 2 2.22386 2 2.5V11.5C2 11.7761 1.77614 12 1.5 12C1.22386 12 1 11.7761 1 11.5V2.5Z" fill="#6C707E"/>
<path d="M3 3.5C3 3.22386 3.22386 3 3.5 3H12.5C12.7761 3 13 3.22386 13 3.5C13 3.77614 12.7761 4 12.5 4H3.5C3.22386 4 3 3.77614 3 3.5Z" fill="#6C707E"/>
<path d="M3 6.5C3 6.22386 3.22386 6 3.5 6H12.5C12.7761 6 13 6.22386 13 6.5C13 6.77614 12.7761 7 12.5 7H3.5C3.22386 7 3 6.77614 3 6.5Z" fill="#6C707E"/>
<path d="M3 9.5C3 9.22386 3.22386 9 3.5 9H8.5C8.77614 9 9 9.22386 9 9.5C9 9.77614 8.77614 10 8.5 10H3.5C3.22386 10 3 9.77614 3 9.5Z" fill="#6C707E"/>
</svg>

Після

Ширина:  |  Висота:  |  Розмір: 836 B

+7
Переглянути файл
@@ -0,0 +1,7 @@
<!-- Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 2.5C1 2.22386 1.22386 2 1.5 2C1.77614 2 2 2.22386 2 2.5V11.5C2 11.7761 1.77614 12 1.5 12C1.22386 12 1 11.7761 1 11.5V2.5Z" fill="#CED0D6"/>
<path d="M3 3.5C3 3.22386 3.22386 3 3.5 3H12.5C12.7761 3 13 3.22386 13 3.5C13 3.77614 12.7761 4 12.5 4H3.5C3.22386 4 3 3.77614 3 3.5Z" fill="#CED0D6"/>
<path d="M3 6.5C3 6.22386 3.22386 6 3.5 6H12.5C12.7761 6 13 6.22386 13 6.5C13 6.77614 12.7761 7 12.5 7H3.5C3.22386 7 3 6.77614 3 6.5Z" fill="#CED0D6"/>
<path d="M3 9.5C3 9.22386 3.22386 9 3.5 9H8.5C8.77614 9 9 9.22386 9 9.5C9 9.77614 8.77614 10 8.5 10H3.5C3.22386 10 3 9.77614 3 9.5Z" fill="#CED0D6"/>
</svg>

Після

Ширина:  |  Висота:  |  Розмір: 836 B

+2
Переглянути файл
@@ -438,6 +438,8 @@ const ICONS = {
"gutter-implementing": "gutter/implementingMethod",
"gutter-overridden": "gutter/overridenMethod",
"gutter-overriding": "gutter/overridingMethod",
"gutter-javadoc-read": "gutter/javadocRead",
"gutter-javadoc-edit": "gutter/javadocEdit",
stop: "run/stop",
rerun: "run/rerun",
"hot-reload": "openidea/general/hotReload",
+53
Переглянути файл
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import { logLineColor } from "./logColors";
const C = {
enabled: true,
error: "var(--ansi-red)",
warn: "var(--ansi-yellow)",
info: "var(--ansi-green)",
debug: "var(--ansi-cyan)",
trace: "var(--ansi-magenta)",
};
describe("logLineColor", () => {
it("matches the common line formats", () => {
expect(logLineColor("2026-11-05 10:42:39.123 INFO 4242 --- [nio-8080-exec-1] c.a.S : x", "stdout", C)).toBe(
"var(--ansi-green)",
);
expect(logLineColor("main ERROR com.foo.Bar - log4j line", "stdout", C)).toBe("var(--ansi-red)");
expect(logLineColor("[ERROR] Failed to execute goal org.apache...", "stdout", C)).toBe("var(--ansi-red)");
expect(logLineColor("[warn] lowercase marker", "stdout", C)).toBe("var(--ansi-yellow)");
expect(logLineColor("<Info> log4j2 style", "stdout", C)).toBe("var(--ansi-green)");
expect(logLineColor('ts=2026-11-05 level=debug msg="hi"', "stdout", C)).toBe("var(--ansi-cyan)");
expect(logLineColor('{"level":"error","msg":"boom"}', "stdout", C)).toBe("var(--ansi-red)");
expect(logLineColor('{"severity":"ERROR","message":"gcp"}', "stdout", C)).toBe("var(--ansi-red)");
expect(logLineColor("SEVERE: javax.servlet - JUL", "stdout", C)).toBe("var(--ansi-red)");
expect(logLineColor("WARNING: deprecated", "stdout", C)).toBe("var(--ansi-yellow)");
expect(logLineColor("TRACE detailed", "stdout", C)).toBe("var(--ansi-magenta)");
expect(logLineColor("FINEST jul fine", "stdout", C)).toBe("var(--ansi-magenta)");
});
it("prefers the explicit level field over a later bare word", () => {
expect(logLineColor('level=debug msg="ERROR fallback"', "stdout", C)).toBe("var(--ansi-cyan)");
});
it("stays plain for prose and identifiers", () => {
expect(logLineColor("information about the error", "stdout", C)).toBeUndefined();
expect(logLineColor("an error occurred", "stdout", C)).toBeUndefined();
expect(logLineColor("error-prone checks", "stdout", C)).toBeUndefined();
expect(logLineColor("ERROR_CODES must not match", "stdout", C)).toBeUndefined();
expect(logLineColor("GET /api/info 200", "stdout", C)).toBeUndefined();
expect(logLineColor("my-app.INFO tail", "stdout", C)).toBeUndefined();
expect(logLineColor("User info panel", "stdout", C)).toBeUndefined();
});
it("colours stdout and stderr only, and only when enabled", () => {
expect(logLineColor("ERROR x", "stderr", C)).toBe("var(--ansi-red)");
expect(logLineColor("INFO x", "system", C)).toBeUndefined();
expect(logLineColor("INFO x", "input", C)).toBeUndefined();
expect(logLineColor("INFO x", "result", C)).toBeUndefined();
expect(logLineColor("INFO x", "stdout", { ...C, enabled: false })).toBeUndefined();
expect(logLineColor("plain", "stdout", C)).toBeUndefined();
});
});
+53
Переглянути файл
@@ -0,0 +1,53 @@
/**
* Console log-line colours by level (Settings › Build, Execution, Deployment ›
* Console). Detection mirrors the `log` editor mode (`editor/core/cm/log.ts`):
* explicit `level=`/`severity=` fields and `[warn]`/`<Info>` markers match in
* any case, bare words only in upper case, so prose stays plain.
*/
import { LEVELS } from "@/editor/core/cm/log";
export type LogLevel = "error" | "warn" | "info" | "debug" | "trace";
/** Explicit level fields: JSON `"level":"info"`, logfmt `level=error`, GCP `severity`. */
const KEY_RE = new RegExp(`(?:^|[^A-Za-z0-9_])(?:level|lvl|severity)["']?\\s*[:=]\\s*["']?(${LEVELS})\\b`, "i");
/** Bracketed markers: Maven `[ERROR]`, log4j2 `<Info>`. */
const MARK_RE = new RegExp(`[\\[<](${LEVELS})[\\]>]`, "i");
/** Bare upper-case words at a word start (the `log` mode's rule). */
const BARE_RE = new RegExp(`(?:^|[^A-Za-z0-9_$.-])(${LEVELS})(?![A-Za-z0-9_])`);
const ALIAS: Record<string, LogLevel> = {
FATAL: "error",
CRITICAL: "error",
SEVERE: "error",
EMERG: "error",
ALERT: "error",
ERROR: "error",
ERR: "error",
WARNING: "warn",
WARN: "warn",
NOTICE: "warn",
INFO: "info",
DEBUG: "debug",
VERBOSE: "trace",
TRACE: "trace",
FINEST: "trace",
FINER: "trace",
FINE: "trace",
};
function detectLogLevel(text: string): LogLevel | null {
// An explicit declaration is the most trusted, then a marker, then a bare word.
const m = KEY_RE.exec(text) ?? MARK_RE.exec(text) ?? BARE_RE.exec(text);
return m ? (ALIAS[m[1]!.toUpperCase()] ?? null) : null;
}
/** A line's colour, or `undefined`: colouring off, a stream the IDE owns, or no level. */
export function logLineColor(
text: string,
kind: string,
colors: { enabled: boolean } & Record<LogLevel, string>,
): string | undefined {
if (!colors.enabled || (kind !== "stdout" && kind !== "stderr")) return undefined;
const level = detectLogLevel(text);
return level ? colors[level] : undefined;
}
+18 -5
Переглянути файл
@@ -1726,6 +1726,12 @@ export const useDebugRunSessions = create<{
Console: ComponentType<{ sessionId: number }> | null;
}>(() => ({ sessions: [], serviceSessions: [], Console: null }));
/**
* The Services tool window's badge: how many of its listed services are running.
* The run provider (another feature) keeps it current; `null` hides the badge.
*/
export const useServicesRunningCount = create<{ count: number | null }>(() => ({ count: null }));
// ---------------------------------------------------------------------------
// Processes a closing window or project would terminate. Features that own
// child processes (Run/Debug sessions) publish them here, so the close guard can
@@ -1825,16 +1831,23 @@ export function addContributedFileTemplate(t: import("@/platform/fileTemplates")
return () => useContributedFileTemplates.setState((s) => ({ templates: s.templates.filter((x) => x !== t) }));
}
/** Flush contributed document indexes before workspace usages or refactoring. */
const documentIndexSync = new Set<(path: string) => Promise<void>>();
export function registerDocumentIndexSync(sync: (path: string) => Promise<void>): () => void {
/**
* Flush contributed document indexes before workspace usages or refactoring. A refactoring
* refuses (throws) while an index is incomplete; usages go ahead and resolve `false`, so the
* results can say they may be incomplete.
*/
export type DocumentIndexPurpose = "usages" | "refactor";
type DocumentIndexSync = (path: string, purpose: DocumentIndexPurpose) => Promise<boolean | void>;
const documentIndexSync = new Set<DocumentIndexSync>();
export function registerDocumentIndexSync(sync: DocumentIndexSync): () => void {
documentIndexSync.add(sync);
return () => {
documentIndexSync.delete(sync);
};
}
export async function synchronizeDocumentIndexes(path: string): Promise<void> {
await Promise.all([...documentIndexSync].map((sync) => sync(path)));
export async function synchronizeDocumentIndexes(path: string, purpose: DocumentIndexPurpose): Promise<boolean> {
const complete = await Promise.all([...documentIndexSync].map((sync) => sync(path, purpose)));
return complete.every((c) => c !== false);
}
/** Font zoom for non-CodeMirror surfaces, owned and released by their features. */