diff --git a/crates/ide-core/src/editor_settings.rs b/crates/ide-core/src/editor_settings.rs index 70fab2d41..4a4d2836c 100644 --- a/crates/ide-core/src/editor_settings.rs +++ b/crates/ide-core/src/editor_settings.rs @@ -270,6 +270,8 @@ pub struct SmartKeysSettings { pub pair_quotes: bool, pub surround_selection: bool, pub tab_out_of_pair: bool, + /// Simultaneous `` editing: renaming a tag renames its matching tag. + pub simultaneous_tag_editing: bool, /// Reformat on paste: `none` | `indentBlock` | `indentEachLine`. pub reformat_on_paste: String, pub escape_on_paste: bool, @@ -289,6 +291,7 @@ impl Default for SmartKeysSettings { pair_quotes: true, surround_selection: true, tab_out_of_pair: true, + simultaneous_tag_editing: true, reformat_on_paste: "indentEachLine".into(), escape_on_paste: true, smart_indent: true, diff --git a/src/bindings.ts b/src/bindings.ts index 3952a83e4..0c9690e63 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -9605,6 +9605,8 @@ export type SmartKeysSettings = { pairQuotes?: boolean, surroundSelection?: boolean, tabOutOfPair?: boolean, + /** Simultaneous `` editing: renaming a tag renames its matching tag. */ + simultaneousTagEditing?: boolean, /** Reformat on paste: `none` | `indentBlock` | `indentEachLine`. */ reformatOnPaste?: string, escapeOnPaste?: boolean, diff --git a/src/editor/core/cm/linkedTags.test.ts b/src/editor/core/cm/linkedTags.test.ts new file mode 100644 index 000000000..129b00a7d --- /dev/null +++ b/src/editor/core/cm/linkedTags.test.ts @@ -0,0 +1,271 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { EditorSelection, EditorState, type Extension } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { history, redo, undo } from "@codemirror/commands"; +import { html } from "@codemirror/lang-html"; +import { linkedTags } from "./linkedTags"; +import { prefsExtension } from "./prefs"; +import { angular } from "./angular"; +import { astro } from "./astro"; +import { django } from "./django"; +import { jsp } from "./jsp"; +import { vueLanguage } from "./vue"; +import { validateEditorExtras } from "@/platform/editorSettings"; + +const views: EditorView[] = []; +afterEach(() => views.splice(0).forEach((v) => v.destroy())); + +const extensions: Extension = [html(), linkedTags]; + +/** `|` marks carets, `[`/`]` a selection. */ +function make(text: string, extra: Extension = []) { + return makeWith([EditorState.allowMultipleSelections.of(true), extensions, extra], text); +} + +/** Like `make`, with the full extension list spelled out. */ +function makeWith(extensions: Extension, text: string) { + const ranges: ReturnType[] = []; + let doc = ""; + let anchor = -1; + for (const ch of text) { + if (ch === "|") ranges.push(EditorSelection.cursor(doc.length)); + else if (ch === "[") anchor = doc.length; + else if (ch === "]") ranges.push(EditorSelection.range(anchor, doc.length)); + else doc += ch; + } + const view = new EditorView({ + state: EditorState.create({ + doc, + selection: EditorSelection.create(ranges.length ? ranges : [EditorSelection.cursor(0)]), + extensions, + }), + parent: document.body, + }); + views.push(view); + return view; +} + +function show(view: EditorView): string { + const { doc, selection } = view.state; + let out = doc.toString(); + for (const r of [...selection.ranges].sort((a, b) => b.from - a.from)) + out = r.empty + ? out.slice(0, r.head) + "|" + out.slice(r.head) + : out.slice(0, r.from) + "[" + out.slice(r.from, r.to) + "]" + out.slice(r.to); + return out; +} + +/** Types `text` at every caret / over every selection, as typing does. */ +function type(view: EditorView, text: string) { + const { state } = view; + view.dispatch( + state.changeByRange((r) => ({ + changes: { from: r.from, to: r.to, insert: text }, + range: EditorSelection.cursor(r.from + text.length), + })), + { userEvent: "input.type" }, + ); +} + +/** One Backspace before every caret. */ +function backspace(view: EditorView) { + const { state } = view; + view.dispatch( + state.changeByRange((r) => ({ + changes: { from: Math.max(0, r.head - 1), to: r.head }, + range: EditorSelection.cursor(Math.max(0, r.head - 1)), + })), + { userEvent: "delete.backward" }, + ); +} + +describe("linked html tags", () => { + it("renames the closing tag while the opening one is typed", () => { + const v = make('\n Content\n'); + type(v, "ision"); + expect(v.state.doc.toString()).toBe('\n Content\n'); + // The caret stays where the user typed, not in the mirrored tag. + expect(show(v)).toBe('\n Content\n'); + }); + + it("renames the opening tag while the closing one is typed", () => { + const v = make('
\n Content\n'); + type(v, "ision"); + expect(v.state.doc.toString()).toBe('\n Content\n'); + }); + + it("renames a name typed at its start", () => { + const v = make("<|div>
"); + type(v, "s"); + expect(v.state.doc.toString()).toBe(""); + }); + + it("follows deletions", () => { + const v = make(""); + backspace(v); + expect(v.state.doc.toString()).toBe(""); + backspace(v); + expect(v.state.doc.toString()).toBe(""); + }); + + it("follows a selection replaced with a new name", () => { + const v = make("<[div]>x"); + type(v, "article"); + expect(v.state.doc.toString()).toBe("
x
"); + expect(show(v)).toBe("x"); + }); + + it("pairs the outer tags of nested same-name elements", () => { + const v = make("
\n
inner
\n"); + type(v, "s"); + expect(v.state.doc.toString()).toBe("\n
inner
\n
"); + }); + + it("pairs the inner tags of nested same-name elements", () => { + const v = make("
\n inner
\n
"); + type(v, "s"); + expect(v.state.doc.toString()).toBe("
\n inner\n
"); + }); + + it("renames two elements with one caret each", () => { + const v = make("one

\ntwo"); + type(v, "x"); + expect(v.state.doc.toString()).toBe("one\ntwo"); + }); + + it("leaves both tags alone when the caret edits both at once", () => { + const v = make(""); + type(v, "s"); + expect(v.state.doc.toString()).toBe(""); + }); + + it("mirrors only the caret inside a tag name", () => { + const v = make("one\nplain| text\n

"); + type(v, "x"); + expect(v.state.doc.toString()).toBe("one\nplainx text\n"); + }); + + it("skips rather than guesses with two carets in one name", () => { + const v = make(""); + type(v, "x"); + expect(v.state.doc.toString()).toBe(""); + }); + + it("does not touch other tags or attributes", () => { + const v = make('
\n keep\n
'); + type(v, "x"); + expect(v.state.doc.toString()).toBe('
\n keep\n
'); + }); + + it("skips void and self-closing tags", () => { + const v = make("

before

\n\n
"); + type(v, "x"); + expect(v.state.doc.toString()).toBe("

before

\n\n
"); + const selfClosing = make("

before

\n"); + type(selfClosing, "x"); + expect(selfClosing.state.doc.toString()).toBe("

before

\n"); + }); + + it("skips unclosed elements and stray close tags", () => { + const v = make("unclosed"); + type(v, "x"); + expect(v.state.doc.toString()).toBe("unclosed"); + const stray = make("

before

\n
"); + type(stray, "x"); + expect(stray.state.doc.toString()).toBe("

before

\n
"); + }); + + it("keeps a partially typed tag unmirrored", () => { + const v = make(" { + // `/` would make the open tag self-closing; `>` would end it: neither extends the name. + const v = make(""); + type(v, "/"); + expect(v.state.doc.toString()).toBe("
"); + const closing = make("
content"); + type(closing, ">"); + expect(closing.state.doc.toString()).toBe("
contentv>"); + }); + + it("undoes the pair as one step and redoes it", () => { + const v = make("
", history()); + type(v, "x"); + expect(v.state.doc.toString()).toBe(""); + expect(undo(v)).toBe(true); + expect(v.state.doc.toString()).toBe("
"); + expect(redo(v)).toBe(true); + expect(v.state.doc.toString()).toBe(""); + }); + + // Every mode whose outer parser is `@lezer/html`: renaming an opening tag follows into + // the closing one (prefs.ts enables the filter for exactly these language ids). + describe("in the html-based modes", () => { + const modes: [string, () => Extension][] = [ + ["Vue", () => vueLanguage()], + ["Angular Template", () => angular()], + ["JSP", () => jsp()], + ["Django Template", () => django()], + ["Astro", () => astro()], + ]; + + for (const [name, language] of modes) { + it(`renames the closing tag while the opening one is typed in ${name}`, () => { + const v = makeWith([language(), linkedTags], 'x
'); + type(v, "s"); + expect(v.state.doc.toString()).toBe('x'); + }); + } + }); + + describe("the smart key gates the filter", () => { + /** The real wiring: prefsExtension attaches the filter for the document's language. */ + const prefs = (language: string | undefined, smartKeys: Record = {}) => + makeWith( + [ + EditorState.allowMultipleSelections.of(true), + // A parseable tree: the real editors attach the mode's parser beside prefsExtension. + html(), + prefsExtension({ + prefs: validateEditorExtras({ smartKeys }), + path: "", + softWrap: false, + showWhitespace: false, + hardWrapAt: 120, + readOnly: false, + language, + }), + ], + "", + ); + + it("links tags for plain html", () => { + const v = prefs("html"); + type(v, "s"); + expect(v.state.doc.toString()).toBe(""); + }); + + it("links tags for the html-based language ids", () => { + for (const language of ["vue", "jsp", "django-html", "astro"]) { + const v = prefs(language); + type(v, "s"); + expect(v.state.doc.toString()).toBe(""); + } + }); + + it("stays off when the option is off", () => { + const v = prefs("html", { simultaneousTagEditing: false }); + type(v, "s"); + expect(v.state.doc.toString()).toBe(""); + }); + + it("stays off for the token-based erb mode", () => { + const v = prefs("erb"); + type(v, "s"); + expect(v.state.doc.toString()).toBe(""); + }); + }); +}); diff --git a/src/editor/core/cm/linkedTags.ts b/src/editor/core/cm/linkedTags.ts new file mode 100644 index 000000000..ebb88cdff --- /dev/null +++ b/src/editor/core/cm/linkedTags.ts @@ -0,0 +1,84 @@ +/** + * Linked editing for matching HTML tags, as in IDEA's Simultaneous + * `` editing (on by default) and VS Code's Linked Editing: editing + * the name in an opening (or closing) tag renames the matching tag of the same + * element as you type. Done from the syntax tree — `@lezer/html`'s + * `OpenTag`/`CloseTag` elements, shared by every HTML-based mode — so it works + * without a language server. + * + * A transaction whose change lies inside one tag's `TagName` is extended with + * the same edit at the same offset in the counterpart's name — one + * transaction, so undo takes both back together. Without a matching tag (void + * and self-closing tags, unclosed elements, a stray close tag) or with the two + * names already different (malformed markup) nothing follows: the tags stay as + * they are. + */ +import { EditorState, type Extension, type Transaction } from "@codemirror/state"; +import { syntaxTree } from "@codemirror/language"; +import type { SyntaxNode, Tree } from "@lezer/common"; +import { isExternalChange } from "@/editor/api"; + +/** The `TagName` a changed range edits — an insertion at either edge extends the name. */ +function tagNameAround(tree: Tree, from: number, to: number): SyntaxNode | null { + let n = tree.resolveInner(from, -1); + if (n.name !== "TagName") n = tree.resolveInner(from, 1); + return n.name === "TagName" && n.from <= from && to <= n.to ? n : null; +} + +/** The matching tag's name of the same element, or null without one. */ +function counterpartName(name: SyntaxNode): SyntaxNode | null { + const tag = name.parent; + const element = tag?.parent; + if (!tag || element?.name !== "Element") return null; + const other = element.getChild( + /^Open/.test(tag.name) ? tag.name.replace("Open", "Close") : tag.name.replace("Close", "Open"), + ); + return other?.getChild("TagName") ?? null; +} + +/** Insertions at a name's edge must extend the name; structural characters end it. */ +const NOT_NAME_CHARS = /[\s/>"'<=]/; + +/** + * Editing the name of an opening (or closing) HTML tag renames its matching + * tag, from the syntax tree — no language server. Installed while Smart Keys › + * Simultaneous `` editing is on (the default). + */ +export const linkedTags: Extension = EditorState.transactionFilter.of((tr: Transaction) => { + const state = tr.startState; + if (!tr.docChanged || state.readOnly || isExternalChange(tr)) return tr; + const tree = syntaxTree(state); + const edits: { from: number; to: number; insert: string }[] = []; + tr.changes.iterChanges((fromA, toA, _fromB, _toB, inserted) => + edits.push({ from: fromA, to: toA, insert: inserted.toString() }), + ); + const mirrored: { from: number; to: number; insert: string }[] = []; + const targets = new Set(); + for (const edit of edits) { + const name = tagNameAround(tree, edit.from, edit.to); + if (!name || NOT_NAME_CHARS.test(edit.insert)) continue; + const other = counterpartName(name); + // Names that already differ mean malformed markup: leave the tags alone. + if (!other || state.sliceDoc(name.from, name.to) !== state.sliceDoc(other.from, other.to)) continue; + // Both ends edited at once (a second caret, undo of a linked edit): already in step. + if (edits.some((e) => e.from <= other.to && other.from <= e.to)) continue; + // Two carets in one name: their offsets would not line up; skip rather than guess. + if (targets.has(other.from)) return tr; + targets.add(other.from); + mirrored.push({ + from: other.from + (edit.from - name.from), + to: other.from + (edit.to - name.from), + insert: edit.insert, + }); + } + if (!mirrored.length) return tr; + return [ + tr, + { + changes: mirrored + .map((m) => ({ from: tr.changes.mapPos(m.from, -1), to: tr.changes.mapPos(m.to, 1), insert: m.insert })) + .sort((a, b) => a.from - b.from), + sequential: true, + }, + ]; +}); diff --git a/src/editor/core/cm/prefs.ts b/src/editor/core/cm/prefs.ts index 892cba026..a070d4d74 100644 --- a/src/editor/core/cm/prefs.ts +++ b/src/editor/core/cm/prefs.ts @@ -52,6 +52,7 @@ import { enterInLineComment, lineCommentStyle, type LineCommentStyle } from "./c import { enterInBlockComment, enterInDocComment } from "./docComment"; import { renderDocComments } from "./renderedDocs"; import { skipClosingQuote, smartAngleBrackets, smartBackspace, smartBackspaceApplies, tabOut } from "./smartKeys"; +import { linkedTags } from "./linkedTags"; import { stringClipboard } from "./stringClipboard"; import { filterCopiedText } from "./clipboard"; import { LINE_INSET } from "./lineInset"; @@ -480,6 +481,14 @@ export const GUTTER_ICON_GROUPS = [ { id: "colors", gutterClass: "cm-openidea-gutter-colors" }, ] as const; +/** + * Languages whose outer parser is `@lezer/html`, where simultaneous + * `` editing works from the syntax tree. Angular templates carry + * the `html` backend id (the Angular mode is picked per project), `erb` is a + * token-based mode without a syntax tree and stays out. + */ +const LINKED_TAG_LANGUAGES = new Set(["html", "vue", "jsp", "django-html", "astro"]); + export interface PrefsOptions { prefs: EditorExtras; path: string; @@ -605,6 +614,11 @@ export function prefsExtension(o: PrefsOptions): Extension { if (!k.surroundSelection) out.push(Prec.highest(noSurround)); if (k.pairQuotes) out.push(Prec.highest(skipClosingQuote)); if (k.tabOutOfPair) out.push(tabOut); + // Simultaneous `` editing, from the syntax tree: every mode whose outer + // parser is `@lezer/html` — plain HTML plus the HTML-based modes (Vue SFCs, Angular + // templates, JSP, Django and Astro; Angular templates carry the `html` backend id). + // ERB is token-based (no syntax tree), so it stays out. + if (!o.readOnly && k.simultaneousTagEditing && LINKED_TAG_LANGUAGES.has(o.language ?? "")) out.push(linkedTags); if (o.commentStyle) out.push(lineCommentStyle.of(o.commentStyle)); const keys = []; diff --git a/src/features/settings/pages/editor/general.tsx b/src/features/settings/pages/editor/general.tsx index e420ed5d9..0d1a75282 100644 --- a/src/features/settings/pages/editor/general.tsx +++ b/src/features/settings/pages/editor/general.tsx @@ -701,6 +701,7 @@ const SMART_KEYS = editorPage({ { t: "check", key: `${P}.smartKeys.pairQuotes`, label: "Insert pair quote" }, { t: "check", key: `${P}.smartKeys.surroundSelection`, label: "Surround selection on typing quote or brace" }, { t: "check", key: `${P}.smartKeys.tabOutOfPair`, label: "Jump outside closing bracket/quote with Tab" }, + { t: "check", key: `${P}.smartKeys.simultaneousTagEditing`, label: "Simultaneous editing" }, { t: "select", key: `${P}.smartKeys.reformatOnPaste`, diff --git a/src/platform/editorSettings.ts b/src/platform/editorSettings.ts index 41d5ee020..938b24489 100644 --- a/src/platform/editorSettings.ts +++ b/src/platform/editorSettings.ts @@ -159,6 +159,7 @@ export const EDITOR_EXTRA_DEFAULTS = { pairQuotes: true, surroundSelection: true, tabOutOfPair: true, + simultaneousTagEditing: true, reformatOnPaste: "indentEachLine", escapeOnPaste: true, smartIndent: true, diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json index e1dc2edbb..a610a6108 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -1436,6 +1436,7 @@ "Role:": "Role:", "The user's default role when empty": "The user's default role when empty", "Token:": "Token:", + "Simultaneous editing": "Simultaneous editing", "Single sign-on (web browser)": "Single sign-on (web browser)", "Opens your web browser to sign in with your identity provider when connecting.": "Opens your web browser to sign in with your identity provider when connecting.", "Execute Script": "Execute Script", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index f0d01ea6f..1ba341d74 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -7840,6 +7840,7 @@ "Role:": "角色:", "The user's default role when empty": "留空则使用用户的默认角色", "Token:": "令牌:", + "Simultaneous editing": "同时编辑 ", "Single sign-on (web browser)": "单点登录(网页浏览器)", "Opens your web browser to sign in with your identity provider when connecting.": "连接时打开网页浏览器,通过身份提供方登录。", "Execute Script": "执行脚本",