diff --git a/e2e/commentToggle.spec.ts b/e2e/commentToggle.spec.ts
new file mode 100644
index 000000000..1a93813b1
--- /dev/null
+++ b/e2e/commentToggle.spec.ts
@@ -0,0 +1,32 @@
+import { expect, openFromTree, test } from "./fixtures";
+
+// A JSX comment is `{/* … */}` among JSX children, while its own text parses as
+// JavaScript, where the tokens are `/* … */`. Toggling must still take it back out.
+const SRC = `export function App() {
+ return (
+
+ x
+
+ );
+}
+`;
+
+test("⌘/ twice on a JSX line gives the line back", async ({ workbench: page }) => {
+ await page.evaluate(async (src) => {
+ const backendModule = "/src/editor/core/backend.ts";
+ const mod = await import(/* @vite-ignore */ backendModule);
+ const backend = mod.docBackend() as { files: Map };
+ backend.files.set("/dev/acme-api/App.tsx", src);
+ }, SRC);
+
+ const editor = await openFromTree(page, "App.tsx");
+ const content = editor.locator(".cm-content");
+ await editor.locator(".cm-line", { hasText: "x" }).click();
+
+ await page.keyboard.press("Meta+/");
+ await expect(content).toContainText("{/* x */}");
+
+ await page.keyboard.press("Meta+/");
+ await expect(content).not.toContainText("/*");
+ await expect(editor.locator(".cm-line", { hasText: "x" })).toHaveText("x");
+});
diff --git a/src/editor/core/actions.ts b/src/editor/core/actions.ts
index 81358f537..abc9389e1 100644
--- a/src/editor/core/actions.ts
+++ b/src/editor/core/actions.ts
@@ -1,7 +1,7 @@
/** Editor actions (ids from keymaps/intellij-macos.ts). */
import { EditorSelection, type EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
-import { indentSelection, moveLineDown, moveLineUp, toggleBlockComment } from "@codemirror/commands";
+import { indentSelection, moveLineDown, moveLineUp } from "@codemirror/commands";
import { foldEffect, syntaxTree, toggleFold } from "@codemirror/language";
import { acceptCompletion, completionStatus } from "@codemirror/autocomplete";
import { executeAction, registerActions, type Action } from "@/platform/actions";
@@ -9,6 +9,7 @@ import { getContext } from "@/platform/context";
import { editorController, useGotoLine, useTabs } from "./controller";
import {
addCaretsToLineEnds,
+ commentBlock,
commentLine,
completeStatement,
deleteLine,
@@ -661,7 +662,7 @@ export function registerEditorActions(): () => void {
"color scheme",
]),
text("code.commentLine", "Comment with Line Comment", (v) => commentLine(v)),
- text("code.commentBlock", "Comment with Block Comment", (v) => toggleBlockComment(v)),
+ text("code.commentBlock", "Comment with Block Comment", (v) => commentBlock(v)),
text("code.duplicateLine", "Duplicate Line or Selection", (v) => duplicateLineOrSelection(v)),
text("code.deleteLine", "Delete Line", (v) => deleteLine(v)),
text("code.moveLineUp", "Move Line Up", (v) => moveLineUp(v)),
diff --git a/src/editor/core/cm/comments.ts b/src/editor/core/cm/comments.ts
index 4509a61f2..6c67f7a84 100644
--- a/src/editor/core/cm/comments.ts
+++ b/src/editor/core/cm/comments.ts
@@ -1,5 +1,6 @@
/**
- * Comment with Line Comment and Enter inside a line comment, as in IDEA.
+ * Comment with Line Comment, Comment with Block Comment and Enter inside a
+ * line comment, as in IDEA.
*/
import {
EditorSelection,
@@ -11,7 +12,7 @@ import {
} from "@codemirror/state";
import type { Command, EditorView } from "@codemirror/view";
import { indentString, syntaxTree } from "@codemirror/language";
-import { toggleComment } from "@codemirror/commands";
+import { toggleBlockComment, toggleComment } from "@codemirror/commands";
import { offsetAtVisibleColumn, visibleColumn } from "./visibleColumn";
/** Code Style › › Code Generation › Comment Code. */
@@ -76,7 +77,7 @@ export const commentLine: Command = (view) => {
lineToken(state, doc.line(b.startLine).from + leadingWs(doc.line(b.startLine).text)),
);
// Languages without line comments (CSS, HTML…): block comments.
- if (tokens.some((t) => !t)) return toggleComment(view);
+ if (tokens.some((t) => !t)) return blockCommentLines(view);
let allCommented = true;
let commentedNonEmpty = false;
@@ -195,6 +196,164 @@ function minIndent(state: EditorState, startLine: number, endLine: number, token
return min ?? 0;
}
+// --- Block comments ---------------------------------------------------------------
+
+interface CommentPair {
+ open: string;
+ close: string;
+}
+
+/** The block comment token pairs the language offers at `pos`, the more specific first. */
+function commentPairs(state: EditorState, pos: number): CommentPair[] {
+ const pairs: CommentPair[] = [];
+ for (const data of state.languageDataAt<{ block?: CommentPair }>("commentTokens", pos)) {
+ const block = data?.block;
+ if (!block?.open || !block.close) continue;
+ if (!pairs.some((p) => p.open === block.open && p.close === block.close)) pairs.push(block);
+ }
+ return pairs;
+}
+
+const BLOCK_SEARCH = 50;
+
+/** A block comment around a range: the tokens' own positions, and how much of their margin to take. */
+interface BlockComment {
+ open: number;
+ openMargin: number;
+ close: number;
+ closeMargin: number;
+}
+
+/**
+ * The comment `pair` around `from..to`: the tokens just outside the range, or
+ * just inside it when the range takes the tokens in itself.
+ */
+function findBlockComment(state: EditorState, pair: CommentPair, from: number, to: number): BlockComment | null {
+ const doc = state.doc;
+ const before = state.sliceDoc(Math.max(0, from - BLOCK_SEARCH), from);
+ const after = state.sliceDoc(to, Math.min(doc.length, to + BLOCK_SEARCH));
+ const spaceBefore = /\s*$/.exec(before)![0].length;
+ const spaceAfter = /^\s*/.exec(after)![0].length;
+ const start = before.length - spaceBefore;
+ if (
+ before.slice(start - pair.open.length, start) === pair.open &&
+ after.slice(spaceAfter, spaceAfter + pair.close.length) === pair.close
+ )
+ return {
+ open: from - spaceBefore,
+ openMargin: spaceBefore ? 1 : 0,
+ close: to + spaceAfter,
+ closeMargin: spaceAfter ? 1 : 0,
+ };
+ const inside = to - from <= 2 * BLOCK_SEARCH;
+ const head = state.sliceDoc(from, inside ? to : from + BLOCK_SEARCH);
+ const tail = inside ? head : state.sliceDoc(Math.max(from, to - BLOCK_SEARCH), to);
+ const headSpace = /^\s*/.exec(head)![0].length;
+ const tailSpace = /\s*$/.exec(tail)![0].length;
+ const end = tail.length - tailSpace - pair.close.length;
+ if (
+ head.slice(headSpace, headSpace + pair.open.length) === pair.open &&
+ tail.slice(end, end + pair.close.length) === pair.close
+ )
+ return {
+ open: from + headSpace + pair.open.length,
+ openMargin: /\s/.test(head.charAt(headSpace + pair.open.length)) ? 1 : 0,
+ close: to - tailSpace - pair.close.length,
+ closeMargin: /\s/.test(tail.charAt(end - 1)) ? 1 : 0,
+ };
+ return null;
+}
+
+/** The lines the selection touches, from their first non-blank column to their end. */
+function selectedLines(state: EditorState): { from: number; to: number }[] {
+ const ranges: { from: number; to: number }[] = [];
+ const doc = state.doc;
+ for (const r of state.selection.ranges) {
+ const firstLine = doc.lineAt(r.from);
+ let lastLine = r.to <= firstLine.to ? firstLine : doc.lineAt(r.to);
+ if (lastLine.from > firstLine.from && lastLine.from === r.to)
+ lastLine = r.to === firstLine.to + 1 ? firstLine : doc.lineAt(r.to - 1);
+ const last = ranges[ranges.length - 1];
+ if (last && last.to > firstLine.from) last.to = lastLine.to;
+ else ranges.push({ from: firstLine.from + leadingWs(firstLine.text), to: lastLine.to });
+ }
+ return ranges;
+}
+
+/**
+ * Removes the block comment wrapping every one of `ranges`; false when one of
+ * them is not commented (the caller then comments instead).
+ *
+ * The pair to look for is read at both ends of a range and past its last line,
+ * and just outside matching tokens. A comment can span syntax contexts:
+ * a JSX `{/* … *\/}` is the extreme case — it sits in JSX children,
+ * while its own text parses as JavaScript, where the tokens are `/* … *\/`. The
+ * widest pair wins when several fit, so the outer comment is the one removed.
+ */
+function uncommentBlock(view: EditorView, ranges: { from: number; to: number }[]): boolean {
+ const { state } = view;
+ const doc = state.doc;
+ const comments = ranges.map((r) => {
+ const pairs = commentPairs(state, r.from);
+ for (const pos of [r.to, doc.lineAt(r.to).to])
+ for (const pair of commentPairs(state, pos))
+ if (!pairs.some((p) => p.open === pair.open && p.close === pair.close)) pairs.push(pair);
+ let best: { pair: CommentPair; comment: BlockComment } | null = null;
+ for (const pair of pairs) {
+ const comment = findBlockComment(state, pair, r.from, r.to);
+ if (comment)
+ for (const pos of [
+ Math.max(0, comment.open - pair.open.length - 1),
+ Math.min(doc.length, comment.close + pair.close.length + 1),
+ ])
+ for (const outer of commentPairs(state, pos))
+ if (!pairs.some((p) => p.open === outer.open && p.close === outer.close)) pairs.push(outer);
+ if (comment && (!best || comment.open - pair.open.length < best.comment.open - best.pair.open.length))
+ best = { pair, comment };
+ }
+ return best;
+ });
+ // End tokens from separate comments must not be removed across live code.
+ for (const done of comments) {
+ if (!done) continue;
+ const { pair, comment } = done;
+ if (!state.sliceDoc(comment.open, comment.close).includes(pair.close)) continue;
+ let node = syntaxTree(state).resolveInner(comment.open, 1);
+ while (node.parent && !/comment/i.test(node.name)) node = node.parent;
+ // A parser-confirmed nested comment can legitimately contain closing tokens.
+ if (
+ !/comment/i.test(node.name) ||
+ node.from !== comment.open - pair.open.length ||
+ node.to !== comment.close + pair.close.length
+ )
+ return true;
+ }
+ if (comments.some((c) => !c)) return false;
+
+ const changes: ChangeSpec[] = [];
+ for (const done of comments) {
+ const { pair, comment } = done!;
+ changes.push(
+ { from: comment.open - pair.open.length, to: comment.open + comment.openMargin },
+ { from: comment.close - comment.closeMargin, to: comment.close + pair.close.length },
+ );
+ }
+ view.dispatch({ changes, scrollIntoView: true, userEvent: "delete.uncomment" });
+ return true;
+}
+
+/** ⌘⇧/: block comments the selection, or takes the block comment back out. */
+export const commentBlock: Command = (view) => {
+ if (view.state.readOnly) return false;
+ const ranges = view.state.selection.ranges.map((r) => ({ from: r.from, to: r.to }));
+ return uncommentBlock(view, ranges) || toggleBlockComment(view);
+};
+
+/** ⌘/ in a language without line comments (CSS, HTML…): the block comment of the lines. */
+function blockCommentLines(view: EditorView): boolean {
+ return uncommentBlock(view, selectedLines(view.state)) || toggleComment(view);
+}
+
// --- Enter in a line comment ------------------------------------------------------
/** The start of the line comment the caret is in (after its prefix), else -1. */
diff --git a/src/editor/core/cm/editing.test.ts b/src/editor/core/cm/editing.test.ts
index 326134312..a9b6edcd6 100644
--- a/src/editor/core/cm/editing.test.ts
+++ b/src/editor/core/cm/editing.test.ts
@@ -15,7 +15,7 @@ import {
} from "./editing";
import { batchLanguage, commentTokens, findLanguageDescription } from "./languages";
import { toggleCaret } from "./multiCaret";
-import { enterInLineComment, lineCommentStyle } from "./comments";
+import { commentBlock, enterInLineComment, lineCommentStyle } from "./comments";
import { languages } from "@codemirror/language-data";
/** Brace languages in miniature: one unit deeper after a line ending in `{`. */
@@ -386,3 +386,131 @@ describe("Batch language mode and @REM comment toggle", () => {
expect(has("labelName", ":sub")).toBe(true);
});
});
+
+describe("toggle comment where the tokens depend on the syntax context", () => {
+ // A JSX comment is `{/* … */}` in JSX children, but its own text parses as
+ // JavaScript, where the tokens are `/* … */`.
+ const wrap = (body: string) => `const a = (\n \n ${body}\n
\n);`;
+
+ it("takes a JSX line comment back out, with the caret", async () => {
+ const tsx = await languages.find((l) => l.name === "TSX")!.load();
+ const v = make(wrap("|"), tsx);
+ commentLine(v);
+ expect(v.state.doc.line(3).text).toBe(" {/* */}");
+ commentLine(v);
+ expect(show(v)).toBe(wrap("|"));
+ });
+
+ it("takes a JSX line comment back out, with the line selected", async () => {
+ const tsx = await languages.find((l) => l.name === "TSX")!.load();
+ const v = make(wrap("[]"), tsx);
+ commentLine(v);
+ expect(v.state.doc.line(3).text).toBe(" {/* */}");
+ commentLine(v);
+ expect(v.state.doc.line(3).text).toBe(" ");
+ });
+
+ it("takes a JSX block comment back out after ⌘⇧/", async () => {
+ const tsx = await languages.find((l) => l.name === "TSX")!.load();
+ const v = make(wrap("[]"), tsx);
+ commentBlock(v);
+ expect(v.state.doc.line(3).text).toBe(" {/* */}");
+ commentBlock(v);
+ expect(show(v)).toBe(wrap("[]"));
+ });
+
+ it("takes a block comment back out in a language with no line comment", async () => {
+ const html = await languages.find((l) => l.name === "HTML")!.load();
+ const v = make("", html);
+ commentLine(v);
+ expect(v.state.doc.line(2).text).toBe(" ");
+ commentLine(v);
+ expect(show(v)).toBe("");
+ });
+
+ it("wraps several selected JSX lines once and unwraps them again", async () => {
+ const tsx = await languages.find((l) => l.name === "TSX")!.load();
+ const v = make("const a = (\n \n [x\n y]\n
\n);", tsx);
+ commentBlock(v);
+ expect(v.state.doc.toString()).toContain("{/* x\n y */}");
+ commentBlock(v);
+ expect(v.state.doc.toString()).toBe("const a = (\n \n x\n y\n
\n);");
+ });
+});
+
+describe("comment toggle regressions", () => {
+ const cases = [
+ ["TSX", "const a = \n [x\n y]\n
;"],
+ ["TSX", "const a = {\n [value +\n other]\n}
;"],
+ ["TSX", "[const a = 1;\nconst b = 2;]"],
+ ["HTML", ""],
+ ["CSS", "[a { color: red; }\nb { color: blue; }]"],
+ ["Vue", "\n[x\ny]\n"],
+ ];
+
+ for (const [action, command] of [
+ ["line", commentLine],
+ ["block", commentBlock],
+ ] as const) {
+ it.each(cases)(`${action} comments round trip in %s: %s`, async (language, input) => {
+ const ext = await languages.find((l) => l.name === language)!.load();
+ const v = make(input, ext);
+ const original = v.state.doc.toString();
+ expect(command(v)).toBe(true);
+ expect(v.state.doc.toString()).not.toBe(original);
+ expect(command(v)).toBe(true);
+ expect(v.state.doc.toString()).toBe(original);
+ });
+
+ it.each(["TSX", "HTML", "CSS", "Vue"])(`${action} comments round trip with multiple ranges in %s`, async (name) => {
+ const ext = await languages.find((l) => l.name === name)!.load();
+ let body = name === "CSS" ? "[a { color: red; }]\n[b { color: blue; }]" : "[x]\n[y]";
+ if (action === "line") body = body.replaceAll("[", "|").replaceAll("]", "");
+ const input = name === "TSX" ? `const a = \n${body}\n
;` : body;
+ const v = make(input, ext);
+ const original = v.state.doc.toString();
+ expect(command(v)).toBe(true);
+ expect(command(v)).toBe(true);
+ expect(v.state.doc.toString()).toBe(original);
+ expect(v.state.selection.ranges).toHaveLength(2);
+ });
+
+ it.each([
+ ["CSS", "/*", "*/"],
+ ["JavaScript", "/*", "*/"],
+ ["HTML", ""],
+ ["TSX", "{/*", "*/}"],
+ ])(`${action} comments do not join separate comments across live code in %s`, async (name, open, close) => {
+ const ext = await languages.find((l) => l.name === name)!.load();
+ for (const body of [`[${open} a ${close} b ${open} c ${close}]`, `${open} [a ${close} b ${open} c] ${close}`]) {
+ const input = name === "TSX" ? `const a = \n${body}\n
;` : body;
+ const v = make(input, ext);
+ const original = v.state.doc.toString();
+ if (name === "JavaScript" && action === "line") {
+ commentLine(v);
+ commentLine(v);
+ } else command(v);
+ expect(v.state.doc.toString()).toBe(original);
+ }
+ });
+ }
+
+ it("keeps a line-only language's block command unavailable", async () => {
+ const ext = await languages.find((l) => l.name === "Python")!.load();
+ const v = make("[a = 1\nb = 2]", ext);
+ const original = v.state.doc.toString();
+ expect(commentBlock(v)).toBe(false);
+ expect(v.state.doc.toString()).toBe(original);
+ expect(commentLine(v)).toBe(true);
+ expect(v.state.doc.toString()).not.toBe(original);
+ expect(commentLine(v)).toBe(true);
+ expect(v.state.doc.toString()).toBe(original);
+ });
+
+ it("uncomments a parser-confirmed nested block comment", async () => {
+ const ext = await languages.find((l) => l.name === "Rust")!.load();
+ const v = make("[/* a /* b */ c */]", ext);
+ expect(commentBlock(v)).toBe(true);
+ expect(v.state.doc.toString()).toBe("a /* b */ c");
+ });
+});
diff --git a/src/editor/core/cm/editing.ts b/src/editor/core/cm/editing.ts
index f7fc7311a..454cc0848 100644
--- a/src/editor/core/cm/editing.ts
+++ b/src/editor/core/cm/editing.ts
@@ -659,7 +659,7 @@ export function completeStatement(language: string): Command {
// --- comments ------------------------------------------------------------------------
-export { commentLine } from "./comments";
+export { commentBlock, commentLine } from "./comments";
// --- smart keys ------------------------------------------------------------------
diff --git a/src/features/project-tree/fs.ts b/src/features/project-tree/fs.ts
index ecceaa2d1..3d610aaf5 100644
--- a/src/features/project-tree/fs.ts
+++ b/src/features/project-tree/fs.ts
@@ -225,6 +225,17 @@ export function sampleFs(root: string): MemoryFs {
f("build.gradle.kts"),
f("settings.gradle.kts"),
f("README.md"),
+ f(
+ "App.tsx",
+ `export function App() {
+ return (
+
+ x
+
+ );
+}
+`,
+ ),
...Array.from({ length: 10_000 }, (_, i) => f(`generated/item${i + 1}.json`)),
]),
root,