дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
The inspection widget and the Review Mode group float in `.cm-editor`, outside `.cm-scroller`: they neither scroll with the text nor count in `scrollWidth`, so at full horizontal scroll the last characters of a line landed under them. Reserve the corner's measured width in the lines' right padding, next to the room the fixed padding already keeps for the overview lane. Co-authored-by: yhqian <[email protected]> Co-authored-by: Claude Code <[email protected]>
405 рядки
15 KiB
TypeScript
405 рядки
15 KiB
TypeScript
/**
|
||
* The base extension set of every text editor, with compartments for the
|
||
* parts that change at runtime (language, settings, contributions).
|
||
*/
|
||
import { Compartment, EditorState, Facet, Prec, type Extension, type Range } from "@codemirror/state";
|
||
import {
|
||
Decoration,
|
||
EditorView,
|
||
dropCursor,
|
||
highlightActiveLineGutter,
|
||
highlightSpecialChars,
|
||
keymap,
|
||
lineNumbers,
|
||
tooltips,
|
||
} from "@codemirror/view";
|
||
import {
|
||
bracketMatching,
|
||
indentOnInput,
|
||
indentUnit,
|
||
syntaxHighlighting,
|
||
type LanguageSupport,
|
||
type MatchResult,
|
||
} from "@codemirror/language";
|
||
import { history, indentWithTab } from "@codemirror/commands";
|
||
import { closeBrackets, closeBracketsKeymap } from "@codemirror/autocomplete";
|
||
import { highlightSelectionMatches } from "@codemirror/search";
|
||
import type { EditorConfigProps } from "@/bindings";
|
||
import type { EditorSettings } from "@/platform/ipc";
|
||
import { editorPrefs, lineCommentStyleFor, type EditorExtras } from "@/platform/editorSettings";
|
||
import { BUNDLED_MONO_FAMILY, monoFontFamily } from "@/platform/fonts";
|
||
import { editorContributions, HEAVY_LIMIT, type EditorDocContext } from "@/editor/api";
|
||
import { distractionFreeWidth, isDistractionFree } from "@/workbench/distractionFree";
|
||
import { openideaHighlightStyle } from "./highlight";
|
||
import { openideaTheme } from "./theme";
|
||
import { caretRowHighlight } from "./caretRow";
|
||
import { changeLane, overviewLane } from "./lanes";
|
||
import { cornerInset } from "./cornerInset";
|
||
import { foldingExtension } from "./folding";
|
||
import { syntaxHighlightField } from "./syntaxHighlight";
|
||
import { indentGuides } from "./indentGuides";
|
||
import { rainbowBrackets } from "./rainbowBrackets";
|
||
import { stickyMaxLines, stickyScroll } from "./stickyScroll";
|
||
import { findExtension } from "./find";
|
||
import { codeLensExtension } from "./codeLens";
|
||
import { caretExtension, fontZoom, methodLinesField, prefsExtension, softWrapActive } from "./prefs";
|
||
import { commentTokens } from "./languages";
|
||
import { multiCaretMouse } from "./multiCaret";
|
||
import { columnModeExtension } from "./columnMode";
|
||
import { visibleGoalColumns } from "./verticalMotion";
|
||
import { tabKeymap } from "./smartKeys";
|
||
import { editorBaseKeymap } from "./baseKeymap";
|
||
import { occurrenceSearch } from "./editing";
|
||
import { gutterLineDrag } from "./gutterDrag";
|
||
import { overwriteField } from "./overwrite";
|
||
import { clipboardDocumentPath, clipboardFilesExtension } from "./clipboard";
|
||
import { clipboardHistoryExtension } from "../clipboardHistory";
|
||
import type { LanguageInfo } from "../backend";
|
||
import { tx } from "@/platform/i18n";
|
||
|
||
export const compartments = {
|
||
language: new Compartment(),
|
||
comments: new Compartment(),
|
||
indent: new Compartment(),
|
||
readOnly: new Compartment(),
|
||
wrap: new Compartment(),
|
||
sticky: new Compartment(),
|
||
font: new Compartment(),
|
||
heavy: new Compartment(),
|
||
contributions: new Compartment(),
|
||
lineNumbers: new Compartment(),
|
||
folding: new Compartment(),
|
||
caret: new Compartment(),
|
||
prefs: new Compartment(),
|
||
distractionFree: new Compartment(),
|
||
};
|
||
|
||
/** Editor-group settings of an `editor` settings object (validated, with defaults). */
|
||
export function prefsOf(settings: EditorSettings | undefined): EditorExtras {
|
||
return editorPrefs({ editor: settings });
|
||
}
|
||
|
||
export { HEAVY_LIMIT } from "@/editor/api";
|
||
|
||
export interface EffectiveIndent {
|
||
tabSize: number;
|
||
insertSpaces: boolean;
|
||
/** Indent width when it differs from the tab size (Code Style › Indent). */
|
||
indentSize?: number;
|
||
}
|
||
|
||
/** Indentation found in a file's text (Detect and use existing file indents for editing). */
|
||
export function detectIndent(text: string): { insertSpaces: boolean; size?: number } | null {
|
||
let tabs = 0;
|
||
let spaces = 0;
|
||
const deltas = new Map<number, number>();
|
||
let prev = 0;
|
||
let n = 0;
|
||
for (const line of text.split("\n", 4000)) {
|
||
if (!line.trim()) continue;
|
||
n++;
|
||
const m = /^[ \t]*/.exec(line)![0];
|
||
if (m.startsWith("\t")) tabs++;
|
||
else if (m.length >= 2) spaces++;
|
||
if (!m.includes("\t")) {
|
||
const d = m.length - prev;
|
||
if (d >= 2 && d <= 8) deltas.set(d, (deltas.get(d) ?? 0) + 1);
|
||
prev = m.length;
|
||
}
|
||
}
|
||
if (n < 2 || (tabs === 0 && spaces === 0)) return null;
|
||
if (tabs > spaces) return { insertSpaces: false };
|
||
let best: number | undefined;
|
||
for (const [d, c] of deltas)
|
||
if (best === undefined || c > deltas.get(best)! || (c === deltas.get(best)! && d < best)) best = d;
|
||
return { insertSpaces: true, size: best };
|
||
}
|
||
|
||
/**
|
||
* Indentation: `.editorconfig` first (when EditorConfig support is on), then the
|
||
* file's detected indents, then Code Style (per-language override, language
|
||
* definition, defaults).
|
||
*/
|
||
export function effectiveIndent(
|
||
settings: EditorSettings | undefined,
|
||
info: LanguageInfo | undefined,
|
||
editorconfig?: EditorConfigProps | null,
|
||
detected?: { insertSpaces: boolean; size?: number } | null,
|
||
): EffectiveIndent {
|
||
const prefs = prefsOf(settings);
|
||
const lang = info ? prefs.codeStyle.languages[info.id] : undefined;
|
||
const fallback = info?.indent
|
||
? { tabSize: info.indent.tabSize, insertSpaces: info.indent.insertSpaces }
|
||
: { tabSize: settings?.tabSize ?? 4, insertSpaces: settings?.insertSpaces ?? true };
|
||
let base: EffectiveIndent = {
|
||
tabSize: lang?.tabSize ?? fallback.tabSize,
|
||
insertSpaces: lang?.useTab !== undefined ? !lang.useTab : fallback.insertSpaces,
|
||
};
|
||
const indentSize = lang?.indent;
|
||
if (indentSize !== undefined && indentSize !== base.tabSize) base.indentSize = indentSize;
|
||
if (detected && prefs.codeStyle.detectIndents) {
|
||
base = detected.insertSpaces
|
||
? { tabSize: detected.size ?? base.indentSize ?? base.tabSize, insertSpaces: true }
|
||
: { tabSize: base.tabSize, insertSpaces: false };
|
||
}
|
||
if (!editorconfig || !prefs.codeStyle.editorconfig) return base;
|
||
return {
|
||
tabSize: editorconfig.indentSize ?? editorconfig.tabWidth ?? base.tabSize,
|
||
insertSpaces: editorconfig.indentStyle ? editorconfig.indentStyle === "space" : base.insertSpaces,
|
||
};
|
||
}
|
||
|
||
/** Hard wrap column of a language (Code Style override, else General). */
|
||
export function hardWrapAt(settings: EditorSettings | undefined, info: LanguageInfo | undefined): number {
|
||
const style = prefsOf(settings).codeStyle;
|
||
return (info && style.languages[info.id]?.hardWrapAt) || style.hardWrapAt;
|
||
}
|
||
|
||
/** `format`: the indent Reformat Code asks for (see `formatIndentFacet`); `null` keeps the editor's. */
|
||
export function indentExtension(indent: EffectiveIndent, format: EffectiveIndent | null = null): Extension {
|
||
return [
|
||
formatIndentFacet.of(format),
|
||
EditorState.tabSize.of(indent.tabSize),
|
||
indentUnit.of(indent.insertSpaces ? " ".repeat(Math.max(1, indent.indentSize ?? indent.tabSize)) : "\t"),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Indentation explicit reformat requests use: the Code Style without the file's
|
||
* detected indents (detection affects editing, reformatting uses the configured
|
||
* style, as in IDEA). Set only when the language has an explicit Tabs and Indents
|
||
* override — unset, formatting keeps the editor's indent, the status bar choice
|
||
* included.
|
||
*/
|
||
export const formatIndentFacet = Facet.define<EffectiveIndent | null>({
|
||
combine: (values) => [values.find((v) => v !== null) ?? null],
|
||
});
|
||
|
||
export function fontExtension(settings: EditorSettings | undefined): Extension {
|
||
const vars: string[] = [];
|
||
const prefs = prefsOf(settings);
|
||
const family = settings?.fontFamily;
|
||
const fallback = prefs.fallbackFont.trim();
|
||
if ((family && family !== BUNDLED_MONO_FAMILY) || fallback) {
|
||
vars.push(`--openidea-editor-font: ${monoFontFamily(family, fallback, "var(--font-mono)")}`);
|
||
}
|
||
if (settings?.fontSize)
|
||
vars.push(
|
||
`--openidea-editor-font-size: ${settings.fontSize}px`,
|
||
`--openidea-editor-base-size: ${settings.fontSize}px`,
|
||
);
|
||
if (settings?.lineHeight)
|
||
vars.push(
|
||
`--openidea-editor-line-height: ${settings.lineHeight}px`,
|
||
`--openidea-editor-base-line: ${settings.lineHeight}px`,
|
||
);
|
||
const attrs: Record<string, string> = {};
|
||
if (vars.length) attrs.style = vars.join("; ");
|
||
if (prefs.ligatures) attrs.class = "cm-openidea-ligatures";
|
||
return Object.keys(attrs).length ? EditorView.editorAttributes.of(attrs) : [];
|
||
}
|
||
|
||
export function readOnlyExtension(readOnly: boolean): Extension {
|
||
return readOnly ? [EditorState.readOnly.of(true)] : [];
|
||
}
|
||
|
||
/** Soft wraps per settings (file patterns), unless toggled for the file (`override`). */
|
||
export function wrapExtension(
|
||
settings: EditorSettings | undefined,
|
||
path: string,
|
||
override?: boolean | null,
|
||
): Extension {
|
||
return (override ?? softWrapActive(settings?.softWrap, prefsOf(settings), path)) ? EditorView.lineWrapping : [];
|
||
}
|
||
|
||
export function stickyExtension(settings: EditorSettings | undefined): Extension {
|
||
return settings?.stickyScroll === false || isDistractionFree()
|
||
? []
|
||
: [stickyScroll, stickyMaxLines.of(prefsOf(settings).codeEditing.stickyLinesMax)];
|
||
}
|
||
|
||
const matchingBracket = Decoration.mark({ class: "cm-matchingBracket" });
|
||
const matchingTag = Decoration.mark({ class: "cm-matchingBracket cm-matchingTag" });
|
||
const nonmatchingBracket = Decoration.mark({ class: "cm-nonmatchingBracket" });
|
||
|
||
/** Like the default, but matched tag names (HTML / XML / JSX) get their own mark, as IDEA's MATCHED_TAG_NAME. */
|
||
function renderMatch(match: MatchResult, state: EditorState): readonly Range<Decoration>[] {
|
||
const mark = (from: number, to: number) =>
|
||
!match.matched ? nonmatchingBracket : /^[()[\]{}]$/.test(state.sliceDoc(from, to)) ? matchingBracket : matchingTag;
|
||
const out = [mark(match.start.from, match.start.to).range(match.start.from, match.start.to)];
|
||
if (match.end) out.push(mark(match.end.from, match.end.to).range(match.end.from, match.end.to));
|
||
return out;
|
||
}
|
||
|
||
export function heavyExtension(docLength: number, settings?: EditorSettings): Extension {
|
||
if (docLength > HEAVY_LIMIT) return [];
|
||
const prefs = prefsOf(settings);
|
||
return [
|
||
prefs.codeEditing.highlightBraces ? bracketMatching({ renderMatch }) : [],
|
||
prefs.appearance.indentGuides ? indentGuides : [],
|
||
prefs.rainbowBrackets ? rainbowBrackets() : [],
|
||
prefs.codeEditing.highlightSelection ? highlightSelectionMatches({ minSelectionLength: 2, maxMatches: 500 }) : [],
|
||
];
|
||
}
|
||
|
||
export function lineNumbersExtension(settings: EditorSettings | undefined): Extension {
|
||
return prefsOf(settings).appearance.lineNumbers && !isDistractionFree() ? lineNumbers() : [];
|
||
}
|
||
|
||
/**
|
||
* Distraction Free Mode: no gutter, text column centred at the configured
|
||
* width (see `cm-openidea-distractionFree` in theme.ts).
|
||
*/
|
||
export function distractionFreeExtension(
|
||
settings: EditorSettings | undefined,
|
||
active = isDistractionFree(),
|
||
): Extension {
|
||
if (!active) return [];
|
||
const columns = distractionFreeWidth(prefsOf(settings).appearance.distractionFreeWidth);
|
||
return EditorView.editorAttributes.of({
|
||
class: "cm-openidea-distractionFree",
|
||
style: `--openidea-df-columns: ${columns}`,
|
||
});
|
||
}
|
||
|
||
export function foldingFor(settings: EditorSettings | undefined): Extension {
|
||
return foldingExtension(prefsOf(settings).folding.showOutline);
|
||
}
|
||
|
||
export function caretFor(settings: EditorSettings | undefined): Extension {
|
||
return caretExtension(prefsOf(settings));
|
||
}
|
||
|
||
export function prefsFor(o: {
|
||
settings: EditorSettings | undefined;
|
||
path: string;
|
||
info: LanguageInfo | undefined;
|
||
readOnly: boolean;
|
||
wrapOverride?: boolean | null;
|
||
}): Extension {
|
||
return prefsExtension({
|
||
prefs: prefsOf(o.settings),
|
||
path: o.path,
|
||
softWrap: o.settings?.softWrap,
|
||
wrapOverride: o.wrapOverride,
|
||
showWhitespace: o.settings?.showWhitespace,
|
||
hardWrapAt: hardWrapAt(o.settings, o.info),
|
||
readOnly: o.readOnly,
|
||
distractionFree: isDistractionFree(),
|
||
language: o.info?.id,
|
||
commentStyle: lineCommentStyleFor(prefsOf(o.settings), o.info?.id),
|
||
});
|
||
}
|
||
|
||
export function contributionExtensions(ctx: EditorDocContext): Extension {
|
||
const out: Extension[] = [];
|
||
for (const c of editorContributions()) {
|
||
try {
|
||
out.push(c.extensions(ctx));
|
||
} catch (e) {
|
||
console.error(`editor contribution ${c.id} failed`, e);
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
export interface SetupOptions {
|
||
ctx: EditorDocContext;
|
||
settings: EditorSettings | undefined;
|
||
info: LanguageInfo | undefined;
|
||
language: LanguageSupport | null;
|
||
docLength: number;
|
||
/** Effective indentation of the document (detected / overridden); computed when absent. */
|
||
indent?: EffectiveIndent;
|
||
/** Indentation for Reformat Code (`formatIndentFacet`); the editor's when absent. */
|
||
formatIndent?: EffectiveIndent | null;
|
||
/** Soft wraps toggled for this file (View › Active Editor › Soft-Wrap). */
|
||
wrapOverride?: boolean | null;
|
||
/** Extra extensions owned by the slot (update listeners, focus handlers). */
|
||
extra?: Extension;
|
||
}
|
||
|
||
/**
|
||
* Tooltips (hints, docs, completion) stay inside the editor horizontally and
|
||
* below its top edge. WebKit positions them `absolute` within `.cm-editor`, so
|
||
* a window-wide right bound makes CodeMirror skip the shift left and the
|
||
* browser squeezes the box into the space left of the editor's edge.
|
||
*/
|
||
export function tooltipBounds(view: { scrollDOM: Element; dom: Element }) {
|
||
const { top } = view.scrollDOM.getBoundingClientRect();
|
||
const { left, right } = view.dom.getBoundingClientRect();
|
||
return {
|
||
top,
|
||
left: Math.max(0, left),
|
||
right: Math.min(window.innerWidth, right),
|
||
bottom: window.innerHeight,
|
||
};
|
||
}
|
||
|
||
const tooltipSpace = tooltips({ tooltipSpace: tooltipBounds });
|
||
|
||
export function baseExtensions(o: SetupOptions): Extension {
|
||
return [
|
||
tooltipSpace,
|
||
compartments.font.of(fontExtension(o.settings)),
|
||
openideaTheme,
|
||
compartments.lineNumbers.of(lineNumbersExtension(o.settings)),
|
||
// New UI order: numbers | contributed icon gutters | VCS bar | fold chevrons.
|
||
Prec.lowest(changeLane),
|
||
compartments.folding.of(foldingFor(o.settings)),
|
||
highlightActiveLineGutter(),
|
||
highlightSpecialChars(),
|
||
history(),
|
||
compartments.caret.of(caretFor(o.settings)),
|
||
dropCursor(),
|
||
EditorState.allowMultipleSelections.of(true),
|
||
multiCaretMouse,
|
||
columnModeExtension,
|
||
visibleGoalColumns,
|
||
occurrenceSearch,
|
||
overwriteField,
|
||
gutterLineDrag,
|
||
clipboardHistoryExtension,
|
||
clipboardDocumentPath.of(o.ctx.path),
|
||
clipboardFilesExtension,
|
||
indentOnInput(),
|
||
syntaxHighlightField,
|
||
syntaxHighlighting(openideaHighlightStyle),
|
||
closeBrackets(),
|
||
caretRowHighlight,
|
||
fontZoom,
|
||
methodLinesField,
|
||
findExtension(),
|
||
overviewLane,
|
||
cornerInset,
|
||
codeLensExtension,
|
||
EditorView.contentAttributes.of({
|
||
spellcheck: "false",
|
||
autocorrect: "off",
|
||
autocapitalize: "off",
|
||
"aria-label": tx`Editor for ${o.ctx.path}`,
|
||
}),
|
||
tabKeymap,
|
||
keymap.of([...closeBracketsKeymap, ...editorBaseKeymap, indentWithTab]),
|
||
compartments.language.of(o.language ?? []),
|
||
compartments.comments.of(commentTokens(o.info, o.ctx.language)),
|
||
compartments.indent.of(indentExtension(o.indent ?? effectiveIndent(o.settings, o.info), o.formatIndent)),
|
||
compartments.readOnly.of(readOnlyExtension(o.ctx.readOnly)),
|
||
compartments.wrap.of(wrapExtension(o.settings, o.ctx.path, o.wrapOverride)),
|
||
compartments.sticky.of(stickyExtension(o.settings)),
|
||
compartments.heavy.of(heavyExtension(o.docLength, o.settings)),
|
||
compartments.prefs.of(
|
||
prefsFor({
|
||
settings: o.settings,
|
||
path: o.ctx.path,
|
||
info: o.info,
|
||
readOnly: o.ctx.readOnly,
|
||
wrapOverride: o.wrapOverride,
|
||
}),
|
||
),
|
||
compartments.contributions.of(contributionExtensions(o.ctx)),
|
||
compartments.distractionFree.of(distractionFreeExtension(o.settings)),
|
||
o.extra ?? [],
|
||
];
|
||
}
|