дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-12 20:56:00 +02:00
Порівняти коміти
| Автор | SHA1 | Дата | |
|---|---|---|---|
|
|
4d596c7943 |
No files matched your search
@@ -78,10 +78,6 @@ 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,
|
||||
}
|
||||
@@ -104,7 +100,6 @@ impl Default for EditorAppearanceSettings {
|
||||
rainbow_indent_guides: false,
|
||||
hard_wrap_guides: true,
|
||||
intention_bulb: true,
|
||||
render_doc_comments: false,
|
||||
distraction_free_width: 80,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,33 +192,6 @@ 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")]
|
||||
@@ -230,8 +203,6 @@ 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 {
|
||||
@@ -248,7 +219,6 @@ impl Default for ConsoleSettings {
|
||||
"at node:internal/".into(),
|
||||
"<frozen importlib._bootstrap".into(),
|
||||
],
|
||||
log_colors: LogLevelColors::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,23 +161,6 @@ 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 });
|
||||
@@ -190,7 +173,6 @@ 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,
|
||||
|
||||
@@ -254,7 +254,6 @@ 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"
|
||||
|
||||
@@ -53,7 +53,6 @@ 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",
|
||||
|
||||
@@ -282,16 +282,10 @@ 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 {
|
||||
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)
|
||||
let required = crate::jdtls::script_for(cfg)
|
||||
.and_then(|script| Some(script.parent()?.parent()?.to_path_buf()))
|
||||
.and_then(|home| bundles_min_java(&home))
|
||||
.map(|v| v.max(JDTLS_MIN_JAVA))
|
||||
.and_then(|home| bundles_min_java(&home));
|
||||
required.map_or(JDTLS_MIN_JAVA, |v| v.max(JDTLS_MIN_JAVA))
|
||||
}
|
||||
|
||||
/// Per install: (plugins directory mtime, its requirement).
|
||||
|
||||
@@ -565,22 +565,6 @@ 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();
|
||||
|
||||
@@ -156,7 +156,6 @@ 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.
|
||||
|
||||
@@ -152,7 +152,6 @@ Maven 模块在 **自动** 和 **构建(Make)** 时由 jdtls(ECJ)构建
|
||||
|
||||
- 输出里的文件路径和堆栈帧都可以点击跳转。
|
||||
- 重复的行可以折叠。折叠规则、缓冲区大小和控制台编码在 **设置 › 构建、执行、部署 › 控制台** 中配置。
|
||||
- 日志行可按级别着色(`ERROR`、`WARN`、`INFO`、`DEBUG`、`TRACE`);程序自行输出的颜色保持不变。默认关闭,可在 **设置 › 构建、执行、部署 › 控制台** 开启并修改颜色。
|
||||
- 测试运行时,标签页会在输出旁显示测试树,参见[测试](/zh/guide/testing)。
|
||||
|
||||
**设置 › 构建、执行、部署 › 运行/调试** 控制进程启动时是否打开工具窗口、重新运行或关闭正在运行的标签页前是否确认,以及按停止后等待进程退出的时间。
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@@ -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); runs on a JDK new enough for the installed release, chosen or downloaded automatically"
|
||||
install-hint = "Install Eclipse JDT Language Server (brew install jdtls, or https://github.com/eclipse-jdtls/eclipse.jdt.ls); requires JDK 21+"
|
||||
|
||||
[language-servers.initialization-options]
|
||||
bundles = []
|
||||
|
||||
@@ -455,7 +455,6 @@ 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,
|
||||
|
||||
@@ -66,13 +66,6 @@ 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,
|
||||
|
||||
@@ -409,14 +409,7 @@ 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"),
|
||||
act("editor.toggleRenderedDocs", "Render Documentation Comments"),
|
||||
],
|
||||
),
|
||||
sub("activeEditor", "Active Editor", vec![act("editor.toggleSoftWrap", "Soft-Wrap")]),
|
||||
SEP,
|
||||
act("code.quickDefinition", "Quick Definition"),
|
||||
act("code.quickTypeDefinition", "Quick Type Definition"),
|
||||
|
||||
@@ -931,8 +931,6 @@ 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
|
||||
@@ -3128,8 +3126,6 @@ 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";
|
||||
@@ -4315,12 +4311,6 @@ 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,
|
||||
};
|
||||
@@ -6645,20 +6635,6 @@ 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. */
|
||||
|
||||
@@ -49,7 +49,6 @@ 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";
|
||||
@@ -749,19 +748,6 @@ 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), {
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
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 < b</code>"]);
|
||||
expect(restoreInlineTags(text, html)).toBe("Use <code>a < 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<K, {V}></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>");
|
||||
});
|
||||
});
|
||||
@@ -1,332 +0,0 @@
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import { StreamLanguage, type StreamParser } from "@codemirror/language";
|
||||
import { logWarning } from "./highlight";
|
||||
|
||||
/** Level vocabulary, shared with the console line colouring (`ui/logColors`). */
|
||||
export const LEVELS =
|
||||
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`);
|
||||
|
||||
@@ -50,7 +50,6 @@ 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";
|
||||
@@ -486,8 +485,6 @@ 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;
|
||||
@@ -581,7 +578,6 @@ 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),
|
||||
);
|
||||
|
||||
@@ -1,276 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@@ -1,221 +0,0 @@
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -1,850 +0,0 @@
|
||||
/**
|
||||
* 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,
|
||||
];
|
||||
@@ -42,7 +42,6 @@ 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";
|
||||
@@ -276,14 +275,12 @@ 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,
|
||||
@@ -317,8 +314,6 @@ 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. */
|
||||
@@ -389,7 +384,6 @@ export function baseExtensions(o: SetupOptions): Extension {
|
||||
overviewLane,
|
||||
cornerInset,
|
||||
codeLensExtension,
|
||||
renderedDocsExtension,
|
||||
EditorView.contentAttributes.of({
|
||||
spellcheck: "false",
|
||||
autocorrect: "off",
|
||||
@@ -412,7 +406,6 @@ 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)),
|
||||
|
||||
@@ -44,7 +44,6 @@ 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") },
|
||||
|
||||
@@ -469,18 +469,6 @@ 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();
|
||||
@@ -509,7 +497,6 @@ export class DocModel {
|
||||
info: this.info,
|
||||
readOnly: this.meta.readOnly,
|
||||
wrapOverride: this.softWrapOverride,
|
||||
renderedDocsOverride: this.renderedDocsOverride,
|
||||
}),
|
||||
),
|
||||
];
|
||||
@@ -545,7 +532,6 @@ export class DocModel {
|
||||
info: this.info,
|
||||
readOnly,
|
||||
wrapOverride: this.softWrapOverride,
|
||||
renderedDocsOverride: this.renderedDocsOverride,
|
||||
}),
|
||||
),
|
||||
]);
|
||||
|
||||
@@ -117,7 +117,6 @@ export class EditorSlot {
|
||||
indent: model.effectiveIndent(),
|
||||
formatIndent: model.formatIndent(),
|
||||
wrapOverride: model.softWrapOverride,
|
||||
renderedDocsOverride: model.renderedDocsOverride,
|
||||
extra: this.slotExtensions(),
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
completeFromList,
|
||||
completionStatus,
|
||||
currentCompletions,
|
||||
moveCompletionSelection,
|
||||
pickedCompletion,
|
||||
selectedCompletion,
|
||||
snippet,
|
||||
@@ -631,12 +630,6 @@ 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
|
||||
|
||||
@@ -822,9 +815,6 @@ 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) => {
|
||||
|
||||
@@ -192,10 +192,7 @@ export const lspTheme = EditorView.theme({
|
||||
".cm-lsp-doc-signature": { marginTop: "0", color: "var(--fg-emphasis)" },
|
||||
|
||||
// --- hover / quick documentation / signature / hints ---------------------
|
||||
// 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-tooltip.cm-tooltip-hover": { ...tooltipSurface },
|
||||
".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)",
|
||||
|
||||
@@ -95,18 +95,6 @@ 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(
|
||||
|
||||
@@ -139,9 +139,6 @@ 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,9 +15,7 @@ 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 {
|
||||
@@ -119,13 +117,8 @@ 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);
|
||||
@@ -147,13 +140,13 @@ export function DebugConsole({ session, interactive = true }: { session: DebugSe
|
||||
const virtualizer = useVirtualizer({
|
||||
count: lines?.length ?? 0,
|
||||
getScrollElement: () => scroller.current,
|
||||
estimateSize: () => rowHeight,
|
||||
estimateSize: () => 18,
|
||||
overscan: 30,
|
||||
getItemKey: (i) => lines?.[i]?.id ?? i,
|
||||
});
|
||||
|
||||
// Wrapping changes every row's height; the end is pinned again below.
|
||||
useLayoutEffect(() => virtualizer.measure?.(), [softWrap, rowHeight, virtualizer]);
|
||||
useLayoutEffect(() => virtualizer.measure?.(), [softWrap, virtualizer]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = scroller.current;
|
||||
@@ -436,7 +429,6 @@ 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;
|
||||
@@ -456,15 +448,13 @@ 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)`, color }}
|
||||
style={{ transform: `translateY(${item.start}px)` }}
|
||||
>
|
||||
<DebugLine line={l} marks={marksByLine.get(item.index)} length={l.text.length} cwd={cwd} />
|
||||
</span>
|
||||
@@ -475,13 +465,12 @@ export function DebugConsole({ session, interactive = true }: { session: DebugSe
|
||||
<div className={styles.repl}>
|
||||
{interactive && (
|
||||
<>
|
||||
<span className={styles.prompt} style={fontStyle} aria-hidden>
|
||||
<span className={styles.prompt} aria-hidden>
|
||||
›
|
||||
</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className={styles.evalInput}
|
||||
style={fontStyle}
|
||||
onFocus={() => setConsoleHistoryBrowser(browseHistory)}
|
||||
onBlur={() => {
|
||||
setConsoleHistoryBrowser(null);
|
||||
|
||||
@@ -14,10 +14,10 @@ import {
|
||||
import clsx from "clsx";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { useWorkflowSettings } from "@/platform/workflowSettings";
|
||||
import { useConsoleFont } from "@/platform/consoleFont";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { resolveMonoFont } from "@/platform/fonts";
|
||||
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,11 +183,12 @@ 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 consoleFont = useConsoleFont();
|
||||
const { rowHeight } = consoleFont;
|
||||
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));
|
||||
/** 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)`,
|
||||
@@ -594,15 +595,13 @@ 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), color }}
|
||||
style={rowStyle(item.start)}
|
||||
>
|
||||
<Line line={line} marks={marksByLine.get(row.index)} length={line.text.length} cwd={cwd} />
|
||||
{row.fold && foldButton(row.fold, true)}
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
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("");
|
||||
});
|
||||
});
|
||||
@@ -44,12 +44,7 @@ import { invalidateGoModules } from "./goModules";
|
||||
import { invalidatePythonPackages } from "./pythonPackages";
|
||||
import { RunWidget } from "./RunWidget";
|
||||
import { runConfigsRunAnything } from "./runAnything";
|
||||
import {
|
||||
cleanupRemovedProject,
|
||||
registerRunServiceActions,
|
||||
runServiceProvider,
|
||||
watchServiceRunningCount,
|
||||
} from "./services";
|
||||
import { cleanupRemovedProject, registerRunServiceActions, runServiceProvider } from "./services";
|
||||
import { liveServiceSession, serviceSessions } from "./serviceSessions";
|
||||
import { ServiceImportHost } from "./serviceImport";
|
||||
import { cmakeBuildId } from "./cmake";
|
||||
@@ -401,7 +396,6 @@ export function activate(): () => void {
|
||||
registerOverlay({ id: "run.serviceImport", component: ServiceImportHost }),
|
||||
registerServiceProvider(runServiceProvider),
|
||||
registerRunServiceActions(),
|
||||
watchServiceRunningCount(),
|
||||
registerRunningProcessesProvider({
|
||||
id: "run",
|
||||
running: () =>
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
readHidden,
|
||||
readTypes,
|
||||
runServiceItems,
|
||||
runningServiceCount,
|
||||
sessionDetail,
|
||||
sessionStatus,
|
||||
} from "./services";
|
||||
@@ -376,71 +375,6 @@ 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> = {
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
notifications,
|
||||
useRunConfigTypes,
|
||||
useDebugRunSessions,
|
||||
useServicesRunningCount,
|
||||
type DebugServiceSession,
|
||||
type ServiceItemAction,
|
||||
type ServiceProviderContribution,
|
||||
@@ -242,49 +241,6 @@ 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
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
registerServiceView,
|
||||
registerToolWindow,
|
||||
showServiceView,
|
||||
useServicesRunningCount,
|
||||
} from "@/workbench/contributions";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { addFileIconProvider } from "@/ui";
|
||||
@@ -59,9 +58,6 @@ 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;
|
||||
|
||||
@@ -75,7 +71,6 @@ export function activate(): () => void {
|
||||
anchor: "bottom",
|
||||
order: 8,
|
||||
stripe: "onUse",
|
||||
useBadge: useServicesBadge,
|
||||
component: ServicesHost,
|
||||
headerContent: ServicesTabs,
|
||||
headerActions: ServicesHeaderActions,
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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, useJdtlsJdk } from "./components";
|
||||
import { SdkField, tildify, useDetected } from "./components";
|
||||
import styles from "./Sdk.module.css";
|
||||
|
||||
function PathInput({
|
||||
@@ -391,11 +391,6 @@ 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}>
|
||||
@@ -409,7 +404,7 @@ export function JavaPage() {
|
||||
directory
|
||||
browseTitle="Select JDK Home Directory"
|
||||
aria-label="Project JDK"
|
||||
hint="Compiles and runs the project: Run/Debug and JAVA_HOME for Maven and Gradle; it also runs jdtls when new enough for it."
|
||||
hint="Compiles and runs the project: the default jdtls runtime, Run/Debug, and JAVA_HOME for Maven and Gradle."
|
||||
/>
|
||||
<Row
|
||||
label="Language level"
|
||||
@@ -444,8 +439,7 @@ export function JavaPage() {
|
||||
directory
|
||||
browseTitle="Select JDK Home Directory"
|
||||
aria-label="Language server JDK"
|
||||
hint={jdtls.hint}
|
||||
minJava={jdtls.minJava}
|
||||
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."
|
||||
/>
|
||||
<Row
|
||||
label="JVM options"
|
||||
|
||||
@@ -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, useJdtlsJdk, useProjectSetting } from "./components";
|
||||
import { SdkField, tildify, useDetected, useProjectSetting } from "./components";
|
||||
import styles from "./Sdk.module.css";
|
||||
import { tx } from "@/platform/i18n";
|
||||
|
||||
@@ -304,10 +304,6 @@ 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 (
|
||||
@@ -373,7 +369,10 @@ function ProjectSection() {
|
||||
</Row>
|
||||
</Section>
|
||||
<Section title="Language Server">
|
||||
<Row label="JDK" hint={jdtls.hint}>
|
||||
<Row
|
||||
label="JDK"
|
||||
hint="JDK used to run the Eclipse JDT Language Server (jdtls 21+), independent of the target Project SDK."
|
||||
>
|
||||
<SdkField
|
||||
kind="jdk"
|
||||
value={jdtlsJdk ?? ""}
|
||||
@@ -383,7 +382,6 @@ function ProjectSection() {
|
||||
directory
|
||||
browseTitle="Select JDK Home Directory"
|
||||
aria-label="Language server JDK"
|
||||
minJava={jdtls.minJava}
|
||||
/>
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
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";
|
||||
@@ -49,8 +47,6 @@ 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). */
|
||||
@@ -66,7 +62,6 @@ export function SdkField({
|
||||
directory,
|
||||
browseTitle,
|
||||
extra,
|
||||
minJava,
|
||||
...aria
|
||||
}: SdkFieldProps) {
|
||||
const { list, loading, refresh } = useDetected(kind);
|
||||
@@ -74,16 +69,11 @@ export function SdkField({
|
||||
const out: SelectOption[] = [];
|
||||
if (emptyLabel !== undefined) out.push({ value: "", label: emptyLabel, detail: emptyDetail });
|
||||
out.push(...(extra ?? []));
|
||||
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 });
|
||||
}
|
||||
for (const sdk of list) out.push({ value: sdk.path, label: sdk.name, detail: tildify(sdk.path) });
|
||||
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, minJava]);
|
||||
}, [list, value, emptyLabel, emptyDetail, extra, kind]);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const browse = async () => {
|
||||
const picked = await choosePath({ directory: !!directory, title: browseTitle, defaultPath: value || undefined });
|
||||
@@ -107,7 +97,6 @@ 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);
|
||||
@@ -118,43 +107,3 @@ 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 };
|
||||
}
|
||||
@@ -281,11 +281,6 @@ 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`,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { CMakeProfiles } from "./cmake";
|
||||
import { WORKFLOW_LIMITS } from "@/platform/workflowSettings";
|
||||
import type { SelectOption } from "@/ui";
|
||||
import { ColorSwatches, FormGroup, GlobListEditor, NumberInput, Row, Toggle } from "../components/Form";
|
||||
import { GlobListEditor, NumberInput, Row } from "../components/Form";
|
||||
import { GradlePage, MavenPage } from "../languages/LanguagePages";
|
||||
import { useSetting } from "../components/context";
|
||||
import type { PageSpec } from "./spec";
|
||||
@@ -66,54 +66,6 @@ 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");
|
||||
@@ -221,7 +173,7 @@ export const EXECUTION_PAGES: PageSpec[] = [
|
||||
group: BUILD_GROUP,
|
||||
order: 360,
|
||||
projectScope: true,
|
||||
keywords: ["run output", "encoding", "fold", "buffer", "colour"],
|
||||
keywords: ["run output", "encoding", "fold", "buffer"],
|
||||
sections: [
|
||||
{
|
||||
options: [
|
||||
@@ -234,12 +186,6 @@ 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 }] },
|
||||
|
||||
@@ -79,10 +79,6 @@
|
||||
text-overflow: ellipsis;
|
||||
color: var(--fg-subtle);
|
||||
}
|
||||
.detailIcon {
|
||||
flex: none;
|
||||
--icon-color: var(--fg-subtle);
|
||||
}
|
||||
.partial {
|
||||
flex: none;
|
||||
padding: 0 4px;
|
||||
|
||||
@@ -568,11 +568,10 @@ 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, heads),
|
||||
[status, root, groupByDir, lists, repos, groupByStaging, heads],
|
||||
() => buildChangeTree(status, root, groupByDir, lists, repos, groupByStaging),
|
||||
[status, root, groupByDir, lists, repos, groupByStaging],
|
||||
);
|
||||
const [byKey, parents] = useMemo(() => {
|
||||
const m = new Map<string, ChangeNode>();
|
||||
@@ -883,7 +882,6 @@ 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>
|
||||
);
|
||||
|
||||
@@ -151,75 +151,6 @@ 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");
|
||||
@@ -283,19 +214,6 @@ 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 });
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,6 @@
|
||||
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";
|
||||
|
||||
@@ -39,10 +38,8 @@ export interface ChangeNode {
|
||||
kind?: EffectiveKind;
|
||||
/** Absolute path (files and directories). */
|
||||
path?: string;
|
||||
/** Detail text: the relative directory (flat mode), the old name or a repository's branch. */
|
||||
/** Detail text: the relative directory (flat mode) or the old name. */
|
||||
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. */
|
||||
@@ -183,13 +180,6 @@ 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[],
|
||||
@@ -198,7 +188,6 @@ 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[]>();
|
||||
@@ -219,7 +208,6 @@ function repoNodes(
|
||||
children: childNodes(own, repo, group, groupByDir, `${scope}@${repo}`),
|
||||
files: own,
|
||||
path: repo,
|
||||
...headHint(heads[repo]),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -235,9 +223,8 @@ 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
|
||||
* each repository's branch (`heads`) beside its name. With `byStaging`, "Changes"
|
||||
* becomes "Staged Changes" and "Unstaged Changes" instead.
|
||||
* repositories (`repos`), each group's files are grouped by repository first. With
|
||||
* `byStaging`, "Changes" becomes "Staged Changes" and "Unstaged Changes" instead.
|
||||
*/
|
||||
export function buildChangeTree(
|
||||
status: readonly FileStatus[],
|
||||
@@ -246,7 +233,6 @@ 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) {
|
||||
@@ -267,7 +253,7 @@ export function buildChangeTree(
|
||||
];
|
||||
for (const [side, own] of sides) {
|
||||
if (!own.length) continue;
|
||||
const children = repoNodes(own, root, side, groupByDir, side, repos, heads);
|
||||
const children = repoNodes(own, root, side, groupByDir, side, repos);
|
||||
roots.push({
|
||||
key: `group:${side}`,
|
||||
type: "group",
|
||||
@@ -295,7 +281,7 @@ export function buildChangeTree(
|
||||
type: "group",
|
||||
label: l.name,
|
||||
group: g,
|
||||
children: tagList(repoNodes(own, root, g, groupByDir, scope, repos, heads), l.id),
|
||||
children: tagList(repoNodes(own, root, g, groupByDir, scope, repos), l.id),
|
||||
files: own,
|
||||
changelist: l.id,
|
||||
active: l.id === lists.active,
|
||||
@@ -305,7 +291,7 @@ export function buildChangeTree(
|
||||
continue;
|
||||
}
|
||||
if (!files.length) continue;
|
||||
const children = repoNodes(files, root, g, groupByDir, g, repos, heads);
|
||||
const children = repoNodes(files, root, g, groupByDir, g, repos);
|
||||
roots.push({ key: `group:${g}`, type: "group", label: GROUP_LABEL[g], group: g, children, files });
|
||||
}
|
||||
return roots;
|
||||
@@ -390,12 +376,7 @@ 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)) {
|
||||
// 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;
|
||||
}
|
||||
if (!groupOf(fs)) continue;
|
||||
const c = checkOf(fs, checks);
|
||||
const path = stripSlash(fs.path);
|
||||
if (c === "none") {
|
||||
|
||||
@@ -71,7 +71,6 @@ export const CLIENT_SETTINGS_KEYS = [
|
||||
"terminal.columnWidth",
|
||||
"console.softWrap",
|
||||
"console.foldPatterns",
|
||||
"console.logColors",
|
||||
"debugger.showOnBreakpoint",
|
||||
"debugger.focusAppOnBreakpoint",
|
||||
"debugger.hideOnTermination",
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
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)) };
|
||||
}
|
||||
@@ -113,7 +113,6 @@ export const EDITOR_EXTRA_DEFAULTS = {
|
||||
rainbowIndentGuides: false,
|
||||
hardWrapGuides: true,
|
||||
intentionBulb: true,
|
||||
renderDocComments: false,
|
||||
distractionFreeWidth: 80,
|
||||
},
|
||||
breadcrumbs: { show: true, placement: "bottom", disabledLanguages: [] as string[] },
|
||||
|
||||
@@ -109,7 +109,6 @@
|
||||
"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…",
|
||||
|
||||
@@ -441,6 +441,7 @@
|
||||
"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",
|
||||
@@ -570,13 +571,6 @@
|
||||
"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",
|
||||
@@ -1075,7 +1069,6 @@
|
||||
"Color picker": "Color picker",
|
||||
"HEX": "HEX",
|
||||
"Red": "Red",
|
||||
"Yellow": "Yellow",
|
||||
"Green": "Green",
|
||||
"Blue": "Blue",
|
||||
"Alpha percent": "Alpha percent",
|
||||
@@ -1348,11 +1341,6 @@
|
||||
"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.",
|
||||
|
||||
@@ -502,6 +502,7 @@
|
||||
"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",
|
||||
|
||||
@@ -450,6 +450,7 @@
|
||||
"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",
|
||||
|
||||
@@ -539,6 +539,7 @@
|
||||
"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": "완성",
|
||||
|
||||
@@ -494,6 +494,7 @@
|
||||
"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",
|
||||
|
||||
@@ -494,6 +494,7 @@
|
||||
"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",
|
||||
|
||||
@@ -881,6 +881,7 @@
|
||||
"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": "补全",
|
||||
@@ -2021,6 +2022,7 @@
|
||||
"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 与编译器输出",
|
||||
@@ -6975,10 +6977,6 @@
|
||||
"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": "传递环境变量",
|
||||
@@ -7734,26 +7732,6 @@
|
||||
"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 语言服务器。编辑器中的问题仍来自语言服务器。",
|
||||
"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": "插件索引更新失败,详见插件通知"
|
||||
}
|
||||
@@ -109,7 +109,6 @@
|
||||
"common.copy": "复制",
|
||||
"menu.activeEditor": "活动编辑器",
|
||||
"menu.editor.toggleSoftWrap": "软换行",
|
||||
"menu.editor.toggleRenderedDocs": "渲染文档注释",
|
||||
"menu.app": "OpenIDEA",
|
||||
"menu.app.about": "关于 OpenIDEA",
|
||||
"menu.app.checkForUpdates": "检查更新…",
|
||||
|
||||
@@ -28,11 +28,7 @@ describe("validateWorkflow", () => {
|
||||
confirmation: { onPatchCreated: "always" },
|
||||
issueNavigation: [{ pattern: "" }, { pattern: "#(\\d+)", link: "x/$1" }],
|
||||
},
|
||||
console: {
|
||||
bufferKb: 1e12,
|
||||
foldPatterns: ["a", 3, ""],
|
||||
logColors: { enabled: false, error: "#ff0000", warn: 123 },
|
||||
},
|
||||
console: { bufferKb: 1e12, foldPatterns: ["a", 3, ""] },
|
||||
terminal: { cursorShape: "bar", env: { A: "1", B: 2 }, tabName: " " },
|
||||
externalTools: [{ name: "Lint", id: "x" }, { name: "" }, { name: "Again", id: "x" }],
|
||||
});
|
||||
@@ -46,14 +42,6 @@ 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"],
|
||||
|
||||
@@ -74,23 +74,12 @@ 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 {
|
||||
@@ -265,14 +254,6 @@ 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,
|
||||
@@ -408,22 +389,12 @@ 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),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
<!-- 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 |
@@ -1,4 +0,0 @@
|
||||
<!-- 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 |
@@ -1,7 +0,0 @@
|
||||
<!-- 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 |
@@ -1,7 +0,0 @@
|
||||
<!-- 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 |
@@ -438,8 +438,6 @@ 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",
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,53 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -1726,12 +1726,6 @@ 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
|
||||
|
||||
Reference in new issue
Заблокувати користувача